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