1 |
|
#!/usr/bin/python3 |
2 |
|
# -*- coding: utf-8 -*- |
3 |
|
|
4 |
|
## |
5 |
|
# Copyright 2020 Telefonica Investigacion y Desarrollo, S.A.U. |
6 |
|
# |
7 |
|
# Licensed under the Apache License, Version 2.0 (the "License"); |
8 |
|
# you may not use this file except in compliance with the License. |
9 |
|
# You may obtain a copy of the License at |
10 |
|
# |
11 |
|
# http://www.apache.org/licenses/LICENSE-2.0 |
12 |
|
# |
13 |
|
# Unless required by applicable law or agreed to in writing, software |
14 |
|
# distributed under the License is distributed on an "AS IS" BASIS, |
15 |
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or |
16 |
|
# implied. |
17 |
|
# See the License for the specific language governing permissions and |
18 |
|
# limitations under the License. |
19 |
|
## |
20 |
|
|
21 |
|
|
22 |
0 |
from codecs import getreader |
23 |
0 |
import getopt |
24 |
0 |
from http import HTTPStatus |
25 |
0 |
import json |
26 |
0 |
import logging |
27 |
0 |
import logging.handlers |
28 |
0 |
from os import environ, path |
29 |
0 |
import sys |
30 |
0 |
import time |
31 |
|
|
32 |
0 |
import cherrypy |
33 |
0 |
from osm_common.dbbase import DbException |
34 |
0 |
from osm_common.fsbase import FsException |
35 |
0 |
from osm_common.msgbase import MsgException |
36 |
0 |
from osm_ng_ro import version as ro_version, version_date as ro_version_date |
37 |
0 |
import osm_ng_ro.html_out as html |
38 |
0 |
from osm_ng_ro.monitor import start_monitoring, stop_monitoring |
39 |
0 |
from osm_ng_ro.ns import Ns, NsException |
40 |
0 |
from osm_ng_ro.validation import ValidationError |
41 |
0 |
from osm_ng_ro.vim_admin import VimAdminThread |
42 |
0 |
import yaml |
43 |
|
|
44 |
|
|
45 |
0 |
__author__ = "Alfonso Tierno <alfonso.tiernosepulveda@telefonica.com>" |
46 |
0 |
__version__ = "0.1." # file version, not NBI version |
47 |
0 |
version_date = "May 2020" |
48 |
|
|
49 |
0 |
database_version = "1.2" |
50 |
0 |
auth_database_version = "1.0" |
51 |
0 |
ro_server = None # instance of Server class |
52 |
0 |
vim_admin_thread = None # instance of VimAdminThread class |
53 |
|
|
54 |
|
# vim_threads = None # instance of VimThread class |
55 |
|
|
56 |
|
""" |
57 |
|
RO North Bound Interface |
58 |
|
URL: /ro GET POST PUT DELETE PATCH |
59 |
|
/ns/v1/deploy O |
60 |
|
/<nsrs_id> O O O |
61 |
|
/<action_id> O |
62 |
|
/cancel O |
63 |
|
|
64 |
|
""" |
65 |
|
|
66 |
0 |
valid_query_string = ("ADMIN", "SET_PROJECT", "FORCE", "PUBLIC") |
67 |
|
# ^ Contains possible administrative query string words: |
68 |
|
# ADMIN=True(by default)|Project|Project-list: See all elements, or elements of a project |
69 |
|
# (not owned by my session project). |
70 |
|
# PUBLIC=True(by default)|False: See/hide public elements. Set/Unset a topic to be public |
71 |
|
# FORCE=True(by default)|False: Force edition/deletion operations |
72 |
|
# SET_PROJECT=Project|Project-list: Add/Delete the topic to the projects portfolio |
73 |
|
|
74 |
0 |
valid_url_methods = { |
75 |
|
# contains allowed URL and methods, and the role_permission name |
76 |
|
"admin": { |
77 |
|
"v1": { |
78 |
|
"tokens": { |
79 |
|
"METHODS": ("POST",), |
80 |
|
"ROLE_PERMISSION": "tokens:", |
81 |
|
"<ID>": {"METHODS": ("DELETE",), "ROLE_PERMISSION": "tokens:id:"}, |
82 |
|
}, |
83 |
|
} |
84 |
|
}, |
85 |
|
"ns": { |
86 |
|
"v1": { |
87 |
|
"rebuild": { |
88 |
|
"METHODS": ("POST",), |
89 |
|
"ROLE_PERMISSION": "rebuild:", |
90 |
|
"<ID>": { |
91 |
|
"METHODS": ("POST",), |
92 |
|
"ROLE_PERMISSION": "rebuild:id:", |
93 |
|
}, |
94 |
|
}, |
95 |
|
"start": { |
96 |
|
"METHODS": ("POST",), |
97 |
|
"ROLE_PERMISSION": "start:", |
98 |
|
"<ID>": { |
99 |
|
"METHODS": ("POST",), |
100 |
|
"ROLE_PERMISSION": "start:id:", |
101 |
|
}, |
102 |
|
}, |
103 |
|
"stop": { |
104 |
|
"METHODS": ("POST",), |
105 |
|
"ROLE_PERMISSION": "stop:", |
106 |
|
"<ID>": { |
107 |
|
"METHODS": ("POST",), |
108 |
|
"ROLE_PERMISSION": "stop:id:", |
109 |
|
}, |
110 |
|
}, |
111 |
|
"deploy": { |
112 |
|
"METHODS": ("GET",), |
113 |
|
"ROLE_PERMISSION": "deploy:", |
114 |
|
"<ID>": { |
115 |
|
"METHODS": ("GET", "POST", "DELETE"), |
116 |
|
"ROLE_PERMISSION": "deploy:id:", |
117 |
|
"<ID>": { |
118 |
|
"METHODS": ("GET",), |
119 |
|
"ROLE_PERMISSION": "deploy:id:id:", |
120 |
|
"cancel": { |
121 |
|
"METHODS": ("POST",), |
122 |
|
"ROLE_PERMISSION": "deploy:id:id:cancel", |
123 |
|
}, |
124 |
|
}, |
125 |
|
}, |
126 |
|
}, |
127 |
|
"recreate": { |
128 |
|
"<ID>": { |
129 |
|
"METHODS": ("POST"), |
130 |
|
"ROLE_PERMISSION": "recreate:id:", |
131 |
|
"<ID>": { |
132 |
|
"METHODS": ("GET",), |
133 |
|
"ROLE_PERMISSION": "recreate:id:id:", |
134 |
|
}, |
135 |
|
}, |
136 |
|
}, |
137 |
|
"migrate": { |
138 |
|
"<ID>": { |
139 |
|
"METHODS": ("POST"), |
140 |
|
"ROLE_PERMISSION": "migrate:id:", |
141 |
|
"<ID>": { |
142 |
|
"METHODS": ("GET",), |
143 |
|
"ROLE_PERMISSION": "migrate:id:id:", |
144 |
|
}, |
145 |
|
}, |
146 |
|
}, |
147 |
|
"verticalscale": { |
148 |
|
"<ID>": { |
149 |
|
"METHODS": ("POST"), |
150 |
|
"ROLE_PERMISSION": "verticalscale:id:", |
151 |
|
"<ID>": { |
152 |
|
"METHODS": ("GET",), |
153 |
|
"ROLE_PERMISSION": "verticalscale:id:id:", |
154 |
|
}, |
155 |
|
}, |
156 |
|
}, |
157 |
|
} |
158 |
|
}, |
159 |
|
} |
160 |
|
|
161 |
|
|
162 |
0 |
class RoException(Exception): |
163 |
0 |
def __init__(self, message, http_code=HTTPStatus.METHOD_NOT_ALLOWED): |
164 |
0 |
Exception.__init__(self, message) |
165 |
0 |
self.http_code = http_code |
166 |
|
|
167 |
|
|
168 |
0 |
class AuthException(RoException): |
169 |
0 |
pass |
170 |
|
|
171 |
|
|
172 |
0 |
class Authenticator: |
173 |
0 |
def __init__(self, valid_url_methods, valid_query_string): |
174 |
0 |
self.valid_url_methods = valid_url_methods |
175 |
0 |
self.valid_query_string = valid_query_string |
176 |
|
|
177 |
0 |
def authorize(self, *args, **kwargs): |
178 |
0 |
return {"token": "ok", "id": "ok"} |
179 |
|
|
180 |
0 |
def new_token(self, token_info, indata, remote): |
181 |
0 |
return {"token": "ok", "id": "ok", "remote": remote} |
182 |
|
|
183 |
0 |
def del_token(self, token_id): |
184 |
0 |
pass |
185 |
|
|
186 |
0 |
def start(self, engine_config): |
187 |
0 |
pass |
188 |
|
|
189 |
|
|
190 |
0 |
class Server(object): |
191 |
0 |
instance = 0 |
192 |
|
# to decode bytes to str |
193 |
0 |
reader = getreader("utf-8") |
194 |
|
|
195 |
0 |
def __init__(self): |
196 |
0 |
self.instance += 1 |
197 |
0 |
self.authenticator = Authenticator(valid_url_methods, valid_query_string) |
198 |
0 |
self.ns = Ns() |
199 |
0 |
self.map_operation = { |
200 |
|
"token:post": self.new_token, |
201 |
|
"token:id:delete": self.del_token, |
202 |
|
"deploy:get": self.ns.get_deploy, |
203 |
|
"deploy:id:get": self.ns.get_actions, |
204 |
|
"deploy:id:post": self.ns.deploy, |
205 |
|
"deploy:id:delete": self.ns.delete, |
206 |
|
"deploy:id:id:get": self.ns.status, |
207 |
|
"deploy:id:id:cancel:post": self.ns.cancel, |
208 |
|
"rebuild:id:post": self.ns.rebuild_start_stop, |
209 |
|
"start:id:post": self.ns.rebuild_start_stop, |
210 |
|
"stop:id:post": self.ns.rebuild_start_stop, |
211 |
|
"recreate:id:post": self.ns.recreate, |
212 |
|
"recreate:id:id:get": self.ns.recreate_status, |
213 |
|
"migrate:id:post": self.ns.migrate, |
214 |
|
"verticalscale:id:post": self.ns.verticalscale, |
215 |
|
} |
216 |
|
|
217 |
0 |
def _format_in(self, kwargs): |
218 |
0 |
try: |
219 |
0 |
indata = None |
220 |
|
|
221 |
0 |
if cherrypy.request.body.length: |
222 |
0 |
error_text = "Invalid input format " |
223 |
|
|
224 |
0 |
if "Content-Type" in cherrypy.request.headers: |
225 |
0 |
if "application/json" in cherrypy.request.headers["Content-Type"]: |
226 |
0 |
error_text = "Invalid json format " |
227 |
0 |
indata = json.load(self.reader(cherrypy.request.body)) |
228 |
0 |
cherrypy.request.headers.pop("Content-File-MD5", None) |
229 |
0 |
elif "application/yaml" in cherrypy.request.headers["Content-Type"]: |
230 |
0 |
error_text = "Invalid yaml format " |
231 |
0 |
indata = yaml.safe_load(cherrypy.request.body) |
232 |
0 |
cherrypy.request.headers.pop("Content-File-MD5", None) |
233 |
0 |
elif ( |
234 |
|
"application/binary" in cherrypy.request.headers["Content-Type"] |
235 |
|
or "application/gzip" |
236 |
|
in cherrypy.request.headers["Content-Type"] |
237 |
|
or "application/zip" in cherrypy.request.headers["Content-Type"] |
238 |
|
or "text/plain" in cherrypy.request.headers["Content-Type"] |
239 |
|
): |
240 |
0 |
indata = cherrypy.request.body # .read() |
241 |
0 |
elif ( |
242 |
|
"multipart/form-data" |
243 |
|
in cherrypy.request.headers["Content-Type"] |
244 |
|
): |
245 |
0 |
if "descriptor_file" in kwargs: |
246 |
0 |
filecontent = kwargs.pop("descriptor_file") |
247 |
|
|
248 |
0 |
if not filecontent.file: |
249 |
0 |
raise RoException( |
250 |
|
"empty file or content", HTTPStatus.BAD_REQUEST |
251 |
|
) |
252 |
|
|
253 |
0 |
indata = filecontent.file # .read() |
254 |
|
|
255 |
0 |
if filecontent.content_type.value: |
256 |
0 |
cherrypy.request.headers[ |
257 |
|
"Content-Type" |
258 |
|
] = filecontent.content_type.value |
259 |
|
else: |
260 |
|
# raise cherrypy.HTTPError(HTTPStatus.Not_Acceptable, |
261 |
|
# "Only 'Content-Type' of type 'application/json' or |
262 |
|
# 'application/yaml' for input format are available") |
263 |
0 |
error_text = "Invalid yaml format " |
264 |
0 |
indata = yaml.safe_load(cherrypy.request.body) |
265 |
0 |
cherrypy.request.headers.pop("Content-File-MD5", None) |
266 |
|
else: |
267 |
0 |
error_text = "Invalid yaml format " |
268 |
0 |
indata = yaml.safe_load(cherrypy.request.body) |
269 |
0 |
cherrypy.request.headers.pop("Content-File-MD5", None) |
270 |
|
|
271 |
0 |
if not indata: |
272 |
0 |
indata = {} |
273 |
|
|
274 |
0 |
format_yaml = False |
275 |
0 |
if cherrypy.request.headers.get("Query-String-Format") == "yaml": |
276 |
0 |
format_yaml = True |
277 |
|
|
278 |
0 |
for k, v in kwargs.items(): |
279 |
0 |
if isinstance(v, str): |
280 |
0 |
if v == "": |
281 |
0 |
kwargs[k] = None |
282 |
0 |
elif format_yaml: |
283 |
0 |
try: |
284 |
0 |
kwargs[k] = yaml.safe_load(v) |
285 |
0 |
except Exception as yaml_error: |
286 |
0 |
logging.exception( |
287 |
|
f"{yaml_error} occured while parsing the yaml" |
288 |
|
) |
289 |
0 |
elif ( |
290 |
|
k.endswith(".gt") |
291 |
|
or k.endswith(".lt") |
292 |
|
or k.endswith(".gte") |
293 |
|
or k.endswith(".lte") |
294 |
|
): |
295 |
0 |
try: |
296 |
0 |
kwargs[k] = int(v) |
297 |
0 |
except Exception: |
298 |
0 |
try: |
299 |
0 |
kwargs[k] = float(v) |
300 |
0 |
except Exception as keyword_error: |
301 |
0 |
logging.exception( |
302 |
|
f"{keyword_error} occured while getting the keyword arguments" |
303 |
|
) |
304 |
0 |
elif v.find(",") > 0: |
305 |
0 |
kwargs[k] = v.split(",") |
306 |
0 |
elif isinstance(v, (list, tuple)): |
307 |
0 |
for index in range(0, len(v)): |
308 |
0 |
if v[index] == "": |
309 |
0 |
v[index] = None |
310 |
0 |
elif format_yaml: |
311 |
0 |
try: |
312 |
0 |
v[index] = yaml.safe_load(v[index]) |
313 |
0 |
except Exception as error: |
314 |
0 |
logging.exception( |
315 |
|
f"{error} occured while parsing the yaml" |
316 |
|
) |
317 |
|
|
318 |
0 |
return indata |
319 |
0 |
except (ValueError, yaml.YAMLError) as exc: |
320 |
0 |
raise RoException(error_text + str(exc), HTTPStatus.BAD_REQUEST) |
321 |
0 |
except KeyError as exc: |
322 |
0 |
raise RoException("Query string error: " + str(exc), HTTPStatus.BAD_REQUEST) |
323 |
0 |
except Exception as exc: |
324 |
0 |
raise RoException(error_text + str(exc), HTTPStatus.BAD_REQUEST) |
325 |
|
|
326 |
0 |
@staticmethod |
327 |
0 |
def _format_out(data, token_info=None, _format=None): |
328 |
|
""" |
329 |
|
return string of dictionary data according to requested json, yaml, xml. By default json |
330 |
|
:param data: response to be sent. Can be a dict, text or file |
331 |
|
:param token_info: Contains among other username and project |
332 |
|
:param _format: The format to be set as Content-Type if data is a file |
333 |
|
:return: None |
334 |
|
""" |
335 |
0 |
accept = cherrypy.request.headers.get("Accept") |
336 |
|
|
337 |
0 |
if data is None: |
338 |
0 |
if accept and "text/html" in accept: |
339 |
0 |
return html.format( |
340 |
|
data, cherrypy.request, cherrypy.response, token_info |
341 |
|
) |
342 |
|
|
343 |
|
# cherrypy.response.status = HTTPStatus.NO_CONTENT.value |
344 |
0 |
return |
345 |
0 |
elif hasattr(data, "read"): # file object |
346 |
0 |
if _format: |
347 |
0 |
cherrypy.response.headers["Content-Type"] = _format |
348 |
0 |
elif "b" in data.mode: # binariy asssumig zip |
349 |
0 |
cherrypy.response.headers["Content-Type"] = "application/zip" |
350 |
|
else: |
351 |
0 |
cherrypy.response.headers["Content-Type"] = "text/plain" |
352 |
|
|
353 |
|
# TODO check that cherrypy close file. If not implement pending things to close per thread next |
354 |
0 |
return data |
355 |
|
|
356 |
0 |
if accept: |
357 |
0 |
if "application/json" in accept: |
358 |
0 |
cherrypy.response.headers[ |
359 |
|
"Content-Type" |
360 |
|
] = "application/json; charset=utf-8" |
361 |
0 |
a = json.dumps(data, indent=4) + "\n" |
362 |
|
|
363 |
0 |
return a.encode("utf8") |
364 |
0 |
elif "text/html" in accept: |
365 |
0 |
return html.format( |
366 |
|
data, cherrypy.request, cherrypy.response, token_info |
367 |
|
) |
368 |
0 |
elif ( |
369 |
|
"application/yaml" in accept |
370 |
|
or "*/*" in accept |
371 |
|
or "text/plain" in accept |
372 |
|
): |
373 |
0 |
pass |
374 |
|
# if there is not any valid accept, raise an error. But if response is already an error, format in yaml |
375 |
0 |
elif cherrypy.response.status >= 400: |
376 |
0 |
raise cherrypy.HTTPError( |
377 |
|
HTTPStatus.NOT_ACCEPTABLE.value, |
378 |
|
"Only 'Accept' of type 'application/json' or 'application/yaml' " |
379 |
|
"for output format are available", |
380 |
|
) |
381 |
|
|
382 |
0 |
cherrypy.response.headers["Content-Type"] = "application/yaml" |
383 |
|
|
384 |
0 |
return yaml.safe_dump( |
385 |
|
data, |
386 |
|
explicit_start=True, |
387 |
|
indent=4, |
388 |
|
default_flow_style=False, |
389 |
|
tags=False, |
390 |
|
encoding="utf-8", |
391 |
|
allow_unicode=True, |
392 |
|
) # , canonical=True, default_style='"' |
393 |
|
|
394 |
0 |
@cherrypy.expose |
395 |
0 |
def index(self, *args, **kwargs): |
396 |
0 |
token_info = None |
397 |
|
|
398 |
0 |
try: |
399 |
0 |
if cherrypy.request.method == "GET": |
400 |
0 |
token_info = self.authenticator.authorize() |
401 |
0 |
outdata = token_info # Home page |
402 |
|
else: |
403 |
0 |
raise cherrypy.HTTPError( |
404 |
|
HTTPStatus.METHOD_NOT_ALLOWED.value, |
405 |
|
"Method {} not allowed for tokens".format(cherrypy.request.method), |
406 |
|
) |
407 |
|
|
408 |
0 |
return self._format_out(outdata, token_info) |
409 |
0 |
except (NsException, AuthException) as e: |
410 |
|
# cherrypy.log("index Exception {}".format(e)) |
411 |
0 |
cherrypy.response.status = e.http_code.value |
412 |
|
|
413 |
0 |
return self._format_out("Welcome to OSM!", token_info) |
414 |
|
|
415 |
0 |
@cherrypy.expose |
416 |
0 |
def version(self, *args, **kwargs): |
417 |
|
# TODO consider to remove and provide version using the static version file |
418 |
0 |
try: |
419 |
0 |
if cherrypy.request.method != "GET": |
420 |
0 |
raise RoException( |
421 |
|
"Only method GET is allowed", |
422 |
|
HTTPStatus.METHOD_NOT_ALLOWED, |
423 |
|
) |
424 |
0 |
elif args or kwargs: |
425 |
0 |
raise RoException( |
426 |
|
"Invalid URL or query string for version", |
427 |
|
HTTPStatus.METHOD_NOT_ALLOWED, |
428 |
|
) |
429 |
|
|
430 |
|
# TODO include version of other modules, pick up from some kafka admin message |
431 |
0 |
osm_ng_ro_version = {"version": ro_version, "date": ro_version_date} |
432 |
|
|
433 |
0 |
return self._format_out(osm_ng_ro_version) |
434 |
0 |
except RoException as e: |
435 |
0 |
cherrypy.response.status = e.http_code.value |
436 |
0 |
problem_details = { |
437 |
|
"code": e.http_code.name, |
438 |
|
"status": e.http_code.value, |
439 |
|
"detail": str(e), |
440 |
|
} |
441 |
|
|
442 |
0 |
return self._format_out(problem_details, None) |
443 |
|
|
444 |
0 |
def new_token(self, engine_session, indata, *args, **kwargs): |
445 |
0 |
token_info = None |
446 |
|
|
447 |
0 |
try: |
448 |
0 |
token_info = self.authenticator.authorize() |
449 |
0 |
except Exception: |
450 |
0 |
token_info = None |
451 |
|
|
452 |
0 |
if kwargs: |
453 |
0 |
indata.update(kwargs) |
454 |
|
|
455 |
|
# This is needed to log the user when authentication fails |
456 |
0 |
cherrypy.request.login = "{}".format(indata.get("username", "-")) |
457 |
0 |
token_info = self.authenticator.new_token( |
458 |
|
token_info, indata, cherrypy.request.remote |
459 |
|
) |
460 |
0 |
cherrypy.session["Authorization"] = token_info["id"] |
461 |
0 |
self._set_location_header("admin", "v1", "tokens", token_info["id"]) |
462 |
|
# for logging |
463 |
|
|
464 |
|
# cherrypy.response.cookie["Authorization"] = outdata["id"] |
465 |
|
# cherrypy.response.cookie["Authorization"]['expires'] = 3600 |
466 |
|
|
467 |
0 |
return token_info, token_info["id"], True |
468 |
|
|
469 |
0 |
def del_token(self, engine_session, indata, version, _id, *args, **kwargs): |
470 |
0 |
token_id = _id |
471 |
|
|
472 |
0 |
if not token_id and "id" in kwargs: |
473 |
0 |
token_id = kwargs["id"] |
474 |
0 |
elif not token_id: |
475 |
0 |
token_info = self.authenticator.authorize() |
476 |
|
# for logging |
477 |
0 |
token_id = token_info["id"] |
478 |
|
|
479 |
0 |
self.authenticator.del_token(token_id) |
480 |
0 |
token_info = None |
481 |
0 |
cherrypy.session["Authorization"] = "logout" |
482 |
|
# cherrypy.response.cookie["Authorization"] = token_id |
483 |
|
# cherrypy.response.cookie["Authorization"]['expires'] = 0 |
484 |
|
|
485 |
0 |
return None, None, True |
486 |
|
|
487 |
0 |
@cherrypy.expose |
488 |
0 |
def test(self, *args, **kwargs): |
489 |
0 |
if not cherrypy.config.get("server.enable_test") or ( |
490 |
|
isinstance(cherrypy.config["server.enable_test"], str) |
491 |
|
and cherrypy.config["server.enable_test"].lower() == "false" |
492 |
|
): |
493 |
0 |
cherrypy.response.status = HTTPStatus.METHOD_NOT_ALLOWED.value |
494 |
|
|
495 |
0 |
return "test URL is disabled" |
496 |
|
|
497 |
0 |
thread_info = None |
498 |
|
|
499 |
0 |
if args and args[0] == "help": |
500 |
0 |
return ( |
501 |
|
"<html><pre>\ninit\nfile/<name> download file\ndb-clear/table\nfs-clear[/folder]\nlogin\nlogin2\n" |
502 |
|
"sleep/<time>\nmessage/topic\n</pre></html>" |
503 |
|
) |
504 |
0 |
elif args and args[0] == "init": |
505 |
0 |
try: |
506 |
|
# self.ns.load_dbase(cherrypy.request.app.config) |
507 |
0 |
self.ns.create_admin() |
508 |
|
|
509 |
0 |
return "Done. User 'admin', password 'admin' created" |
510 |
0 |
except Exception: |
511 |
0 |
cherrypy.response.status = HTTPStatus.FORBIDDEN.value |
512 |
|
|
513 |
0 |
return self._format_out("Database already initialized") |
514 |
0 |
elif args and args[0] == "file": |
515 |
0 |
return cherrypy.lib.static.serve_file( |
516 |
|
cherrypy.tree.apps["/ro"].config["storage"]["path"] + "/" + args[1], |
517 |
|
"text/plain", |
518 |
|
"attachment", |
519 |
|
) |
520 |
0 |
elif args and args[0] == "file2": |
521 |
0 |
f_path = cherrypy.tree.apps["/ro"].config["storage"]["path"] + "/" + args[1] |
522 |
0 |
f = open(f_path, "r") |
523 |
0 |
cherrypy.response.headers["Content-type"] = "text/plain" |
524 |
0 |
return f |
525 |
|
|
526 |
0 |
elif len(args) == 2 and args[0] == "db-clear": |
527 |
0 |
deleted_info = self.ns.db.del_list(args[1], kwargs) |
528 |
0 |
return "{} {} deleted\n".format(deleted_info["deleted"], args[1]) |
529 |
0 |
elif len(args) and args[0] == "fs-clear": |
530 |
0 |
if len(args) >= 2: |
531 |
0 |
folders = (args[1],) |
532 |
|
else: |
533 |
0 |
folders = self.ns.fs.dir_ls(".") |
534 |
|
|
535 |
0 |
for folder in folders: |
536 |
0 |
self.ns.fs.file_delete(folder) |
537 |
|
|
538 |
0 |
return ",".join(folders) + " folders deleted\n" |
539 |
0 |
elif args and args[0] == "login": |
540 |
0 |
if not cherrypy.request.headers.get("Authorization"): |
541 |
0 |
cherrypy.response.headers[ |
542 |
|
"WWW-Authenticate" |
543 |
|
] = 'Basic realm="Access to OSM site", charset="UTF-8"' |
544 |
0 |
cherrypy.response.status = HTTPStatus.UNAUTHORIZED.value |
545 |
0 |
elif args and args[0] == "login2": |
546 |
0 |
if not cherrypy.request.headers.get("Authorization"): |
547 |
0 |
cherrypy.response.headers[ |
548 |
|
"WWW-Authenticate" |
549 |
|
] = 'Bearer realm="Access to OSM site"' |
550 |
0 |
cherrypy.response.status = HTTPStatus.UNAUTHORIZED.value |
551 |
0 |
elif args and args[0] == "sleep": |
552 |
0 |
sleep_time = 5 |
553 |
|
|
554 |
0 |
try: |
555 |
0 |
sleep_time = int(args[1]) |
556 |
0 |
except Exception: |
557 |
0 |
cherrypy.response.status = HTTPStatus.FORBIDDEN.value |
558 |
0 |
return self._format_out("Database already initialized") |
559 |
|
|
560 |
0 |
thread_info = cherrypy.thread_data |
561 |
0 |
print(thread_info) |
562 |
0 |
time.sleep(sleep_time) |
563 |
|
# thread_info |
564 |
0 |
elif len(args) >= 2 and args[0] == "message": |
565 |
0 |
main_topic = args[1] |
566 |
0 |
return_text = "<html><pre>{} ->\n".format(main_topic) |
567 |
|
|
568 |
0 |
try: |
569 |
0 |
if cherrypy.request.method == "POST": |
570 |
0 |
to_send = yaml.safe_load(cherrypy.request.body) |
571 |
0 |
for k, v in to_send.items(): |
572 |
0 |
self.ns.msg.write(main_topic, k, v) |
573 |
0 |
return_text += " {}: {}\n".format(k, v) |
574 |
0 |
elif cherrypy.request.method == "GET": |
575 |
0 |
for k, v in kwargs.items(): |
576 |
0 |
self.ns.msg.write(main_topic, k, yaml.safe_load(v)) |
577 |
0 |
return_text += " {}: {}\n".format(k, yaml.safe_load(v)) |
578 |
0 |
except Exception as e: |
579 |
0 |
return_text += "Error: " + str(e) |
580 |
|
|
581 |
0 |
return_text += "</pre></html>\n" |
582 |
|
|
583 |
0 |
return return_text |
584 |
|
|
585 |
0 |
return_text = ( |
586 |
|
"<html><pre>\nheaders:\n args: {}\n".format(args) |
587 |
|
+ " kwargs: {}\n".format(kwargs) |
588 |
|
+ " headers: {}\n".format(cherrypy.request.headers) |
589 |
|
+ " path_info: {}\n".format(cherrypy.request.path_info) |
590 |
|
+ " query_string: {}\n".format(cherrypy.request.query_string) |
591 |
|
+ " session: {}\n".format(cherrypy.session) |
592 |
|
+ " cookie: {}\n".format(cherrypy.request.cookie) |
593 |
|
+ " method: {}\n".format(cherrypy.request.method) |
594 |
|
+ " session: {}\n".format(cherrypy.session.get("fieldname")) |
595 |
|
+ " body:\n" |
596 |
|
) |
597 |
0 |
return_text += " length: {}\n".format(cherrypy.request.body.length) |
598 |
|
|
599 |
0 |
if cherrypy.request.body.length: |
600 |
0 |
return_text += " content: {}\n".format( |
601 |
|
str( |
602 |
|
cherrypy.request.body.read( |
603 |
|
int(cherrypy.request.headers.get("Content-Length", 0)) |
604 |
|
) |
605 |
|
) |
606 |
|
) |
607 |
|
|
608 |
0 |
if thread_info: |
609 |
0 |
return_text += "thread: {}\n".format(thread_info) |
610 |
|
|
611 |
0 |
return_text += "</pre></html>" |
612 |
|
|
613 |
0 |
return return_text |
614 |
|
|
615 |
0 |
@staticmethod |
616 |
0 |
def _check_valid_url_method(method, *args): |
617 |
0 |
if len(args) < 3: |
618 |
0 |
raise RoException( |
619 |
|
"URL must contain at least 'main_topic/version/topic'", |
620 |
|
HTTPStatus.METHOD_NOT_ALLOWED, |
621 |
|
) |
622 |
|
|
623 |
0 |
reference = valid_url_methods |
624 |
0 |
for arg in args: |
625 |
0 |
if arg is None: |
626 |
0 |
break |
627 |
|
|
628 |
0 |
if not isinstance(reference, dict): |
629 |
0 |
raise RoException( |
630 |
|
"URL contains unexpected extra items '{}'".format(arg), |
631 |
|
HTTPStatus.METHOD_NOT_ALLOWED, |
632 |
|
) |
633 |
|
|
634 |
0 |
if arg in reference: |
635 |
0 |
reference = reference[arg] |
636 |
0 |
elif "<ID>" in reference: |
637 |
0 |
reference = reference["<ID>"] |
638 |
0 |
elif "*" in reference: |
639 |
|
# reference = reference["*"] |
640 |
0 |
break |
641 |
|
else: |
642 |
0 |
raise RoException( |
643 |
|
"Unexpected URL item {}".format(arg), |
644 |
|
HTTPStatus.METHOD_NOT_ALLOWED, |
645 |
|
) |
646 |
|
|
647 |
0 |
if "TODO" in reference and method in reference["TODO"]: |
648 |
0 |
raise RoException( |
649 |
|
"Method {} not supported yet for this URL".format(method), |
650 |
|
HTTPStatus.NOT_IMPLEMENTED, |
651 |
|
) |
652 |
0 |
elif "METHODS" not in reference or method not in reference["METHODS"]: |
653 |
0 |
raise RoException( |
654 |
|
"Method {} not supported for this URL".format(method), |
655 |
|
HTTPStatus.METHOD_NOT_ALLOWED, |
656 |
|
) |
657 |
|
|
658 |
0 |
return reference["ROLE_PERMISSION"] + method.lower() |
659 |
|
|
660 |
0 |
@staticmethod |
661 |
0 |
def _set_location_header(main_topic, version, topic, id): |
662 |
|
""" |
663 |
|
Insert response header Location with the URL of created item base on URL params |
664 |
|
:param main_topic: |
665 |
|
:param version: |
666 |
|
:param topic: |
667 |
|
:param id: |
668 |
|
:return: None |
669 |
|
""" |
670 |
|
# Use cherrypy.request.base for absoluted path and make use of request.header HOST just in case behind aNAT |
671 |
0 |
cherrypy.response.headers["Location"] = "/ro/{}/{}/{}/{}".format( |
672 |
|
main_topic, version, topic, id |
673 |
|
) |
674 |
|
|
675 |
0 |
return |
676 |
|
|
677 |
0 |
@cherrypy.expose |
678 |
0 |
def default( |
679 |
|
self, |
680 |
|
main_topic=None, |
681 |
|
version=None, |
682 |
|
topic=None, |
683 |
|
_id=None, |
684 |
|
_id2=None, |
685 |
|
*args, |
686 |
|
**kwargs, |
687 |
|
): |
688 |
0 |
token_info = None |
689 |
0 |
outdata = None |
690 |
0 |
_format = None |
691 |
0 |
method = "DONE" |
692 |
0 |
rollback = [] |
693 |
0 |
engine_session = None |
694 |
|
|
695 |
0 |
try: |
696 |
0 |
if not main_topic or not version or not topic: |
697 |
0 |
raise RoException( |
698 |
|
"URL must contain at least 'main_topic/version/topic'", |
699 |
|
HTTPStatus.METHOD_NOT_ALLOWED, |
700 |
|
) |
701 |
|
|
702 |
0 |
if main_topic not in ( |
703 |
|
"admin", |
704 |
|
"ns", |
705 |
|
): |
706 |
0 |
raise RoException( |
707 |
|
"URL main_topic '{}' not supported".format(main_topic), |
708 |
|
HTTPStatus.METHOD_NOT_ALLOWED, |
709 |
|
) |
710 |
|
|
711 |
0 |
if version != "v1": |
712 |
0 |
raise RoException( |
713 |
|
"URL version '{}' not supported".format(version), |
714 |
|
HTTPStatus.METHOD_NOT_ALLOWED, |
715 |
|
) |
716 |
|
|
717 |
0 |
if ( |
718 |
|
kwargs |
719 |
|
and "METHOD" in kwargs |
720 |
|
and kwargs["METHOD"] in ("PUT", "POST", "DELETE", "GET", "PATCH") |
721 |
|
): |
722 |
0 |
method = kwargs.pop("METHOD") |
723 |
|
else: |
724 |
0 |
method = cherrypy.request.method |
725 |
|
|
726 |
0 |
role_permission = self._check_valid_url_method( |
727 |
|
method, main_topic, version, topic, _id, _id2, *args, **kwargs |
728 |
|
) |
729 |
|
# skip token validation if requesting a token |
730 |
0 |
indata = self._format_in(kwargs) |
731 |
|
|
732 |
0 |
if main_topic != "admin" or topic != "tokens": |
733 |
0 |
token_info = self.authenticator.authorize(role_permission, _id) |
734 |
|
|
735 |
0 |
outdata, created_id, done = self.map_operation[role_permission]( |
736 |
|
engine_session, indata, version, _id, _id2, *args, *kwargs |
737 |
|
) |
738 |
|
|
739 |
0 |
if created_id: |
740 |
0 |
self._set_location_header(main_topic, version, topic, _id) |
741 |
|
|
742 |
0 |
cherrypy.response.status = ( |
743 |
|
HTTPStatus.ACCEPTED.value |
744 |
|
if not done |
745 |
|
else HTTPStatus.OK.value |
746 |
|
if outdata is not None |
747 |
|
else HTTPStatus.NO_CONTENT.value |
748 |
|
) |
749 |
|
|
750 |
0 |
return self._format_out(outdata, token_info, _format) |
751 |
0 |
except Exception as e: |
752 |
0 |
if isinstance( |
753 |
|
e, |
754 |
|
( |
755 |
|
RoException, |
756 |
|
NsException, |
757 |
|
DbException, |
758 |
|
FsException, |
759 |
|
MsgException, |
760 |
|
AuthException, |
761 |
|
ValidationError, |
762 |
|
), |
763 |
|
): |
764 |
0 |
http_code_value = cherrypy.response.status = e.http_code.value |
765 |
0 |
http_code_name = e.http_code.name |
766 |
0 |
cherrypy.log("Exception {}".format(e)) |
767 |
|
else: |
768 |
0 |
http_code_value = ( |
769 |
|
cherrypy.response.status |
770 |
|
) = HTTPStatus.BAD_REQUEST.value # INTERNAL_SERVER_ERROR |
771 |
0 |
cherrypy.log("CRITICAL: Exception {}".format(e), traceback=True) |
772 |
0 |
http_code_name = HTTPStatus.BAD_REQUEST.name |
773 |
|
|
774 |
0 |
if hasattr(outdata, "close"): # is an open file |
775 |
0 |
outdata.close() |
776 |
|
|
777 |
0 |
error_text = str(e) |
778 |
0 |
rollback.reverse() |
779 |
|
|
780 |
0 |
for rollback_item in rollback: |
781 |
0 |
try: |
782 |
0 |
if rollback_item.get("operation") == "set": |
783 |
0 |
self.ns.db.set_one( |
784 |
|
rollback_item["topic"], |
785 |
|
{"_id": rollback_item["_id"]}, |
786 |
|
rollback_item["content"], |
787 |
|
fail_on_empty=False, |
788 |
|
) |
789 |
|
else: |
790 |
0 |
self.ns.db.del_one( |
791 |
|
rollback_item["topic"], |
792 |
|
{"_id": rollback_item["_id"]}, |
793 |
|
fail_on_empty=False, |
794 |
|
) |
795 |
0 |
except Exception as e2: |
796 |
0 |
rollback_error_text = "Rollback Exception {}: {}".format( |
797 |
|
rollback_item, e2 |
798 |
|
) |
799 |
0 |
cherrypy.log(rollback_error_text) |
800 |
0 |
error_text += ". " + rollback_error_text |
801 |
|
|
802 |
|
# if isinstance(e, MsgException): |
803 |
|
# error_text = "{} has been '{}' but other modules cannot be informed because an error on bus".format( |
804 |
|
# engine_topic[:-1], method, error_text) |
805 |
0 |
problem_details = { |
806 |
|
"code": http_code_name, |
807 |
|
"status": http_code_value, |
808 |
|
"detail": error_text, |
809 |
|
} |
810 |
|
|
811 |
0 |
return self._format_out(problem_details, token_info) |
812 |
|
# raise cherrypy.HTTPError(e.http_code.value, str(e)) |
813 |
|
finally: |
814 |
0 |
if token_info: |
815 |
0 |
if method in ("PUT", "PATCH", "POST") and isinstance(outdata, dict): |
816 |
0 |
for logging_id in ("id", "op_id", "nsilcmop_id", "nslcmop_id"): |
817 |
0 |
if outdata.get(logging_id): |
818 |
0 |
cherrypy.request.login += ";{}={}".format( |
819 |
|
logging_id, outdata[logging_id][:36] |
820 |
|
) |
821 |
|
|
822 |
|
|
823 |
0 |
def _start_service(): |
824 |
|
""" |
825 |
|
Callback function called when cherrypy.engine starts |
826 |
|
Override configuration with env variables |
827 |
|
Set database, storage, message configuration |
828 |
|
Init database with admin/admin user password |
829 |
|
""" |
830 |
|
global ro_server, vim_admin_thread |
831 |
|
# global vim_threads |
832 |
0 |
cherrypy.log.error("Starting osm_ng_ro") |
833 |
|
# update general cherrypy configuration |
834 |
0 |
update_dict = {} |
835 |
0 |
engine_config = cherrypy.tree.apps["/ro"].config |
836 |
|
|
837 |
0 |
for k, v in environ.items(): |
838 |
0 |
if not k.startswith("OSMRO_"): |
839 |
0 |
continue |
840 |
|
|
841 |
0 |
k1, _, k2 = k[6:].lower().partition("_") |
842 |
|
|
843 |
0 |
if not k2: |
844 |
0 |
continue |
845 |
|
|
846 |
0 |
try: |
847 |
0 |
if k1 in ("server", "test", "auth", "log"): |
848 |
|
# update [global] configuration |
849 |
0 |
update_dict[k1 + "." + k2] = yaml.safe_load(v) |
850 |
0 |
elif k1 == "static": |
851 |
|
# update [/static] configuration |
852 |
0 |
engine_config["/static"]["tools.staticdir." + k2] = yaml.safe_load(v) |
853 |
0 |
elif k1 == "tools": |
854 |
|
# update [/] configuration |
855 |
0 |
engine_config["/"]["tools." + k2.replace("_", ".")] = yaml.safe_load(v) |
856 |
0 |
elif k1 in ("message", "database", "storage", "authentication", "period"): |
857 |
0 |
engine_config[k1][k2] = yaml.safe_load(v) |
858 |
|
|
859 |
0 |
except Exception as e: |
860 |
0 |
raise RoException("Cannot load env '{}': {}".format(k, e)) |
861 |
|
|
862 |
0 |
if update_dict: |
863 |
0 |
cherrypy.config.update(update_dict) |
864 |
0 |
engine_config["global"].update(update_dict) |
865 |
|
|
866 |
|
# logging cherrypy |
867 |
0 |
log_format_simple = ( |
868 |
|
"%(asctime)s %(levelname)s %(name)s %(filename)s:%(lineno)s %(message)s" |
869 |
|
) |
870 |
0 |
log_formatter_simple = logging.Formatter( |
871 |
|
log_format_simple, datefmt="%Y-%m-%dT%H:%M:%S" |
872 |
|
) |
873 |
0 |
logger_server = logging.getLogger("cherrypy.error") |
874 |
0 |
logger_access = logging.getLogger("cherrypy.access") |
875 |
0 |
logger_cherry = logging.getLogger("cherrypy") |
876 |
0 |
logger = logging.getLogger("ro") |
877 |
|
|
878 |
0 |
if "log.file" in engine_config["global"]: |
879 |
0 |
file_handler = logging.handlers.RotatingFileHandler( |
880 |
|
engine_config["global"]["log.file"], maxBytes=100e6, backupCount=9, delay=0 |
881 |
|
) |
882 |
0 |
file_handler.setFormatter(log_formatter_simple) |
883 |
0 |
logger_cherry.addHandler(file_handler) |
884 |
0 |
logger.addHandler(file_handler) |
885 |
|
|
886 |
|
# log always to standard output |
887 |
0 |
for format_, logger in { |
888 |
|
"ro.server %(filename)s:%(lineno)s": logger_server, |
889 |
|
"ro.access %(filename)s:%(lineno)s": logger_access, |
890 |
|
"%(name)s %(filename)s:%(lineno)s": logger, |
891 |
|
}.items(): |
892 |
0 |
log_format_cherry = "%(asctime)s %(levelname)s {} %(message)s".format(format_) |
893 |
0 |
log_formatter_cherry = logging.Formatter( |
894 |
|
log_format_cherry, datefmt="%Y-%m-%dT%H:%M:%S" |
895 |
|
) |
896 |
0 |
str_handler = logging.StreamHandler() |
897 |
0 |
str_handler.setFormatter(log_formatter_cherry) |
898 |
0 |
logger.addHandler(str_handler) |
899 |
|
|
900 |
0 |
if engine_config["global"].get("log.level"): |
901 |
0 |
logger_cherry.setLevel(engine_config["global"]["log.level"]) |
902 |
0 |
logger.setLevel(engine_config["global"]["log.level"]) |
903 |
|
|
904 |
|
# logging other modules |
905 |
0 |
for k1, logname in { |
906 |
|
"message": "ro.msg", |
907 |
|
"database": "ro.db", |
908 |
|
"storage": "ro.fs", |
909 |
|
}.items(): |
910 |
0 |
engine_config[k1]["logger_name"] = logname |
911 |
0 |
logger_module = logging.getLogger(logname) |
912 |
|
|
913 |
0 |
if "logfile" in engine_config[k1]: |
914 |
0 |
file_handler = logging.handlers.RotatingFileHandler( |
915 |
|
engine_config[k1]["logfile"], maxBytes=100e6, backupCount=9, delay=0 |
916 |
|
) |
917 |
0 |
file_handler.setFormatter(log_formatter_simple) |
918 |
0 |
logger_module.addHandler(file_handler) |
919 |
|
|
920 |
0 |
if "loglevel" in engine_config[k1]: |
921 |
0 |
logger_module.setLevel(engine_config[k1]["loglevel"]) |
922 |
|
# TODO add more entries, e.g.: storage |
923 |
|
|
924 |
0 |
engine_config["assignment"] = {} |
925 |
|
# ^ each VIM, SDNc will be assigned one worker id. Ns class will add items and VimThread will auto-assign |
926 |
0 |
cherrypy.tree.apps["/ro"].root.ns.start(engine_config) |
927 |
0 |
cherrypy.tree.apps["/ro"].root.authenticator.start(engine_config) |
928 |
0 |
cherrypy.tree.apps["/ro"].root.ns.init_db(target_version=database_version) |
929 |
|
|
930 |
|
# # start subscriptions thread: |
931 |
0 |
vim_admin_thread = VimAdminThread(config=engine_config, engine=ro_server.ns) |
932 |
0 |
vim_admin_thread.start() |
933 |
0 |
start_monitoring(config=engine_config) |
934 |
|
|
935 |
|
# # Do not capture except SubscriptionException |
936 |
|
|
937 |
|
# backend = engine_config["authentication"]["backend"] |
938 |
|
# cherrypy.log.error("Starting OSM NBI Version '{} {}' with '{}' authentication backend" |
939 |
|
# .format(ro_version, ro_version_date, backend)) |
940 |
|
|
941 |
|
|
942 |
0 |
def _stop_service(): |
943 |
|
""" |
944 |
|
Callback function called when cherrypy.engine stops |
945 |
|
TODO: Ending database connections. |
946 |
|
""" |
947 |
|
global vim_admin_thread |
948 |
|
|
949 |
|
# terminate vim_admin_thread |
950 |
0 |
if vim_admin_thread: |
951 |
0 |
vim_admin_thread.terminate() |
952 |
0 |
stop_monitoring() |
953 |
0 |
vim_admin_thread = None |
954 |
0 |
cherrypy.tree.apps["/ro"].root.ns.stop() |
955 |
0 |
cherrypy.log.error("Stopping osm_ng_ro") |
956 |
|
|
957 |
|
|
958 |
0 |
def ro_main(config_file): |
959 |
|
global ro_server |
960 |
|
|
961 |
0 |
ro_server = Server() |
962 |
0 |
cherrypy.engine.subscribe("start", _start_service) |
963 |
0 |
cherrypy.engine.subscribe("stop", _stop_service) |
964 |
0 |
cherrypy.quickstart(ro_server, "/ro", config_file) |
965 |
|
|
966 |
|
|
967 |
0 |
def usage(): |
968 |
0 |
print( |
969 |
|
"""Usage: {} [options] |
970 |
|
-c|--config [configuration_file]: loads the configuration file (default: ./ro.cfg) |
971 |
|
-h|--help: shows this help |
972 |
|
""".format( |
973 |
|
sys.argv[0] |
974 |
|
) |
975 |
|
) |
976 |
|
# --log-socket-host HOST: send logs to this host") |
977 |
|
# --log-socket-port PORT: send logs using this port (default: 9022)") |
978 |
|
|
979 |
|
|
980 |
0 |
if __name__ == "__main__": |
981 |
0 |
try: |
982 |
|
# load parameters and configuration |
983 |
0 |
opts, args = getopt.getopt(sys.argv[1:], "hvc:", ["config=", "help"]) |
984 |
|
# TODO add "log-socket-host=", "log-socket-port=", "log-file=" |
985 |
0 |
config_file = None |
986 |
|
|
987 |
0 |
for o, a in opts: |
988 |
0 |
if o in ("-h", "--help"): |
989 |
0 |
usage() |
990 |
0 |
sys.exit() |
991 |
0 |
elif o in ("-c", "--config"): |
992 |
0 |
config_file = a |
993 |
|
else: |
994 |
0 |
raise ValueError("Unhandled option") |
995 |
|
|
996 |
0 |
if config_file: |
997 |
0 |
if not path.isfile(config_file): |
998 |
0 |
print( |
999 |
|
"configuration file '{}' that not exist".format(config_file), |
1000 |
|
file=sys.stderr, |
1001 |
|
) |
1002 |
0 |
exit(1) |
1003 |
|
else: |
1004 |
0 |
for config_file in ( |
1005 |
|
path.dirname(__file__) + "/ro.cfg", |
1006 |
|
"./ro.cfg", |
1007 |
|
"/etc/osm/ro.cfg", |
1008 |
|
): |
1009 |
0 |
if path.isfile(config_file): |
1010 |
0 |
break |
1011 |
|
else: |
1012 |
0 |
print( |
1013 |
|
"No configuration file 'ro.cfg' found neither at local folder nor at /etc/osm/", |
1014 |
|
file=sys.stderr, |
1015 |
|
) |
1016 |
0 |
exit(1) |
1017 |
|
|
1018 |
0 |
ro_main(config_file) |
1019 |
0 |
except KeyboardInterrupt: |
1020 |
0 |
print("KeyboardInterrupt. Finishing", file=sys.stderr) |
1021 |
0 |
except getopt.GetoptError as e: |
1022 |
0 |
print(str(e), file=sys.stderr) |
1023 |
|
# usage() |
1024 |
0 |
exit(1) |