X-Git-Url: https://osm.etsi.org/gitweb/?p=osm%2FNBI.git;a=blobdiff_plain;f=osm_nbi%2Fnbi.py;h=1354f2cae797c6e7251703841285dc94e8cee4e7;hp=9cdb409b2bd2b158e5b12f4857401101314b93ab;hb=07f3172ef24dab2c7c47c9a8e726f1f9876f13b3;hpb=c94c3df90aa64298a7935a80b221f80f3c043260;ds=inline diff --git a/osm_nbi/nbi.py b/osm_nbi/nbi.py index 9cdb409..1354f2c 100644 --- a/osm_nbi/nbi.py +++ b/osm_nbi/nbi.py @@ -7,87 +7,158 @@ import json import yaml import html_out as html import logging +import logging.handlers +import getopt +import sys + +from authconn import AuthException +from auth import Authenticator from engine import Engine, EngineException -from dbbase import DbException -from base64 import standard_b64decode -from os import getenv +from osm_common.dbbase import DbException +from osm_common.fsbase import FsException +from osm_common.msgbase import MsgException from http import HTTPStatus -from http.client import responses as http_responses from codecs import getreader -from os import environ +from os import environ, path __author__ = "Alfonso Tierno " -__version__ = "0.1" -version_date = "Feb 2018" + +# TODO consider to remove and provide version using the static version file +__version__ = "0.1.3" +version_date = "Apr 2018" +database_version = '1.0' +auth_database_version = '1.0' """ -North Bound Interface (O: OSM; S: SOL5 +North Bound Interface (O: OSM specific; 5,X: SOL005 not implemented yet; O5: SOL005 implemented) URL: /osm GET POST PUT DELETE PATCH /nsd/v1 + /ns_descriptors_content O O + / O O O O /ns_descriptors O5 O5 / O5 O5 5 /nsd_content O5 O5 + /nsd O + /artifacts[/] O /pnf_descriptors 5 5 / 5 5 5 /pnfd_content 5 5 - /subcriptions 5 5 - / 5 X + /subscriptions 5 5 + / 5 X /vnfpkgm/v1 + /vnf_packages_content O O + / O O /vnf_packages O5 O5 / O5 O5 5 - /vnfd O5 O /package_content O5 O5 /upload_from_uri X - /artifacts/ X X + /vnfd O5 + /artifacts[/] O5 + /subscriptions X X + / X X /nslcm/v1 - /ns_instances O5 O5 - / O5 O5 - TO BE COMPLETED + /ns_instances_content O O + / O O + /ns_instances 5 5 + / O5 O5 + instantiate O5 + terminate O5 + action O + scale O5 + heal 5 /ns_lcm_op_occs 5 5 / 5 5 5 TO BE COMPLETED 5 5 - /subcriptions 5 5 - / 5 X + /vnf_instances (also vnfrs for compatibility) O + / O + /subscriptions 5 5 + / 5 X + + /pdu/v1 + /pdu_descriptor O O + / O O O O + + /admin/v1 + /tokens O O + / O O + /users O O + / O O O O + /projects O O + / O O + /vims_accounts (also vims for compatibility) O O + / O O O + /sdns O O + / O O O + + /nst/v1 O O + /netslice_templates_content O O + / O O O O + /netslice_templates O O + / O O O + /nst_content O O + /nst O + /artifacts[/] O + /subscriptions X X + / X X -query string. - [....]*[.]=[,...]&... - op: "eq"(or empty to one or the values) | "neq" (to any of the values) | "gt" | "lt" | "gte" | "lte" | "cont" | "ncont" - all_fields, fields=x,y,.., exclude_default, exclude_fields=x,y,... + /nsilcm/v1 + /netslice_instances_content O O + / O O + /netslice_instances O O + / O O + instantiate O + terminate O + action O + /nsi_lcm_op_occs O O + / O O O + /subscriptions X X + / X X + +query string: + Follows SOL005 section 4.3.2 It contains extra METHOD to override http method, FORCE to force. + For filtering inside array, it must select the element of the array, or add ANYINDEX to apply the filtering over any + item of the array, that is, pass if any item of the array pass the filter. + It allows both ne and neq for not equal + TODO: 4.3.3 Attribute selectors + all_fields, fields=x,y,.., exclude_default, exclude_fields=x,y,... (none) … same as “exclude_default” all_fields … all attributes. - fields= … all attributes except all complex attributes with minimum cardinality of zero that are not conditionally mandatory, and that are not provided in . - exclude_fields= … all attributes except those complex attributes with a minimum cardinality of zero that are not conditionally mandatory, and that are provided in . - exclude_default … all attributes except those complex attributes with a minimum cardinality of zero that are not conditionally mandatory, and that are part of the "default exclude set" defined in the present specification for the particular resource - exclude_default and include= … all attributes except those complex attributes with a minimum cardinality of zero that are not conditionally mandatory and that are part of the "default exclude set" defined in the present specification for the particular resource, but that are not part of + fields= … all attributes except all complex attributes with minimum cardinality of zero that are not + conditionally mandatory, and that are not provided in . + exclude_fields= … all attributes except those complex attributes with a minimum cardinality of zero that + are not conditionally mandatory, and that are provided in . + exclude_default … all attributes except those complex attributes with a minimum cardinality of zero that are not + conditionally mandatory, and that are part of the "default exclude set" defined in the present specification for + the particular resource + exclude_default and include= … all attributes except those complex attributes with a minimum cardinality + of zero that are not conditionally mandatory and that are part of the "default exclude set" defined in the + present specification for the particular resource, but that are not part of Header field name Reference Example Descriptions Accept IETF RFC 7231 [19] application/json Content-Types that are acceptable for the response. This header field shall be present if the response is expected to have a non-empty message body. Content-Type IETF RFC 7231 [19] application/json The MIME type of the body of the request. This header field shall be present if the request has a non-empty message body. - Authorization IETF RFC 7235 [22] Bearer mF_9.B5f-4.1JqM The authorization token for the request. Details are specified in clause 4.5.3. + Authorization IETF RFC 7235 [22] Bearer mF_9.B5f-4.1JqM The authorization token for the request. + Details are specified in clause 4.5.3. Range IETF RFC 7233 [21] 1000-2000 Requested range of bytes from a file Header field name Reference Example Descriptions Content-Type IETF RFC 7231 [19] application/json The MIME type of the body of the response. This header field shall be present if the response has a non-empty message body. - Location IETF RFC 7231 [19] http://www.example.com/vnflcm/v1/vnf_instances/123 Used in redirection, or when a new resource has been created. + Location IETF RFC 7231 [19] http://www.example.com/vnflcm/v1/vnf_instances/123 Used in redirection, or when a + new resource has been created. This header field shall be present if the response status code is 201 or 3xx. - In the present document this header field is also used if the response status code is 202 and a new resource was created. - WWW-Authenticate IETF RFC 7235 [22] Bearer realm="example" Challenge if the corresponding HTTP request has not provided authorization, or error details if the corresponding HTTP request has provided an invalid authorization token. - Accept-Ranges IETF RFC 7233 [21] bytes Used by the Server to signal whether or not it supports ranges for certain resources. - Content-Range IETF RFC 7233 [21] bytes 21010-47021/ 47022 Signals the byte range that is contained in the response, and the total length of the file. + In the present document this header field is also used if the response status code is 202 and a new resource was + created. + WWW-Authenticate IETF RFC 7235 [22] Bearer realm="example" Challenge if the corresponding HTTP request has not + provided authorization, or error details if the corresponding HTTP request has provided an invalid authorization + token. + Accept-Ranges IETF RFC 7233 [21] bytes Used by the Server to signal whether or not it supports ranges for + certain resources. + Content-Range IETF RFC 7233 [21] bytes 21010-47021/ 47022 Signals the byte range that is contained in the + response, and the total length of the file. Retry-After IETF RFC 7231 [19] Fri, 31 Dec 1999 23:59:59 GMT - - or - - 120 Used to indicate how long the user agent ought to wait before making a follow-up request. - It can be used with 503 responses. - The value of this field can be an HTTP-date or a number of seconds to delay after the response is received. - - #TODO http header for partial uploads: Content-Range: "bytes 0-1199/15000". Id is returned first time and send in following chunks """ @@ -106,48 +177,137 @@ class Server(object): def __init__(self): self.instance += 1 self.engine = Engine() - - def _authorization(self): - token = None - user_passwd64 = None - try: - # 1. Get token Authorization bearer - auth = cherrypy.request.headers.get("Authorization") - if auth: - auth_list = auth.split(" ") - if auth_list[0].lower() == "bearer": - token = auth_list[-1] - elif auth_list[0].lower() == "basic": - user_passwd64 = auth_list[-1] - if not token: - if cherrypy.session.get("Authorization"): - # 2. Try using session before request a new token. If not, basic authentication will generate - token = cherrypy.session.get("Authorization") - if token == "logout": - token = None # force Unauthorized response to insert user pasword again - elif user_passwd64 and cherrypy.request.config.get("auth.allow_basic_authentication"): - # 3. Get new token from user password - user = None - passwd = None - try: - user_passwd = standard_b64decode(user_passwd64).decode() - user, _, passwd = user_passwd.partition(":") - except: - pass - outdata = self.engine.new_token(None, {"username": user, "password": passwd}) - token = outdata["id"] - cherrypy.session['Authorization'] = token - # 4. Get token from cookie - # if not token: - # auth_cookie = cherrypy.request.cookie.get("Authorization") - # if auth_cookie: - # token = auth_cookie.value - return self.engine.authorize(token) - except EngineException as e: - if cherrypy.session.get('Authorization'): - del cherrypy.session['Authorization'] - cherrypy.response.headers["WWW-Authenticate"] = 'Bearer realm="{}"'.format(e) - raise + self.authenticator = Authenticator() + self.valid_methods = { # contains allowed URL and methods + "admin": { + "v1": { + "tokens": {"METHODS": ("GET", "POST", "DELETE"), + "": {"METHODS": ("GET", "DELETE")} + }, + "users": {"METHODS": ("GET", "POST"), + "": {"METHODS": ("GET", "POST", "DELETE", "PATCH", "PUT")} + }, + "projects": {"METHODS": ("GET", "POST"), + "": {"METHODS": ("GET", "DELETE")} + }, + "vims": {"METHODS": ("GET", "POST"), + "": {"METHODS": ("GET", "DELETE", "PATCH", "PUT")} + }, + "vim_accounts": {"METHODS": ("GET", "POST"), + "": {"METHODS": ("GET", "DELETE", "PATCH", "PUT")} + }, + "sdns": {"METHODS": ("GET", "POST"), + "": {"METHODS": ("GET", "DELETE", "PATCH", "PUT")} + }, + } + }, + "pdu": { + "v1": { + "pdu_descriptors": {"METHODS": ("GET", "POST"), + "": {"METHODS": ("GET", "POST", "DELETE", "PATCH", "PUT")} + }, + } + }, + "nsd": { + "v1": { + "ns_descriptors_content": {"METHODS": ("GET", "POST"), + "": {"METHODS": ("GET", "PUT", "DELETE")} + }, + "ns_descriptors": {"METHODS": ("GET", "POST"), + "": {"METHODS": ("GET", "DELETE"), "TODO": "PATCH", + "nsd_content": {"METHODS": ("GET", "PUT")}, + "nsd": {"METHODS": "GET"}, # descriptor inside package + "artifacts": {"*": {"METHODS": "GET"}} + } + }, + "pnf_descriptors": {"TODO": ("GET", "POST"), + "": {"TODO": ("GET", "DELETE", "PATCH"), + "pnfd_content": {"TODO": ("GET", "PUT")} + } + }, + "subscriptions": {"TODO": ("GET", "POST"), + "": {"TODO": ("GET", "DELETE")} + }, + } + }, + "vnfpkgm": { + "v1": { + "vnf_packages_content": {"METHODS": ("GET", "POST"), + "": {"METHODS": ("GET", "PUT", "DELETE")} + }, + "vnf_packages": {"METHODS": ("GET", "POST"), + "": {"METHODS": ("GET", "DELETE", "PATCH"), # GET: vnfPkgInfo + "package_content": {"METHODS": ("GET", "PUT"), # package + "upload_from_uri": {"TODO": "POST"} + }, + "vnfd": {"METHODS": "GET"}, # descriptor inside package + "artifacts": {"*": {"METHODS": "GET"}} + } + }, + "subscriptions": {"TODO": ("GET", "POST"), + "": {"TODO": ("GET", "DELETE")} + }, + } + }, + "nslcm": { + "v1": { + "ns_instances_content": {"METHODS": ("GET", "POST"), + "": {"METHODS": ("GET", "DELETE")} + }, + "ns_instances": {"METHODS": ("GET", "POST"), + "": {"METHODS": ("GET", "DELETE"), + "scale": {"METHODS": "POST"}, + "terminate": {"METHODS": "POST"}, + "instantiate": {"METHODS": "POST"}, + "action": {"METHODS": "POST"}, + } + }, + "ns_lcm_op_occs": {"METHODS": "GET", + "": {"METHODS": "GET"}, + }, + "vnfrs": {"METHODS": ("GET"), + "": {"METHODS": ("GET")} + }, + "vnf_instances": {"METHODS": ("GET"), + "": {"METHODS": ("GET")} + }, + } + }, + "nst": { + "v1": { + "netslice_templates_content": {"METHODS": ("GET", "POST"), + "": {"METHODS": ("GET", "PUT", "DELETE")} + }, + "netslice_templates": {"METHODS": ("GET", "POST"), + "": {"METHODS": ("GET", "DELETE"), "TODO": "PATCH", + "nst_content": {"METHODS": ("GET", "PUT")}, + "nst": {"METHODS": "GET"}, # descriptor inside package + "artifacts": {"*": {"METHODS": "GET"}} + } + }, + "subscriptions": {"TODO": ("GET", "POST"), + "": {"TODO": ("GET", "DELETE")} + }, + } + }, + "nsilcm": { + "v1": { + "netslice_instances_content": {"METHODS": ("GET", "POST"), + "": {"METHODS": ("GET", "DELETE")} + }, + "netslice_instances": {"METHODS": ("GET", "POST"), + "": {"METHODS": ("GET", "DELETE"), + "terminate": {"METHODS": "POST"}, + "instantiate": {"METHODS": "POST"}, + "action": {"METHODS": "POST"}, + } + }, + "nsi_lcm_op_occs": {"METHODS": "GET", + "": {"METHODS": "GET"}, + }, + } + }, + } def _format_in(self, kwargs): try: @@ -159,19 +319,22 @@ class Server(object): if "application/json" in cherrypy.request.headers["Content-Type"]: error_text = "Invalid json format " indata = json.load(self.reader(cherrypy.request.body)) + cherrypy.request.headers.pop("Content-File-MD5", None) elif "application/yaml" in cherrypy.request.headers["Content-Type"]: error_text = "Invalid yaml format " indata = yaml.load(cherrypy.request.body) + cherrypy.request.headers.pop("Content-File-MD5", None) elif "application/binary" in cherrypy.request.headers["Content-Type"] or \ "application/gzip" in cherrypy.request.headers["Content-Type"] or \ - "application/zip" in cherrypy.request.headers["Content-Type"]: - indata = cherrypy.request.body.read() + "application/zip" in cherrypy.request.headers["Content-Type"] or \ + "text/plain" in cherrypy.request.headers["Content-Type"]: + indata = cherrypy.request.body # .read() elif "multipart/form-data" in cherrypy.request.headers["Content-Type"]: if "descriptor_file" in kwargs: filecontent = kwargs.pop("descriptor_file") if not filecontent.file: raise NbiException("empty file or content", HTTPStatus.BAD_REQUEST) - indata = filecontent.file.read() + indata = filecontent.file # .read() if filecontent.content_type.value: cherrypy.request.headers["Content-Type"] = filecontent.content_type.value else: @@ -180,16 +343,14 @@ class Server(object): # 'application/yaml' for input format are available") error_text = "Invalid yaml format " indata = yaml.load(cherrypy.request.body) + cherrypy.request.headers.pop("Content-File-MD5", None) else: error_text = "Invalid yaml format " indata = yaml.load(cherrypy.request.body) + cherrypy.request.headers.pop("Content-File-MD5", None) if not indata: indata = {} - if "METHOD" in kwargs: - method = kwargs.pop("METHOD") - else: - method = cherrypy.request.method format_yaml = False if cherrypy.request.headers.get("Query-String-Format") == "yaml": format_yaml = True @@ -201,15 +362,15 @@ class Server(object): elif format_yaml: try: kwargs[k] = yaml.load(v) - except: + except Exception: pass elif k.endswith(".gt") or k.endswith(".lt") or k.endswith(".gte") or k.endswith(".lte"): try: kwargs[k] = int(v) - except: + except Exception: try: kwargs[k] = float(v) - except: + except Exception: pass elif v.find(",") > 0: kwargs[k] = v.split(",") @@ -220,25 +381,42 @@ class Server(object): elif format_yaml: try: v[index] = yaml.load(v[index]) - except: + except Exception: pass - return indata, method + return indata except (ValueError, yaml.YAMLError) as exc: raise NbiException(error_text + str(exc), HTTPStatus.BAD_REQUEST) except KeyError as exc: raise NbiException("Query string error: " + str(exc), HTTPStatus.BAD_REQUEST) + except Exception as exc: + raise NbiException(error_text + str(exc), HTTPStatus.BAD_REQUEST) @staticmethod - def _format_out(data, session=None): + def _format_out(data, session=None, _format=None): """ return string of dictionary data according to requested json, yaml, xml. By default json - :param data: response to be sent. Can be a dict or text + :param data: response to be sent. Can be a dict, text or file :param session: + :param _format: The format to be set as Content-Type ir data is a file :return: None """ - if "Accept" in cherrypy.request.headers: - accept = cherrypy.request.headers["Accept"] + accept = cherrypy.request.headers.get("Accept") + if data is None: + if accept and "text/html" in accept: + return html.format(data, cherrypy.request, cherrypy.response, session) + # cherrypy.response.status = HTTPStatus.NO_CONTENT.value + return + elif hasattr(data, "read"): # file object + if _format: + cherrypy.response.headers["Content-Type"] = _format + elif "b" in data.mode: # binariy asssumig zip + cherrypy.response.headers["Content-Type"] = 'application/zip' + else: + cherrypy.response.headers["Content-Type"] = 'text/plain' + # TODO check that cherrypy close file. If not implement pending things to close per thread next + return data + if accept: if "application/json" in accept: cherrypy.response.headers["Content-Type"] = 'application/json; charset=utf-8' a = json.dumps(data, indent=4) + "\n" @@ -246,7 +424,7 @@ class Server(object): elif "text/html" in accept: return html.format(data, cherrypy.request, cherrypy.response, session) - elif "application/yaml" in accept or "*/*" in accept: + elif "application/yaml" in accept or "*/*" in accept or "text/plain" in accept: pass else: raise cherrypy.HTTPError(HTTPStatus.NOT_ACCEPTABLE.value, @@ -261,57 +439,72 @@ class Server(object): session = None try: if cherrypy.request.method == "GET": - session = self._authorization() + session = self.authenticator.authorize() outdata = "Index page" else: raise cherrypy.HTTPError(HTTPStatus.METHOD_NOT_ALLOWED.value, - "Method {} not allowed for tokens".format(cherrypy.request.method)) + "Method {} not allowed for tokens".format(cherrypy.request.method)) return self._format_out(outdata, session) - except EngineException as e: + except (EngineException, AuthException) as e: cherrypy.log("index Exception {}".format(e)) cherrypy.response.status = e.http_code.value return self._format_out("Welcome to OSM!", session) @cherrypy.expose - def token(self, *args, **kwargs): - if not args: - raise NbiException("URL must contain at least 'item/version'", HTTPStatus.METHOD_NOT_ALLOWED) - version = args[0] - if version != 'v1': - raise NbiException("URL version '{}' not supported".format(version), HTTPStatus.METHOD_NOT_ALLOWED) + def version(self, *args, **kwargs): + # TODO consider to remove and provide version using the static version file + global __version__, version_date + try: + if cherrypy.request.method != "GET": + raise NbiException("Only method GET is allowed", HTTPStatus.METHOD_NOT_ALLOWED) + elif args or kwargs: + raise NbiException("Invalid URL or query string for version", HTTPStatus.METHOD_NOT_ALLOWED) + return __version__ + " " + version_date + except NbiException as e: + cherrypy.response.status = e.http_code.value + problem_details = { + "code": e.http_code.name, + "status": e.http_code.value, + "detail": str(e), + } + return self._format_out(problem_details, None) + + @cherrypy.expose + def token(self, method, token_id=None, kwargs=None): session = None # self.engine.load_dbase(cherrypy.request.app.config) + indata = self._format_in(kwargs) + if not isinstance(indata, dict): + raise NbiException("Expected application/yaml or application/json Content-Type", HTTPStatus.BAD_REQUEST) try: - indata, method = self._format_in(kwargs) if method == "GET": - session = self._authorization() - if len(args) >= 2: - outdata = self.engine.get_token(session, args[1]) + session = self.authenticator.authorize() + if token_id: + outdata = self.authenticator.get_token(session, token_id) else: - outdata = self.engine.get_token_list(session) + outdata = self.authenticator.get_token_list(session) elif method == "POST": try: - session = self._authorization() - except: + session = self.authenticator.authorize() + except Exception: session = None if kwargs: indata.update(kwargs) - outdata = self.engine.new_token(session, indata, cherrypy.request.remote) + outdata = self.authenticator.new_token(session, indata, cherrypy.request.remote) session = outdata cherrypy.session['Authorization'] = outdata["_id"] + self._set_location_header("admin", "v1", "tokens", outdata["_id"]) # cherrypy.response.cookie["Authorization"] = outdata["id"] # cherrypy.response.cookie["Authorization"]['expires'] = 3600 elif method == "DELETE": - if len(args) >= 2 and "logout" not in args: - token_id = args[1] - elif "id" in kwargs: + if not token_id and "id" in kwargs: token_id = kwargs["id"] - else: - session = self._authorization() + elif not token_id: + session = self.authenticator.authorize() token_id = session["_id"] - outdata = self.engine.del_token(token_id) + outdata = self.authenticator.del_token(token_id) session = None cherrypy.session['Authorization'] = "logout" # cherrypy.response.cookie["Authorization"] = token_id @@ -319,7 +512,7 @@ class Server(object): else: raise NbiException("Method {} not allowed for token".format(method), HTTPStatus.METHOD_NOT_ALLOWED) return self._format_out(outdata, session) - except (NbiException, EngineException, DbException) as e: + except (NbiException, EngineException, DbException, AuthException) as e: cherrypy.log("tokens Exception {}".format(e)) cherrypy.response.status = e.http_code.value problem_details = { @@ -332,7 +525,11 @@ class Server(object): @cherrypy.expose def test(self, *args, **kwargs): thread_info = None - if args and args[0] == "init": + if args and args[0] == "help": + return "
\ninit\nfile/  download file\ndb-clear/table\nprune\nlogin\nlogin2\n"\
+                   "sleep/
" + + elif args and args[0] == "init": try: # self.engine.load_dbase(cherrypy.request.app.config) self.engine.create_admin() @@ -340,6 +537,17 @@ class Server(object): except Exception: cherrypy.response.status = HTTPStatus.FORBIDDEN.value return self._format_out("Database already initialized") + elif args and args[0] == "file": + return cherrypy.lib.static.serve_file(cherrypy.tree.apps['/osm'].config["storage"]["path"] + "/" + args[1], + "text/plain", "attachment") + elif args and args[0] == "file2": + f_path = cherrypy.tree.apps['/osm'].config["storage"]["path"] + "/" + args[1] + f = open(f_path, "r") + cherrypy.response.headers["Content-type"] = "text/plain" + return f + + elif len(args) == 2 and args[0] == "db-clear": + return self.engine.db.del_list(args[1], kwargs) elif args and args[0] == "prune": return self.engine.prune() elif args and args[0] == "login": @@ -362,13 +570,22 @@ class Server(object): time.sleep(sleep_time) # thread_info elif len(args) >= 2 and args[0] == "message": - topic = args[1] + main_topic = args[1] + return_text = "
{} ->\n".format(main_topic)
             try:
-                for k, v in kwargs.items():
-                    self.engine.msg.write(topic, k, yaml.load(v))
-                return "ok"
+                if cherrypy.request.method == 'POST':
+                    to_send = yaml.load(cherrypy.request.body)
+                    for k, v in to_send.items():
+                        self.engine.msg.write(main_topic, k, v)
+                        return_text += "  {}: {}\n".format(k, v)
+                elif cherrypy.request.method == 'GET':
+                    for k, v in kwargs.items():
+                        self.engine.msg.write(main_topic, k, yaml.load(v))
+                        return_text += "  {}: {}\n".format(k, yaml.load(v))
             except Exception as e:
-                return "Error: " + format(e)
+                return_text += "Error: " + str(e)
+            return_text += "
\n" + return return_text return_text = ( "
\nheaders:\n  args: {}\n".format(args) +
@@ -390,89 +607,254 @@ class Server(object):
         return_text += "
" return return_text + def _check_valid_url_method(self, method, *args): + if len(args) < 3: + raise NbiException("URL must contain at least 'main_topic/version/topic'", HTTPStatus.METHOD_NOT_ALLOWED) + + reference = self.valid_methods + for arg in args: + if arg is None: + break + if not isinstance(reference, dict): + raise NbiException("URL contains unexpected extra items '{}'".format(arg), + HTTPStatus.METHOD_NOT_ALLOWED) + + if arg in reference: + reference = reference[arg] + elif "" in reference: + reference = reference[""] + elif "*" in reference: + reference = reference["*"] + break + else: + raise NbiException("Unexpected URL item {}".format(arg), HTTPStatus.METHOD_NOT_ALLOWED) + if "TODO" in reference and method in reference["TODO"]: + raise NbiException("Method {} not supported yet for this URL".format(method), HTTPStatus.NOT_IMPLEMENTED) + elif "METHODS" in reference and method not in reference["METHODS"]: + raise NbiException("Method {} not supported for this URL".format(method), HTTPStatus.METHOD_NOT_ALLOWED) + return + + @staticmethod + def _set_location_header(main_topic, version, topic, id): + """ + Insert response header Location with the URL of created item base on URL params + :param main_topic: + :param version: + :param topic: + :param id: + :return: None + """ + # Use cherrypy.request.base for absoluted path and make use of request.header HOST just in case behind aNAT + cherrypy.response.headers["Location"] = "/osm/{}/{}/{}/{}".format(main_topic, version, topic, id) + return + @cherrypy.expose - def default(self, *args, **kwargs): + def default(self, main_topic=None, version=None, topic=None, _id=None, item=None, *args, **kwargs): + session = None + outdata = None + _format = None + method = "DONE" + engine_topic = None + rollback = [] session = None try: - if not args or len(args) < 2: - raise NbiException("URL must contain at least 'item/version'", HTTPStatus.METHOD_NOT_ALLOWED) - item = args[0] - version = args[1] - if item not in ("token", "user", "project", "vnfpkgm", "nsd", "nslcm"): - raise NbiException("URL item '{}' not supported".format(item), HTTPStatus.METHOD_NOT_ALLOWED) + if not main_topic or not version or not topic: + raise NbiException("URL must contain at least 'main_topic/version/topic'", + HTTPStatus.METHOD_NOT_ALLOWED) + if main_topic not in ("admin", "vnfpkgm", "nsd", "nslcm", "nst", "nsilcm"): + raise NbiException("URL main_topic '{}' not supported".format(main_topic), + HTTPStatus.METHOD_NOT_ALLOWED) if version != 'v1': raise NbiException("URL version '{}' not supported".format(version), HTTPStatus.METHOD_NOT_ALLOWED) - # self.engine.load_dbase(cherrypy.request.app.config) - session = self._authorization() - indata, method = self._format_in(kwargs) - _id = None - - if item == "nsd": - item = "nsds" - if len(args) < 3 or args[2] != "ns_descriptors": - raise NbiException("only ns_descriptors is allowed", HTTPStatus.METHOD_NOT_ALLOWED) - if len(args) > 3: - _id = args[3] - if len(args) > 4 and args[4] != "nsd_content": - raise NbiException("only nsd_content is allowed", HTTPStatus.METHOD_NOT_ALLOWED) - elif item == "vnfpkgm": - item = "vnfds" - if len(args) < 3 or args[2] != "vnf_packages": - raise NbiException("only vnf_packages is allowed", HTTPStatus.METHOD_NOT_ALLOWED) - if len(args) > 3: - _id = args[3] - if len(args) > 4 and args[4] not in ("vnfd", "package_content"): - raise NbiException("only vnfd or package_content are allowed", HTTPStatus.METHOD_NOT_ALLOWED) - elif item == "nslcm": - item = "nsrs" - if len(args) < 3 or args[2] != "ns_instances": - raise NbiException("only ns_instances is allowed", HTTPStatus.METHOD_NOT_ALLOWED) - if len(args) > 3: - _id = args[3] - if len(args) > 4: - raise NbiException("This feature is not implemented", HTTPStatus.METHOD_NOT_ALLOWED) + if kwargs and "METHOD" in kwargs and kwargs["METHOD"] in ("PUT", "POST", "DELETE", "GET", "PATCH"): + method = kwargs.pop("METHOD") + else: + method = cherrypy.request.method + if kwargs and "FORCE" in kwargs: + force = kwargs.pop("FORCE") else: - if len(args) >= 3: - _id = args[2] - item += "s" + force = False + self._check_valid_url_method(method, main_topic, version, topic, _id, item, *args) + if main_topic == "admin" and topic == "tokens": + return self.token(method, _id, kwargs) + + # self.engine.load_dbase(cherrypy.request.app.config) + session = self.authenticator.authorize() + indata = self._format_in(kwargs) + engine_topic = topic + if topic == "subscriptions": + engine_topic = main_topic + "_" + topic + if item: + engine_topic = item + + if main_topic == "nsd": + engine_topic = "nsds" + elif main_topic == "vnfpkgm": + engine_topic = "vnfds" + elif main_topic == "nslcm": + engine_topic = "nsrs" + if topic == "ns_lcm_op_occs": + engine_topic = "nslcmops" + if topic == "vnfrs" or topic == "vnf_instances": + engine_topic = "vnfrs" + elif main_topic == "nst": + engine_topic = "nsts" + elif main_topic == "nsilcm": + engine_topic = "nsis" + if topic == "nsi_lcm_op_occs": + engine_topic = "nsilcmops" + elif main_topic == "pdu": + engine_topic = "pdus" + if engine_topic == "vims": # TODO this is for backward compatibility, it will remove in the future + engine_topic = "vim_accounts" if method == "GET": - if not _id: - outdata = self.engine.get_item_list(session, item, kwargs) - else: # len(args) > 1 - outdata = self.engine.get_item(session, item, _id) + if item in ("nsd_content", "package_content", "artifacts", "vnfd", "nsd", "nst", "nst_content"): + if item in ("vnfd", "nsd", "nst"): + path = "$DESCRIPTOR" + elif args: + path = args + elif item == "artifacts": + path = () + else: + path = None + file, _format = self.engine.get_file(session, engine_topic, _id, path, + cherrypy.request.headers.get("Accept")) + outdata = file + elif not _id: + outdata = self.engine.get_item_list(session, engine_topic, kwargs) + else: + outdata = self.engine.get_item(session, engine_topic, _id) elif method == "POST": - id, completed = self.engine.new_item(session, item, indata, kwargs, cherrypy.request.headers) - if not completed: - cherrypy.response.headers["Transaction-Id"] = id - cherrypy.response.status = HTTPStatus.CREATED.value + if topic in ("ns_descriptors_content", "vnf_packages_content", "netslice_templates_content"): + _id = cherrypy.request.headers.get("Transaction-Id") + if not _id: + _id = self.engine.new_item(rollback, session, engine_topic, {}, None, cherrypy.request.headers, + force=force) + completed = self.engine.upload_content(session, engine_topic, _id, indata, kwargs, + cherrypy.request.headers, force=force) + if completed: + self._set_location_header(main_topic, version, topic, _id) + else: + cherrypy.response.headers["Transaction-Id"] = _id + outdata = {"id": _id} + elif topic == "ns_instances_content": + # creates NSR + _id = self.engine.new_item(rollback, session, engine_topic, indata, kwargs, force=force) + # creates nslcmop + indata["lcmOperationType"] = "instantiate" + indata["nsInstanceId"] = _id + self.engine.new_item(rollback, session, "nslcmops", indata, None) + self._set_location_header(main_topic, version, topic, _id) + outdata = {"id": _id} + elif topic == "ns_instances" and item: + indata["lcmOperationType"] = item + indata["nsInstanceId"] = _id + _id = self.engine.new_item(rollback, session, "nslcmops", indata, kwargs) + self._set_location_header(main_topic, version, "ns_lcm_op_occs", _id) + outdata = {"id": _id} + cherrypy.response.status = HTTPStatus.ACCEPTED.value + elif topic == "netslice_instances_content": + # creates NetSlice_Instance_record (NSIR) + _id = self.engine.new_item(rollback, session, engine_topic, indata, kwargs, force=force) + self._set_location_header(main_topic, version, topic, _id) + indata["lcmOperationType"] = "instantiate" + indata["nsiInstanceId"] = _id + self.engine.new_item(rollback, session, "nsilcmops", indata, kwargs) + outdata = {"id": _id} + + elif topic == "netslice_instances" and item: + indata["lcmOperationType"] = item + indata["nsiInstanceId"] = _id + _id = self.engine.new_item(rollback, session, "nsilcmops", indata, kwargs) + self._set_location_header(main_topic, version, "nsi_lcm_op_occs", _id) + outdata = {"id": _id} + cherrypy.response.status = HTTPStatus.ACCEPTED.value else: - cherrypy.response.headers["Location"] = cherrypy.request.base + "/osm/" + "/".join(args[0:3]) + "/" + id - outdata = {"id": id} + _id = self.engine.new_item(rollback, session, engine_topic, indata, kwargs, + cherrypy.request.headers, force=force) + self._set_location_header(main_topic, version, topic, _id) + outdata = {"id": _id} + # TODO form NsdInfo when topic in ("ns_descriptors", "vnf_packages") + cherrypy.response.status = HTTPStatus.CREATED.value + elif method == "DELETE": if not _id: - outdata = self.engine.del_item_list(session, item, kwargs) + outdata = self.engine.del_item_list(session, engine_topic, kwargs) + cherrypy.response.status = HTTPStatus.OK.value else: # len(args) > 1 - outdata = self.engine.del_item(session, item, _id) - elif method == "PUT": - if not _id: - raise NbiException("Missing '/' at the URL to identify item to be updated", - HTTPStatus.METHOD_NOT_ALLOWED) - elif not indata and not kwargs: + if topic == "ns_instances_content" and not force: + nslcmop_desc = { + "lcmOperationType": "terminate", + "nsInstanceId": _id, + "autoremove": True + } + opp_id = self.engine.new_item(rollback, session, "nslcmops", nslcmop_desc, None) + outdata = {"_id": opp_id} + cherrypy.response.status = HTTPStatus.ACCEPTED.value + elif topic == "netslice_instances_content" and not force: + nsilcmop_desc = { + "lcmOperationType": "terminate", + "nsiInstanceId": _id, + "autoremove": True + } + opp_id = self.engine.new_item(rollback, session, "nsilcmops", nsilcmop_desc, None) + outdata = {"_id": opp_id} + cherrypy.response.status = HTTPStatus.ACCEPTED.value + else: + self.engine.del_item(session, engine_topic, _id, force) + cherrypy.response.status = HTTPStatus.NO_CONTENT.value + if engine_topic in ("vim_accounts", "sdns"): + cherrypy.response.status = HTTPStatus.ACCEPTED.value + + elif method in ("PUT", "PATCH"): + outdata = None + if not indata and not kwargs: raise NbiException("Nothing to update. Provide payload and/or query string", HTTPStatus.BAD_REQUEST) - outdata = {"id": self.engine.edit_item(session, item, args[1], indata, kwargs)} + if item in ("nsd_content", "package_content", "nst_content") and method == "PUT": + completed = self.engine.upload_content(session, engine_topic, _id, indata, kwargs, + cherrypy.request.headers, force=force) + if not completed: + cherrypy.response.headers["Transaction-Id"] = id + else: + self.engine.edit_item(session, engine_topic, _id, indata, kwargs, force=force) + cherrypy.response.status = HTTPStatus.NO_CONTENT.value else: raise NbiException("Method {} not allowed".format(method), HTTPStatus.METHOD_NOT_ALLOWED) - return self._format_out(outdata, session) - except (NbiException, EngineException, DbException) as e: - cherrypy.log("Exception {}".format(e)) - cherrypy.response.status = e.http_code.value + return self._format_out(outdata, session, _format) + except Exception as e: + if isinstance(e, (NbiException, EngineException, DbException, FsException, MsgException, AuthException)): + http_code_value = cherrypy.response.status = e.http_code.value + http_code_name = e.http_code.name + cherrypy.log("Exception {}".format(e)) + else: + http_code_value = cherrypy.response.status = HTTPStatus.BAD_REQUEST.value # INTERNAL_SERVER_ERROR + cherrypy.log("CRITICAL: Exception {}".format(e)) + http_code_name = HTTPStatus.BAD_REQUEST.name + if hasattr(outdata, "close"): # is an open file + outdata.close() + error_text = str(e) + rollback.reverse() + for rollback_item in rollback: + try: + if rollback_item.get("operation") == "set": + self.engine.db.set_one(rollback_item["topic"], {"_id": rollback_item["_id"]}, + rollback_item["content"], fail_on_empty=False) + else: + self.engine.del_item(**rollback_item, session=session, force=True) + except Exception as e2: + rollback_error_text = "Rollback Exception {}: {}".format(rollback_item, e2) + cherrypy.log(rollback_error_text) + error_text += ". " + rollback_error_text + # if isinstance(e, MsgException): + # error_text = "{} has been '{}' but other modules cannot be informed because an error on bus".format( + # engine_topic[:-1], method, error_text) problem_details = { - "code": e.http_code.name, - "status": e.http_code.value, - "detail": str(e), + "code": http_code_name, + "status": http_code_value, + "detail": error_text, } return self._format_out(problem_details, session) # raise cherrypy.HTTPError(e.http_code.value, str(e)) @@ -500,7 +882,7 @@ def _start_service(): for k, v in environ.items(): if not k.startswith("OSMNBI_"): continue - k1, _, k2 = k[7:].lower().partition("_") + k1, _, k2 = k[7:].lower().partition("_") if not k2: continue try: @@ -512,14 +894,15 @@ def _start_service(): update_dict['server.socket_port'] = int(v) elif k == 'OSMNBI_SOCKET_HOST' or k == 'OSMNBI_SERVER_HOST': update_dict['server.socket_host'] = v - elif k1 == "server": - update_dict['server' + k2] = v - # TODO add more entries - elif k1 in ("message", "database", "storage"): - if k2 == "port": + elif k1 in ("server", "test", "auth", "log"): + update_dict[k1 + '.' + k2] = v + elif k1 in ("message", "database", "storage", "authentication"): + # k2 = k2.replace('_', '.') + if k2 in ("port", "db_port"): engine_config[k1][k2] = int(v) else: engine_config[k1][k2] = v + except ValueError as e: cherrypy.log.error("Ignoring environ '{}': " + str(e)) except Exception as e: @@ -527,6 +910,7 @@ def _start_service(): if update_dict: cherrypy.config.update(update_dict) + engine_config["global"].update(update_dict) # logging cherrypy log_format_simple = "%(asctime)s %(levelname)s %(name)s %(filename)s:%(lineno)s %(message)s" @@ -536,26 +920,26 @@ def _start_service(): logger_cherry = logging.getLogger("cherrypy") logger_nbi = logging.getLogger("nbi") - if "logfile" in engine_config["global"]: - file_handler = logging.handlers.RotatingFileHandler(engine_config["global"]["logfile"], + if "log.file" in engine_config["global"]: + file_handler = logging.handlers.RotatingFileHandler(engine_config["global"]["log.file"], maxBytes=100e6, backupCount=9, delay=0) file_handler.setFormatter(log_formatter_simple) logger_cherry.addHandler(file_handler) logger_nbi.addHandler(file_handler) - else: - for format_, logger in {"nbi.server": logger_server, - "nbi.access": logger_access, - "%(name)s %(filename)s:%(lineno)s": logger_nbi - }.items(): - log_format_cherry = "%(asctime)s %(levelname)s {} %(message)s".format(format_) - log_formatter_cherry = logging.Formatter(log_format_cherry, datefmt='%Y-%m-%dT%H:%M:%S') - str_handler = logging.StreamHandler() - str_handler.setFormatter(log_formatter_cherry) - logger.addHandler(str_handler) - - if engine_config["global"].get("loglevel"): - logger_cherry.setLevel(engine_config["global"]["loglevel"]) - logger_nbi.setLevel(engine_config["global"]["loglevel"]) + # log always to standard output + for format_, logger in {"nbi.server %(filename)s:%(lineno)s": logger_server, + "nbi.access %(filename)s:%(lineno)s": logger_access, + "%(name)s %(filename)s:%(lineno)s": logger_nbi + }.items(): + log_format_cherry = "%(asctime)s %(levelname)s {} %(message)s".format(format_) + log_formatter_cherry = logging.Formatter(log_format_cherry, datefmt='%Y-%m-%dT%H:%M:%S') + str_handler = logging.StreamHandler() + str_handler.setFormatter(log_formatter_cherry) + logger.addHandler(str_handler) + + if engine_config["global"].get("log.level"): + logger_cherry.setLevel(engine_config["global"]["log.level"]) + logger_nbi.setLevel(engine_config["global"]["log.level"]) # logging other modules for k1, logname in {"message": "nbi.msg", "database": "nbi.db", "storage": "nbi.fs"}.items(): @@ -563,17 +947,16 @@ def _start_service(): logger_module = logging.getLogger(logname) if "logfile" in engine_config[k1]: file_handler = logging.handlers.RotatingFileHandler(engine_config[k1]["logfile"], - maxBytes=100e6, backupCount=9, delay=0) + maxBytes=100e6, backupCount=9, delay=0) file_handler.setFormatter(log_formatter_simple) logger_module.addHandler(file_handler) if "loglevel" in engine_config[k1]: logger_module.setLevel(engine_config[k1]["loglevel"]) # TODO add more entries, e.g.: storage cherrypy.tree.apps['/osm'].root.engine.start(engine_config) - try: - cherrypy.tree.apps['/osm'].root.engine.create_admin() - except EngineException: - pass + cherrypy.tree.apps['/osm'].root.authenticator.start(engine_config) + cherrypy.tree.apps['/osm'].root.engine.init_db(target_version=database_version) + cherrypy.tree.apps['/osm'].root.authenticator.init_db(target_version=auth_database_version) # getenv('OSMOPENMANO_TENANT', None) @@ -585,7 +968,8 @@ def _stop_service(): cherrypy.tree.apps['/osm'].root.engine.stop() cherrypy.log.error("Stopping osm_nbi") -def nbi(): + +def nbi(config_file): # conf = { # '/': { # #'request.dispatch': cherrypy.dispatch.MethodDispatcher(), @@ -605,8 +989,51 @@ def nbi(): # 'tools.auth_basic.checkpassword': validate_password}) cherrypy.engine.subscribe('start', _start_service) cherrypy.engine.subscribe('stop', _stop_service) - cherrypy.quickstart(Server(), '/osm', "nbi.cfg") + cherrypy.quickstart(Server(), '/osm', config_file) + + +def usage(): + print("""Usage: {} [options] + -c|--config [configuration_file]: loads the configuration file (default: ./nbi.cfg) + -h|--help: shows this help + """.format(sys.argv[0])) + # --log-socket-host HOST: send logs to this host") + # --log-socket-port PORT: send logs using this port (default: 9022)") if __name__ == '__main__': - nbi() + try: + # load parameters and configuration + opts, args = getopt.getopt(sys.argv[1:], "hvc:", ["config=", "help"]) + # TODO add "log-socket-host=", "log-socket-port=", "log-file=" + config_file = None + for o, a in opts: + if o in ("-h", "--help"): + usage() + sys.exit() + elif o in ("-c", "--config"): + config_file = a + # elif o == "--log-socket-port": + # log_socket_port = a + # elif o == "--log-socket-host": + # log_socket_host = a + # elif o == "--log-file": + # log_file = a + else: + assert False, "Unhandled option" + if config_file: + if not path.isfile(config_file): + print("configuration file '{}' that not exist".format(config_file), file=sys.stderr) + exit(1) + else: + for config_file in (__file__[:__file__.rfind(".")] + ".cfg", "./nbi.cfg", "/etc/osm/nbi.cfg"): + if path.isfile(config_file): + break + else: + print("No configuration file 'nbi.cfg' found neither at local folder nor at /etc/osm/", file=sys.stderr) + exit(1) + nbi(config_file) + except getopt.GetoptError as e: + print(str(e), file=sys.stderr) + # usage() + exit(1)