From f27c79b67671934005fa1691158c363e2b686e77 Mon Sep 17 00:00:00 2001 From: tierno Date: Mon, 12 Mar 2018 17:08:42 +0100 Subject: [PATCH] sol005 packages upload implementation Change-Id: I7bba91831d0e29e1cd874d28a566de18eb113994 Signed-off-by: tierno --- .gitignore-common | 44 +-- osm_nbi/dbmongo.py | 18 +- osm_nbi/engine.py | 292 ++++++++++----- osm_nbi/fslocal.py | 44 ++- osm_nbi/html_out.py | 25 +- osm_nbi/nbi.py | 370 ++++++++++++++------ osm_nbi/test/cirros_ns/cirros_nsd.yaml | 49 +++ osm_nbi/test/cirros_ns/icons/osm_2x.png | Bin 0 -> 55888 bytes osm_nbi/test/cirros_vnf/cirros_vnfd.yaml | 48 +++ osm_nbi/test/cirros_vnf/icons/cirros-64.png | Bin 0 -> 4134 bytes osm_nbi/test/create-ping-pong.sh | 28 +- osm_nbi/test/delete-all.sh | 4 +- osm_nbi/test/test.py | 337 ++++++++++++++++++ 13 files changed, 1007 insertions(+), 252 deletions(-) create mode 100644 osm_nbi/test/cirros_ns/cirros_nsd.yaml create mode 100644 osm_nbi/test/cirros_ns/icons/osm_2x.png create mode 100644 osm_nbi/test/cirros_vnf/cirros_vnfd.yaml create mode 100644 osm_nbi/test/cirros_vnf/icons/cirros-64.png create mode 100755 osm_nbi/test/test.py diff --git a/.gitignore-common b/.gitignore-common index 77f6798..62bb57e 100644 --- a/.gitignore-common +++ b/.gitignore-common @@ -1,24 +1,3 @@ -## -# Copyright 2015 Telefónica Investigación y Desarrollo, S.A.U. -# This file is part of openmano -# All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. -# -# For those usages not covered by the Apache License, Version 2.0 please -# contact with: nfvlabs@tid.es -## - # This is a template with common files to be igonored, after clone make a copy to .gitignore # cp .gitignore-common .gitignore @@ -28,7 +7,7 @@ #auto-ignore .gitignore -#logs of openmano +#logs logs #pycharm @@ -41,17 +20,12 @@ logs #local stuff files that end in ".local" or folders called "local" *.local -vnfs/*.local -test/*.local -scenarios/*.local -instance-scenarios/*.local -database_utils/*.local -scripts/*.local -local -vnfs/local -test/local -scenarios/local -instance-scenarios/local -database_utils/local -scripts/local +osm_nbi/local +osm_nbi/test/local + +#local stuff files that end in ".temp" or folders called "temp" +*.temp +osm_nbi/temp +osm_nbi/test/temp + diff --git a/osm_nbi/dbmongo.py b/osm_nbi/dbmongo.py index ffab5c7..a8ea1ca 100644 --- a/osm_nbi/dbmongo.py +++ b/osm_nbi/dbmongo.py @@ -21,6 +21,7 @@ __author__ = "Alfonso Tierno " # sleep(retry) # return _retry_mongocall + class DbMongo(DbBase): conn_initial_timout = 120 conn_timout = 10 @@ -63,7 +64,7 @@ class DbMongo(DbBase): "ncont", "neq"): operator = "$" + query_k[dot_index+1:] if operator == "$neq": - operator = "$nq" + operator = "$ne" k = query_k[:dot_index] else: operator = "$eq" @@ -97,7 +98,6 @@ class DbMongo(DbBase): raise DbException("Invalid query string filter at {}:{}. Error: {}".format(query_k, v, e), http_code=HTTPStatus.BAD_REQUEST) - def get_list(self, table, filter={}): try: l = [] @@ -121,11 +121,12 @@ class DbMongo(DbBase): rows = collection.find(filter) if rows.count() == 0: if fail_on_empty: - raise DbException("Not found entry with filter='{}'".format(filter), HTTPStatus.NOT_FOUND) + raise DbException("Not found any {} with filter='{}'".format(table[:-1], filter), + HTTPStatus.NOT_FOUND) return None elif rows.count() > 1: if fail_on_more: - raise DbException("Found more than one entry with filter='{}'".format(filter), + raise DbException("Found more than one {} with filter='{}'".format(table[:-1], filter), HTTPStatus.CONFLICT) return rows[0] except Exception as e: # TODO refine @@ -147,7 +148,8 @@ class DbMongo(DbBase): rows = collection.delete_one(self._format_filter(filter)) if rows.deleted_count == 0: if fail_on_empty: - raise DbException("Not found entry with filter='{}'".format(filter), HTTPStatus.NOT_FOUND) + raise DbException("Not found any {} with filter='{}'".format(table[:-1], filter), + HTTPStatus.NOT_FOUND) return None return {"deleted": rows.deleted_count} except Exception as e: # TODO refine @@ -167,7 +169,8 @@ class DbMongo(DbBase): rows = collection.update_one(self._format_filter(filter), {"$set": update_dict}) if rows.updated_count == 0: if fail_on_empty: - raise DbException("Not found entry with filter='{}'".format(filter), HTTPStatus.NOT_FOUND) + raise DbException("Not found any {} with filter='{}'".format(table[:-1], filter), + HTTPStatus.NOT_FOUND) return None return {"deleted": rows.deleted_count} except Exception as e: # TODO refine @@ -179,7 +182,8 @@ class DbMongo(DbBase): rows = collection.replace_one({"_id": id}, indata) if rows.modified_count == 0: if fail_on_empty: - raise DbException("Not found entry with filter='{}'".format(filter), HTTPStatus.NOT_FOUND) + raise DbException("Not found any {} with filter='{}'".format(table[:-1], filter), + HTTPStatus.NOT_FOUND) return None return {"replace": rows.modified_count} except Exception as e: # TODO refine diff --git a/osm_nbi/engine.py b/osm_nbi/engine.py index c35617b..a15420f 100644 --- a/osm_nbi/engine.py +++ b/osm_nbi/engine.py @@ -29,6 +29,29 @@ class EngineException(Exception): Exception.__init__(self, message) +def _deep_update(dict_to_change, dict_reference): + """ + Modifies one dictionary with the information of the other following https://tools.ietf.org/html/rfc7396 + :param dict_to_change: Ends modified + :param dict_reference: reference + :return: none + """ + + for k in dict_reference: + if dict_reference[k] is None: # None->Anything + if k in dict_to_change: + del dict_to_change[k] + elif not isinstance(dict_reference[k], dict): # NotDict->Anything + dict_to_change[k] = dict_reference[k] + elif k not in dict_to_change: # Dict->Empty + dict_to_change[k] = deepcopy(dict_reference[k]) + _deep_update(dict_to_change[k], dict_reference[k]) + elif isinstance(dict_to_change[k], dict): # Dict->Dict + _deep_update(dict_to_change[k], dict_reference[k]) + else: # Dict->NotDict + dict_to_change[k] = deepcopy(dict_reference[k]) + _deep_update(dict_to_change[k], dict_reference[k]) + class Engine(object): def __init__(self): @@ -189,7 +212,7 @@ class Engine(object): """ Obtain the useful data removing the envelop. It goes throw the vnfd or nsd catalog and returns the vnfd or nsd content - :param item: can be vnfds, nsds, users, projects, + :param item: can be vnfds, nsds, users, projects, userDefinedData (initial content of a vnfds, nsds :param indata: Content to be inspected :return: the useful part of indata """ @@ -214,9 +237,12 @@ class Engine(object): if not isinstance(clean_indata['nsd'], list) or len(clean_indata['nsd']) != 1: raise EngineException("'nsd' must be a list only one element") clean_indata = clean_indata['nsd'][0] + elif item == "userDefinedData": + if "userDefinedData" in indata: + clean_indata = clean_indata['userDefinedData'] return clean_indata - def _validate_new_data(self, session, item, indata): + def _validate_new_data(self, session, item, indata, id=None): if item == "users": if not indata.get("username"): raise EngineException("missing 'username'", HTTPStatus.UNPROCESSABLE_ENTITY) @@ -233,8 +259,10 @@ class Engine(object): # check name not exist if self.db.get_one(item, {"name": indata.get("name")}, fail_on_empty=False, fail_on_more=False): raise EngineException("name '{}' exist".format(indata["name"]), HTTPStatus.CONFLICT) - elif item == "vnfds" or item == "nsds": + elif item in ("vnfds", "nsds"): filter = {"id": indata["id"]} + if id: + filter["_id.neq"] = id # TODO add admin to filter, validate rights self._add_read_filter(session, item, filter) if self.db.get_one(item, filter, fail_on_empty=False): @@ -242,6 +270,10 @@ class Engine(object): HTTPStatus.CONFLICT) # TODO validate with pyangbind + elif item == "userDefinedData": + # TODO validate userDefinedData is a keypair values + pass + elif item == "nsrs": pass @@ -254,7 +286,7 @@ class Engine(object): if item == "users": _id = indata["username"] salt = uuid4().hex - indata["_admin"]["salt"] = salt + indata["_admin"]["salt"] = salt indata["password"] = sha256(indata["password"].encode('utf-8') + salt.encode('utf-8')).hexdigest() elif item == "projects": _id = indata["name"] @@ -266,36 +298,44 @@ class Engine(object): storage = admin.get("storage") if not _id: _id = str(uuid4()) - if item == "vnfds" or item == "nsds": + if item in ("vnfds", "nsds"): if not indata["_admin"].get("projects_read"): indata["_admin"]["projects_read"] = [session["project_id"]] if not indata["_admin"].get("projects_write"): indata["_admin"]["projects_write"] = [session["project_id"]] + indata["_admin"]["onboardingState"] = "CREATED" + indata["_admin"]["operationalState"] = "DISABLED" + indata["_admin"]["usageSate"] = "NOT_IN_USE" if storage: indata["_admin"]["storage"] = storage indata["_id"] = _id - def _new_item_partial(self, session, item, indata, headers): + def upload_content(self, session, item, _id, indata, kwargs, headers): """ - Used for recieve content by chunks (with a transaction_id header and/or gzip file. It will store and extract + Used for receiving content by chunks (with a transaction_id header and/or gzip file. It will store and extract) :param session: session - :param item: + :param item: can be nsds or vnfds + :param _id : the nsd,vnfd is already created, this is the id :param indata: http body request + :param kwargs: user query string to override parameters. NOT USED :param headers: http request headers - :return: a dict with:: - _id: - storage: : where it is saving - desc: : descriptor: Only present when all the content is received, extracted and read the descriptor + :return: True package has is completely uploaded or False if partial content has been uplodaed. + Raise exception on error """ + # Check that _id exist and it is valid + current_desc = self.get_item(session, item, _id) + content_range_text = headers.get("Content-Range") - transaction_id = headers.get("Transaction-Id") - filename = headers.get("Content-Filename", "pkg") - # TODO change to Content-Disposition filename https://tools.ietf.org/html/rfc6266 expected_md5 = headers.get("Content-File-MD5") compressed = None - if "application/gzip" in headers.get("Content-Type") or "application/x-gzip" in headers.get("Content-Type") or \ - "application/zip" in headers.get("Content-Type"): + content_type = headers.get("Content-Type") + if content_type and "application/gzip" in content_type or "application/x-gzip" in content_type or \ + "application/zip" in content_type: compressed = "gzip" + filename = headers.get("Content-Filename") + if not filename: + filename = "package.tar.gz" if compressed else "package" + # TODO change to Content-Disposition filename https://tools.ietf.org/html/rfc6266 file_pkg = None error_text = "" try: @@ -306,41 +346,48 @@ class Engine(object): start = int(content_range[1]) end = int(content_range[2]) + 1 total = int(content_range[3]) - if len(indata) != end-start: - raise EngineException("Mismatch between Content-Range header {}-{} and body length of {}".format( - start, end-1, len(indata)), HTTPStatus.BAD_REQUEST) else: start = 0 - total = end = len(indata) - if not transaction_id: - # generate transaction - transaction_id = str(uuid4()) - self.fs.mkdir(transaction_id) - # control_file = open(self.storage["path"] + transaction_id + "/.osm.yaml", 'wb') - # control = {"received": 0} - elif not self.fs.file_exists(transaction_id): - raise EngineException("invalid Transaction-Id header", HTTPStatus.NOT_FOUND) + + if start: + if not self.fs.file_exists(_id, 'dir'): + raise EngineException("invalid Transaction-Id header", HTTPStatus.NOT_FOUND) else: - pass - # control_file = open(self.storage["path"] + transaction_id + "/.osm.yaml", 'rw') - # control = yaml.load(control_file) - # control_file.seek(0, 0) + self.fs.file_delete(_id, ignore_non_exist=True) + self.fs.mkdir(_id) + storage = self.fs.get_params() - storage["folder"] = transaction_id - storage["file"] = filename + storage["folder"] = _id - file_path = (transaction_id, filename) - if self.fs.file_exists(file_path): + file_path = (_id, filename) + if self.fs.file_exists(file_path, 'file'): file_size = self.fs.file_size(file_path) else: file_size = 0 if file_size != start: - raise EngineException("invalid upload transaction sequence, expected '{}' but received '{}'".format( - file_size, start), HTTPStatus.BAD_REQUEST) + raise EngineException("invalid Content-Range start sequence, expected '{}' but received '{}'".format( + file_size, start), HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE) file_pkg = self.fs.file_open(file_path, 'a+b') - file_pkg.write(indata) - if end != total: - return {"_id": transaction_id, "storage": storage} + if isinstance(indata, dict): + indata_text = yaml.safe_dump(indata, indent=4, default_flow_style=False) + file_pkg.write(indata_text.encode(encoding="utf-8")) + else: + indata_len = 0 + while True: + indata_text = indata.read(4096) + indata_len += len(indata_text) + if not indata_text: + break + file_pkg.write(indata_text) + if content_range_text: + if indata_len != end-start: + raise EngineException("Mismatch between Content-Range header {}-{} and body length of {}".format( + start, end-1, indata_len), HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE) + if end != total: + # TODO update to UPLOADING + return False + + # PACKAGE UPLOADED if expected_md5: file_pkg.seek(0, 0) file_md5 = md5() @@ -352,8 +399,6 @@ class Engine(object): raise EngineException("Error, MD5 mismatch", HTTPStatus.CONFLICT) file_pkg.seek(0, 0) if compressed == "gzip": - # TODO unzip, - storage["tarfile"] = filename tar = tarfile.open(mode='r', fileobj=file_pkg) descriptor_file_name = None for tarinfo in tar: @@ -364,32 +409,43 @@ class Engine(object): if len(tarname_path) == 1 and not tarinfo.isdir(): raise EngineException("All files must be inside a dir for package descriptor tar.gz") if tarname.endswith(".yaml") or tarname.endswith(".json") or tarname.endswith(".yml"): - storage["file"] = tarname_path[0] + storage["pkg-dir"] = tarname_path[0] if len(tarname_path) == 2: if descriptor_file_name: raise EngineException("Found more than one descriptor file at package descriptor tar.gz") descriptor_file_name = tarname if not descriptor_file_name: raise EngineException("Not found any descriptor file at package descriptor tar.gz") - self.fs.file_extract(tar, transaction_id) - with self.fs.file_open((transaction_id, descriptor_file_name), "r") as descriptor_file: + storage["descriptor"] = descriptor_file_name + storage["zipfile"] = filename + self.fs.file_extract(tar, _id) + with self.fs.file_open((_id, descriptor_file_name), "r") as descriptor_file: content = descriptor_file.read() else: content = file_pkg.read() - tarname = "" + storage["descriptor"] = descriptor_file_name = filename - if tarname.endswith(".json"): + if descriptor_file_name.endswith(".json"): error_text = "Invalid json format " indata = json.load(content) else: error_text = "Invalid yaml format " indata = yaml.load(content) - return {"_id": transaction_id, "storage": storage, "desc": indata} + + current_desc["_admin"]["storage"] = storage + current_desc["_admin"]["onboardingState"] = "ONBOARDED" + current_desc["_admin"]["operationalState"] = "ENABLED" + + self._edit_item(session, item, _id, current_desc, indata, kwargs) + # TODO if descriptor has changed because kwargs update content and remove cached zip + # TODO if zip is not present creates one + return True + except EngineException: raise except IndexError: raise EngineException("invalid Content-Range header format. Expected 'bytes start-end/total'", - HTTPStatus.BAD_REQUEST) + HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE) except IOError as e: raise EngineException("invalid upload transaction sequence: '{}'".format(e), HTTPStatus.BAD_REQUEST) except (ValueError, yaml.YAMLError) as e: @@ -441,9 +497,10 @@ class Engine(object): def new_item(self, session, item, indata={}, kwargs=None, headers={}): """ - Creates a new entry into database + Creates a new entry into database. For nsds and vnfds it creates an almost empty DISABLED entry, + that must be completed with a call to method upload_content :param session: contains the used login username and working project - :param item: it can be: users, projects, vnfds, nsds, ... + :param item: it can be: users, projects, nsrs, nsds, vnfds :param indata: data to be inserted :param kwargs: used to override the indata descriptor :param headers: http request headers @@ -453,16 +510,20 @@ class Engine(object): # TODO add admin and status transaction = None - if headers.get("Content-Range") or "application/gzip" in headers.get("Content-Type") or \ - "application/x-gzip" in headers.get("Content-Type") or "application/zip" in headers.get("Content-Type"): - if not indata: - raise EngineException("Empty payload") - transaction = self._new_item_partial(session, item, indata, headers) - if "desc" not in transaction: - return transaction["_id"], False - indata = transaction["desc"] - - content = self._remove_envelop(item, indata) + # if headers.get("Content-Range") or "application/gzip" in headers.get("Content-Type") or \ + # "application/x-gzip" in headers.get("Content-Type") or "application/zip" in headers.get("Content-Type") or \ + # "text/plain" in headers.get("Content-Type"): + # if not indata: + # raise EngineException("Empty payload") + # transaction = self._new_item_partial(session, item, indata, headers) + # if "desc" not in transaction: + # return transaction["_id"], False + # indata = transaction["desc"] + + item_envelop = item + if item in ("nsds", "vnfds"): + item_envelop = "userDefinedData" + content = self._remove_envelop(item_envelop, indata) # Override descriptor with query string kwargs if kwargs: @@ -491,7 +552,7 @@ class Engine(object): except IndexError: raise EngineException( "Invalid query string '{}'. Index '{}' out of range".format(k, kitem_old)) - if not indata: + if not indata and item not in ("nsds", "vnfds"): raise EngineException("Empty payload") if item == "nsrs": @@ -500,12 +561,14 @@ class Engine(object): content, _id = self.new_nsr(session, ns_request) transaction = {"_id": _id} - self._validate_new_data(session, item, content) + self._validate_new_data(session, item_envelop, content) + if item in ("nsds", "vnfds"): + content = {"_admin": {"userDefinedData": content}} self._format_new_data(session, item, content, transaction) _id = self.db.create(item, content) if item == "nsrs": self.msg.write("ns", "create", _id) - return _id, True + return _id def _add_read_filter(self, session, item, filter): if session["project_id"] == "admin": # allows all @@ -527,6 +590,62 @@ class Engine(object): elif item in ("vnfds", "nsds") and session["project_id"] != "admin": filter["_admin.projects_write.cont"] = ["ANY", session["project_id"]] + def get_file(self, session, item, _id, path=None, accept_header=None): + """ + Return the file content of a vnfd or nsd + :param session: contains the used login username and working project + :param item: it can be vnfds or nsds + :param _id: Identity of the vnfd, ndsd + :param path: artifact path or "$DESCRIPTOR" or None + :param accept_header: Content of Accept header. Must contain applition/zip or/and text/plain + :return: opened file or raises an exception + """ + accept_text = accept_zip = False + if accept_header: + if 'text/plain' in accept_header or '*/*' in accept_header: + accept_text = True + if 'application/zip' in accept_header or '*/*' in accept_header: + accept_zip = True + if not accept_text and not accept_zip: + raise EngineException("provide request header 'Accept' with 'application/zip' or 'text/plain'", + http_code=HTTPStatus.NOT_ACCEPTABLE) + + content = self.get_item(session, item, _id) + if content["_admin"]["onboardingState"] != "ONBOARDED": + raise EngineException("Cannot get content because this resource is not at 'ONBOARDED' state. " + "onboardingState is {}".format(content["_admin"]["onboardingState"]), + http_code=HTTPStatus.CONFLICT) + storage = content["_admin"]["storage"] + if path is not None and path != "$DESCRIPTOR": # artifacts + if not storage.get('pkg-dir'): + raise EngineException("Packages does not contains artifacts", http_code=HTTPStatus.BAD_REQUEST) + if self.fs.file_exists((storage['folder'], storage['pkg-dir'], *path), 'dir'): + folder_content = self.fs.dir_ls((storage['folder'], storage['pkg-dir'], *path)) + return folder_content, "text/plain" + # TODO manage folders in http + else: + return self.fs.file_open((storage['folder'], storage['pkg-dir'], *path), "rb"), \ + "application/octet-stream" + + # pkgtype accept ZIP TEXT -> result + # manyfiles yes X -> zip + # no yes -> error + # onefile yes no -> zip + # X yes -> text + + if accept_text and (not storage.get('pkg-dir') or path == "$DESCRIPTOR"): + return self.fs.file_open((storage['folder'], storage['descriptor']), "r"), "text/plain" + elif storage.get('pkg-dir') and not accept_zip: + raise EngineException("Packages that contains several files need to be retrieved with 'application/zip'" + "Accept header", http_code=HTTPStatus.NOT_ACCEPTABLE) + else: + if not storage.get('zipfile'): + # TODO generate zipfile if not present + raise EngineException("Only allowed 'text/plain' Accept header for this descriptor. To be solved in future versions" + "", http_code=HTTPStatus.NOT_ACCEPTABLE) + return self.fs.file_open((storage['folder'], storage['zipfile']), "rb"), "application/zip" + + def get_item_list(self, session, item, filter={}): """ Get a list of items @@ -536,6 +655,9 @@ class Engine(object): :return: The list, it can be empty if no one match the filter. """ # TODO add admin to filter, validate rights + # TODO transform data for SOL005 URL requests. Transform filtering + # TODO implement "field-type" query string SOL005 + self._add_read_filter(session, item, filter) return self.db.get_list(item, filter) @@ -543,12 +665,14 @@ class Engine(object): """ Get complete information on an items :param session: contains the used login username and working project - :param item: it can be: users, projects, vnfds, nsds, ... + :param item: it can be: users, projects, vnfds, nsds, :param _id: server id of the item :return: dictionary, raise exception if not found. """ + database_item = item filter = {"_id": _id} # TODO add admin to filter, validate rights + # TODO transform data for SOL005 URL requests self._add_read_filter(session, item, filter) return self.db.get_one(item, filter) @@ -611,27 +735,15 @@ class Engine(object): _id = self.db.create("users", indata) return _id - def edit_item(self, session, item, id, indata={}, kwargs=None): - """ - Update an existing entry at database - :param session: contains the used login username and working project - :param item: it can be: users, projects, vnfds, nsds, ... - :param id: identity of entry to be updated - :param indata: data to be inserted - :param kwargs: used to override the indata descriptor - :return: dictionary, raise exception if not found. - """ - - content = self.get_item(session, item, id) + def _edit_item(self, session, item, id, content, indata={}, kwargs=None): if indata: indata = self._remove_envelop(item, indata) - # TODO update content with with a deep-update # Override descriptor with query string kwargs if kwargs: try: for k, v in kwargs.items(): - update_content = content + update_content = indata kitem_old = None klist = k.split(".") for kitem in klist: @@ -655,9 +767,25 @@ class Engine(object): raise EngineException( "Invalid query string '{}'. Index '{}' out of range".format(k, kitem_old)) - self._validate_new_data(session, item, content) + _deep_update(content, indata) + self._validate_new_data(session, item, content, id) # self._format_new_data(session, item, content) self.db.replace(item, id, content) return id + def edit_item(self, session, item, _id, indata={}, kwargs=None): + """ + Update an existing entry at database + :param session: contains the used login username and working project + :param item: it can be: users, projects, vnfds, nsds, ... + :param _id: identifier to be updated + :param indata: data to be inserted + :param kwargs: used to override the indata descriptor + :return: dictionary, raise exception if not found. + """ + + content = self.get_item(session, item, _id) + return self._edit_item(session, item, _id, content, indata, kwargs) + + diff --git a/osm_nbi/fslocal.py b/osm_nbi/fslocal.py index 10ddf73..b7dd839 100644 --- a/osm_nbi/fslocal.py +++ b/osm_nbi/fslocal.py @@ -46,17 +46,23 @@ class FsLocal(FsBase): except Exception as e: raise FsException(str(e), http_code=HTTPStatus.INTERNAL_SERVER_ERROR) - def file_exists(self, storage): + def file_exists(self, storage, mode=None): """ Indicates if "storage" file exist :param storage: can be a str or a str list + :param mode: can be 'file' exist as a regular file; 'dir' exists as a directory or; 'None' just exists :return: True, False """ if isinstance(storage, str): f = storage else: f = "/".join(storage) - return os.path.exists(self.path + f) + if os.path.exists(self.path + f): + if mode == "file" and os.path.isfile(self.path + f): + return True + if mode == "dir" and os.path.isdir(self.path + f): + return True + return False def file_size(self, storage): """ @@ -90,11 +96,33 @@ class FsLocal(FsBase): :param mode: file mode :return: file object """ - if isinstance(storage, str): - f = storage - else: - f = "/".join(storage) - return open(self.path + f, mode) + try: + if isinstance(storage, str): + f = storage + else: + f = "/".join(storage) + return open(self.path + f, mode) + except FileNotFoundError: + raise FsException("File {} does not exist".format(f), http_code=HTTPStatus.NOT_FOUND) + except IOError: + raise FsException("File {} cannot be opened".format(f), http_code=HTTPStatus.BAD_REQUEST) + + def dir_ls(self, storage): + """ + return folder content + :param storage: can be a str or list of str + :return: folder content + """ + try: + if isinstance(storage, str): + f = storage + else: + f = "/".join(storage) + return os.listdir(self.path + f) + except NotADirectoryError: + raise FsException("File {} does not exist".format(f), http_code=HTTPStatus.NOT_FOUND) + except IOError: + raise FsException("File {} cannot be opened".format(f), http_code=HTTPStatus.BAD_REQUEST) def file_delete(self, storage, ignore_non_exist=False): """ @@ -111,4 +139,4 @@ class FsLocal(FsBase): if os.path.exists(f): rmtree(f) elif not ignore_non_exist: - raise FsException("File {} does not exist".format(storage), http_code=HTTPStatus.BAD_REQUEST) + raise FsException("File {} does not exist".format(storage), http_code=HTTPStatus.NOT_FOUND) diff --git a/osm_nbi/html_out.py b/osm_nbi/html_out.py index f6f92d9..f37b5dd 100644 --- a/osm_nbi/html_out.py +++ b/osm_nbi/html_out.py @@ -19,13 +19,13 @@ html_start = """ """ @@ -61,7 +61,7 @@ html_auth2 = """

Sign in to OSM

-
+ @@ -108,12 +108,15 @@ def format(data, request, response, session): if response.status and response.status > 202: body += html_body_error.format(yaml.safe_dump(data, explicit_start=True, indent=4, default_flow_style=False)) elif isinstance(data, (list, tuple)): - if request.path_info == "/vnfpkgm/v1/vnf_packages": + if request.path_info == "/vnfpkgm/v1/vnf_packages_content": body += html_upload_body.format("VNFD", request.path_info) - elif request.path_info == "/nsd/v1/ns_descriptors": + elif request.path_info == "/nsd/v1/ns_descriptors_content": body += html_upload_body.format("NSD", request.path_info) for k in data: - data_id = k.pop("_id", None) + if isinstance(k, dict): + data_id = k.pop("_id", None) + elif isinstance(k, str): + data_id = k body += '

{id}: {t}

'.format(url=request.path_info, id=data_id, t=k) elif isinstance(data, dict): if "Location" in response.headers: diff --git a/osm_nbi/nbi.py b/osm_nbi/nbi.py index 9cdb409..99f90aa 100644 --- a/osm_nbi/nbi.py +++ b/osm_nbi/nbi.py @@ -9,49 +9,63 @@ import html_out as html import logging from engine import Engine, EngineException from dbbase import DbException +from fsbase import FsException from base64 import standard_b64decode -from os import getenv +#from os import getenv from http import HTTPStatus -from http.client import responses as http_responses +#from http.client import responses as http_responses from codecs import getreader from os import environ __author__ = "Alfonso Tierno " -__version__ = "0.1" -version_date = "Feb 2018" +__version__ = "0.2" +version_date = "Mar 2018" """ -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 + /nsd/v1 O O + /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 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 + /ns_instances_content O O + / O O + /ns_instances 5 5 + / 5 5 TO BE COMPLETED /ns_lcm_op_occs 5 5 / 5 5 5 TO BE COMPLETED 5 5 - /subcriptions 5 5 - / 5 X + /subscriptions 5 5 + / 5 X + /admin/v1 + /tokens O O + / O O + /users O O + / O O + /projects O O + / O O query string. [....]*[.]=[,...]&... @@ -106,6 +120,74 @@ class Server(object): def __init__(self): self.instance += 1 self.engine = Engine() + 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")} + }, + "projects": { "METHODS": ("GET", "POST"), + "": {"METHODS": ("GET", "POST", "DELETE")} + }, + } + }, + "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"), "TODO": "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": {"TODO": ("GET", "POST"), + "": {"TODO": ("GET", "DELETE")} + } + } + }, + } def _authorization(self): token = None @@ -164,14 +246,15 @@ class Server(object): indata = yaml.load(cherrypy.request.body) 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: @@ -186,10 +269,6 @@ class Server(object): 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 @@ -223,20 +302,33 @@ class Server(object): except: 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) @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 data is None: + 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" in cherrypy.request.headers: accept = cherrypy.request.headers["Accept"] if "application/json" in accept: @@ -246,7 +338,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, @@ -275,20 +367,17 @@ class Server(object): 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 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]) + if token_id: + outdata = self.engine.get_token(session, token_id) else: outdata = self.engine.get_token_list(session) elif method == "POST": @@ -304,11 +393,9 @@ class Server(object): # 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: + elif not token_id: session = self._authorization() token_id = session["_id"] outdata = self.engine.del_token(token_id) @@ -329,10 +416,21 @@ class Server(object): } return self._format_out(problem_details, session) + @cherrypy.expose + def test2(self, args0=None, args1=None, args2=None, args3=None, *args, **kwargs): + return_text = ( + "
\n{} {} {} {} {} {} \n".format(args0, args1, args2, args3, args, kwargs))
+        return_text += "
" + return return_text + @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 +438,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.del_item_list({"project_id": "admin"}, args[1], {}) elif args and args[0] == "prune": return self.engine.prune() elif args and args[0] == "login": @@ -390,83 +499,148 @@ 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 'topic/version/item'", 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 not method in reference["METHODS"]: + raise NbiException("Method {} not supported for this URL".format(method), HTTPStatus.METHOD_NOT_ALLOWED) + return + + @staticmethod + def _set_location_header(topic, version, item, id): + """ + Insert response header Location with the URL of created item base on URL params + :param topic: + :param version: + :param item: + :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(topic, version, item, id) + return + @cherrypy.expose - def default(self, *args, **kwargs): + def default(self, topic=None, version=None, item=None, _id=None, item2=None, *args, **kwargs): session = None + outdata = None + _format = 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 topic or not version or not item: + raise NbiException("URL must contain at least 'topic/version/item'", HTTPStatus.METHOD_NOT_ALLOWED) + if topic not in ("admin", "vnfpkgm", "nsd", "nslcm"): + raise NbiException("URL topic '{}' not supported".format(topic), HTTPStatus.METHOD_NOT_ALLOWED) if version != 'v1': raise NbiException("URL version '{}' not supported".format(version), 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 + + self._check_valid_url_method(method, topic, version, item, _id, item2, *args) + + if topic == "admin" and item == "tokens": + return self.token(method, _id, kwargs) + # 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) - else: - if len(args) >= 3: - _id = args[2] - item += "s" + indata = self._format_in(kwargs) + engine_item = item + if item == "subscriptions": + engine_item = topic + "_" + item + if item2: + engine_item = item2 + + if topic == "nsd": + engine_item = "nsds" + elif topic == "vnfpkgm": + engine_item = "vnfds" + elif topic == "nslcm": + engine_item = "nsrs" 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 item2 in ("nsd_content", "package_content", "artifacts", "vnfd", "nsd"): + if item2 in ("vnfd", "nsd"): + path = "$DESCRIPTOR" + elif args: + path = args + elif item2 == "artifacts": + path = () + else: + path = None + file, _format = self.engine.get_file(session, engine_item, _id, path, + cherrypy.request.headers.get("Accept")) + outdata = file + elif not _id: + outdata = self.engine.get_item_list(session, engine_item, kwargs) + else: + outdata = self.engine.get_item(session, engine_item, _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 item in ("ns_descriptors_content", "vnf_packages_content"): + _id = cherrypy.request.headers.get("Transaction-Id") + if not _id: + _id = self.engine.new_item(session, engine_item, {}, None, cherrypy.request.headers) + completed = self.engine.upload_content(session, engine_item, _id, indata, kwargs, cherrypy.request.headers) + if completed: + self._set_location_header(topic, version, item, _id) + else: + cherrypy.response.headers["Transaction-Id"] = _id + outdata = {"id": _id} + elif item in ("ns_descriptors", "vnf_packages"): + _id = self.engine.new_item(session, engine_item, indata, kwargs, cherrypy.request.headers) + self._set_location_header(topic, version, item, _id) + #TODO form NsdInfo + outdata = {"id": _id} else: - cherrypy.response.headers["Location"] = cherrypy.request.base + "/osm/" + "/".join(args[0:3]) + "/" + id - outdata = {"id": id} + _id = self.engine.new_item(session, engine_item, indata, kwargs, cherrypy.request.headers) + self._set_location_header(topic, version, item, _id) + outdata = {"id": _id} + 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_item, kwargs) else: # len(args) > 1 - outdata = self.engine.del_item(session, item, _id) + outdata = self.engine.del_item(session, engine_item, _id) + if item in ("ns_descriptors", "vnf_packages"): # SOL005 + outdata = None 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 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 item2 in ("nsd_content", "package_content"): + completed = self.engine.upload_content(session, engine_item, _id, indata, kwargs, cherrypy.request.headers) + if not completed: + cherrypy.response.headers["Transaction-Id"] = id + outdata = None + else: + outdata = {"id": self.engine.edit_item(session, engine_item, args[1], indata, kwargs)} else: raise NbiException("Method {} not allowed".format(method), HTTPStatus.METHOD_NOT_ALLOWED) - return self._format_out(outdata, session) - except (NbiException, EngineException, DbException) as e: + return self._format_out(outdata, session, _format) + except (NbiException, EngineException, DbException, FsException) as e: + if hasattr(outdata, "close"): # is an open file + outdata.close() cherrypy.log("Exception {}".format(e)) cherrypy.response.status = e.http_code.value problem_details = { diff --git a/osm_nbi/test/cirros_ns/cirros_nsd.yaml b/osm_nbi/test/cirros_ns/cirros_nsd.yaml new file mode 100644 index 0000000..5c4e214 --- /dev/null +++ b/osm_nbi/test/cirros_ns/cirros_nsd.yaml @@ -0,0 +1,49 @@ +nsd-catalog: + nsd: + - id: cirros_nsd + name: cirros_ns + short-name: cirros_ns + description: Generated by OSM pacakage generator + vendor: OSM + version: '1.0' + + # Place the logo as png in icons directory and provide the name here + logo: osm_2x.png + + # Specify the VNFDs that are part of this NSD + constituent-vnfd: + # The member-vnf-index needs to be unique, starting from 1 + # vnfd-id-ref is the id of the VNFD + # Multiple constituent VNFDs can be specified + - member-vnf-index: 1 + vnfd-id-ref: cirros_vnfd + scaling-group-descriptor: + - name: "scaling_cirros" + vnfd-member: + - count: 1 + member-vnf-index-ref: 1 + min-instance-count: 0 + max-instance-count: 10 + scaling-policy: + - scaling-type: "manual" + cooldown-time: 10 + threshold-time: 10 + name: manual_scale + vld: + # Networks for the VNFs + - id: cirros_nsd_vld1 + name: cirros_nsd_vld1 + type: ELAN + mgmt-network: 'true' + # vim-network-name: + # provider-network: + # segmentation_id: + vnfd-connection-point-ref: + # Specify the constituent VNFs + # member-vnf-index-ref - entry from constituent vnf + # vnfd-id-ref - VNFD id + # vnfd-connection-point-ref - connection point name in the VNFD + - member-vnf-index-ref: 1 + vnfd-id-ref: cirros_vnfd + # NOTE: Validate the entry below + vnfd-connection-point-ref: eth0 diff --git a/osm_nbi/test/cirros_ns/icons/osm_2x.png b/osm_nbi/test/cirros_ns/icons/osm_2x.png new file mode 100644 index 0000000000000000000000000000000000000000..62012d2a2b491bdcd536d62c3c3c863c0d8c1b33 GIT binary patch literal 55888 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 QVa%F-+q93SJpA+j4_IIKX8-^I literal 0 HcmV?d00001 diff --git a/osm_nbi/test/cirros_vnf/cirros_vnfd.yaml b/osm_nbi/test/cirros_vnf/cirros_vnfd.yaml new file mode 100644 index 0000000..94fa5f1 --- /dev/null +++ b/osm_nbi/test/cirros_vnf/cirros_vnfd.yaml @@ -0,0 +1,48 @@ +vnfd-catalog: + vnfd: + - id: cirros_vnfd + name: cirros_vnf + short-name: cirros_vnf + description: Simple VNF example with a cirros + vendor: OSM + version: '1.0' + + # Place the logo as png in icons directory and provide the name here + logo: cirros-64.png + + # Management interface + mgmt-interface: + cp: eth0 + + # Atleast one VDU need to be specified + vdu: + - id: cirros_vnfd-VM + name: cirros_vnfd-VM + description: cirros_vnfd-VM + count: 1 + + # Flavour of the VM to be instantiated for the VDU + # flavor below can fit into m1.micro + vm-flavor: + vcpu-count: 1 + memory-mb: 256 + storage-gb: 2 + + # Image/checksum or image including the full path + image: 'cirros034' + #checksum: + + interface: + # Specify the external interfaces + # There can be multiple interfaces defined + - name: eth0 + type: EXTERNAL + virtual-interface: + type: VIRTIO + bandwidth: '0' + vpci: 0000:00:0a.0 + external-connection-point-ref: eth0 + + connection-point: + - name: eth0 + type: VPORT diff --git a/osm_nbi/test/cirros_vnf/icons/cirros-64.png b/osm_nbi/test/cirros_vnf/icons/cirros-64.png new file mode 100644 index 0000000000000000000000000000000000000000..5725d299b0a234e641e6e8add8a685a0baabbddf GIT binary patch literal 4134 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 literal 0 HcmV?d00001 diff --git a/osm_nbi/test/create-ping-pong.sh b/osm_nbi/test/create-ping-pong.sh index 4d96d47..7ef1cf7 100755 --- a/osm_nbi/test/create-ping-pong.sh +++ b/osm_nbi/test/create-ping-pong.sh @@ -25,19 +25,23 @@ NSD3=${DESCRIPTORS}/cirros_nsd.yaml [ -f "$NSD3" ] || ! echo "not found cirros_nsd.yaml. Set DESCRIPTORS variable to a proper location" || exit 1 #get token -TOKEN=`curl --insecure -H "Content-Type: application/yaml" -H "Accept: application/yaml" --data "{username: $USERNAME, password: $PASSWORD, project_id: $PROJECT}" ${NBI_URL}/token/v1 2>/dev/null | awk '($1=="id:"){print $2}'`; +TOKEN=`curl --insecure -H "Content-Type: application/yaml" -H "Accept: application/yaml" \ + --data "{username: $USERNAME, password: $PASSWORD, project_id: $PROJECT}" ${NBI_URL}/admin/v1/tokens \ + 2>/dev/null | awk '($1=="id:"){print $2}'`; echo token: $TOKEN - - # VNFD ######### #insert PKG -VNFD1_ID=`curl --insecure -w "%{http_code}\n" -H "Content-Type: application/gzip" -H "Accept: application/yaml" -H "Authorization: Bearer $TOKEN" --data-binary "@$VNFD1" ${NBI_URL}/vnfpkgm/v1/vnf_packages 2>/dev/null | awk '($1=="id:"){print $2}'` +VNFD1_ID=`curl --insecure -w "%{http_code}\n" -H "Content-Type: application/gzip" -H "Accept: application/yaml" \ + -H "Authorization: Bearer $TOKEN" --data-binary "@$VNFD1" ${NBI_URL}/vnfpkgm/v1/vnf_packages_content \ + 2>/dev/null | awk '($1=="id:"){print $2}'` echo ping_vnfd: $VNFD1_ID -VNFD2_ID=`curl --insecure -w "%{http_code}\n" -H "Content-Type: application/gzip" -H "Accept: application/yaml" -H "Authorization: Bearer $TOKEN" --data-binary "@$VNFD2" ${NBI_URL}/vnfpkgm/v1/vnf_packages 2>/dev/null | awk '($1=="id:"){print $2}'` +VNFD2_ID=`curl --insecure -w "%{http_code}\n" -H "Content-Type: application/gzip" -H "Accept: application/yaml" \ + -H "Authorization: Bearer $TOKEN" --data-binary "@$VNFD2" ${NBI_URL}/vnfpkgm/v1/vnf_packages_content \ + 2>/dev/null | awk '($1=="id:"){print $2}'` echo pong_vnfd: $VNFD2_ID @@ -45,19 +49,25 @@ echo pong_vnfd: $VNFD2_ID # NSD ######### #insert PKG -NSD1_ID=`curl --insecure -w "%{http_code}\n" -H "Content-Type: application/gzip" -H "Accept: application/yaml" -H "Authorization: Bearer $TOKEN" --data-binary "@$NSD1" ${NBI_URL}/nsd/v1/ns_descriptors 2>/dev/null | awk '($1=="id:"){print $2}'` +NSD1_ID=`curl --insecure -w "%{http_code}\n" -H "Content-Type: application/gzip" -H "Accept: application/yaml" \ + -H "Authorization: Bearer $TOKEN" --data-binary "@$NSD1" ${NBI_URL}/nsd/v1/ns_descriptors_content \ + 2>/dev/null | awk '($1=="id:"){print $2}'` echo ping_pong_nsd: $NSD1_ID # NSRS ############## #add nsr -NSR1_ID=`curl --insecure -w "%{http_code}\n" -H "Content-Type: application/yaml" -H "Accept: application/yaml" -H "Authorization: Bearer $TOKEN" --data "{ nsDescription: default description, nsName: NSNAME, nsdId: $NSD1_ID, ssh-authorized-key: [ {key-pair-ref: gerardo}, {key-pair-ref: alfonso}], vimAccountId: $VIM }" ${NBI_URL}/nslcm/v1/ns_instances 2>/dev/null | awk '($1=="id:"){print $2}'` ; +NSR1_ID=`curl --insecure -w "%{http_code}\n" -H "Content-Type: application/yaml" -H "Accept: application/yaml" \ + -H "Authorization: Bearer $TOKEN" --data "{ nsDescription: default description, nsName: NSNAME, nsdId: $NSD1_ID, \ + ssh-authorized-key: [ {key-pair-ref: gerardo}, {key-pair-ref: alfonso}], vimAccountId: $VIM }" \ + ${NBI_URL}/nslcm/v1/ns_instances_content 2>/dev/null | awk '($1=="id:"){print $2}'` ; echo ping_pong_nsr: $NSR1_ID -echo ' -curl --insecure -w "%{http_code}\n" -H "Content-Type: application/yaml" -H "Accept: application/yaml" -H "Authorization: Bearer '$TOKEN'" '${NBI_URL}'/nslcm/v1/ns_instances/'$NSR1_ID' 2>/dev/null | grep -e detailed-status -e operational-status -e config-status' +echo 'curl --insecure -w "%{http_code}\n" -H "Content-Type: application/yaml" -H "Accept: application/yaml"' \ + '-H "Authorization: Bearer '$TOKEN'" '${NBI_URL}'/nslcm/v1/ns_instances_content/'$NSR1_ID' 2>/dev/null | ' \ + 'grep -e detailed-status -e operational-status -e config-status' diff --git a/osm_nbi/test/delete-all.sh b/osm_nbi/test/delete-all.sh index 6b04538..d8cb474 100755 --- a/osm_nbi/test/delete-all.sh +++ b/osm_nbi/test/delete-all.sh @@ -8,13 +8,13 @@ PROJECT=admin #get token -TOKEN=`curl --insecure -H "Content-Type: application/yaml" -H "Accept: application/yaml" --data "{username: $USERNAME, password: $PASSWORD, project_id: $PROJECT}" ${NBI_URL}/token/v1 2>/dev/null | awk '($1=="id:"){print $2}' ` ; echo $TOKEN +TOKEN=`curl --insecure -H "Content-Type: application/yaml" -H "Accept: application/yaml" --data "{username: $USERNAME, password: $PASSWORD, project_id: $PROJECT}" ${NBI_URL}/admin/v1/tokens 2>/dev/null | awk '($1=="id:"){print $2}' ` ; echo $TOKEN echo deleting all #DELETE ALL -for url_item in nslcm/v1/ns_instances nsd/v1/ns_descriptors vnfpkgm/v1/vnf_packages +for url_item in nslcm/v1/ns_instances nsd/v1/ns_descriptors_content vnfpkgm/v1/vnf_packages_content do for ITEM_ID in `curl --insecure -w "%{http_code}\n" -H "Content-Type: application/yaml" -H "Accept: application/yaml" -H "Authorization: Bearer $TOKEN" ${NBI_URL}/${url_item} 2>/dev/null | awk '($1=="_id:") {print $2}'` ; do diff --git a/osm_nbi/test/test.py b/osm_nbi/test/test.py new file mode 100755 index 0000000..734d667 --- /dev/null +++ b/osm_nbi/test/test.py @@ -0,0 +1,337 @@ +#! /usr/bin/python3 +# -*- coding: utf-8 -*- + +import getopt +import sys +import requests +#import base64 +#from os.path import getsize, basename +#from hashlib import md5 +import json +import logging +import yaml +#import json +import tarfile +from os import makedirs +from copy import deepcopy + +__author__ = "Alfonso Tierno, alfonso.tiernosepulveda@telefonica.com" +__date__ = "$2018-03-01$" +__version__ = "0.1" +version_date = "Mar 2018" + + +def usage(): + print("Usage: ", sys.argv[0], "[options]") + print(" --version: prints current version") + print(" -f|--file FILE: file to be sent") + print(" -h|--help: shows this help") + print(" -u|--url URL: complete server URL") + print(" -s|--chunk-size SIZE: size of chunks, by default 1000") + print(" -t|--token TOKEN: Authorizaton token, previously obtained from server") + print(" -v|--verbose print debug information, can be used several times") + return + + +r_header_json = {"Content-type": "application/json"} +headers_json = { + "Content-type": "application/json", + "Accept": "application/json", +} +r_header_yaml = {"Content-type": "application/yaml"} +headers_yaml = { + "Content-type": "application/yaml", + "Accept": "application/yaml", +} +r_header_text = {"Content-type": "text/plain"} +r_header_octect = {"Content-type": "application/octet-stream"} +headers_text = { + "Accept": "text/plain", +} +r_header_zip = {"Content-type": "application/zip"} +headers_zip = { + "Accept": "application/zip", +} +# test without authorization +test_not_authorized_list = ( + ("Invalid token", "GET", "/admin/v1/users", headers_json, None, 401, r_header_json, "json"), + ("Invalid URL", "POST", "/admin/v1/nonexist", headers_yaml, None, 405, r_header_yaml, "yaml"), + ("Invalid version", "DELETE", "/admin/v2/users", headers_yaml, None, 405, r_header_yaml, "yaml"), +) + +# test ones authorized +test_authorized_list = ( + ("Invalid vnfd id", "GET", "/vnfpkgm/v1/vnf_packages/non-existing-id", headers_json, None, 404, r_header_json, "json"), + ("Invalid nsd id", "GET", "/nsd/v1/ns_descriptors/non-existing-id", headers_yaml, None, 404, r_header_yaml, "yaml"), + ("Invalid nsd id", "DELETE", "/nsd/v1/ns_descriptors_content/non-existing-id", headers_yaml, None, 404, r_header_yaml, "yaml"), +) + +class TestException(Exception): + pass + + +class TestRest: + def __init__(self, url_base, header_base={}, verify=False): + self.url_base = url_base + self.header_base = header_base + self.s = requests.session() + self.s.headers = header_base + self.verify = verify + + def set_header(self, header): + self.s.headers.update(header) + + def test(self, name, method, url, headers, payload, expected_codes, expected_headers, expected_payload): + """ + Performs an http request and check http code response. Exit if different than allowed + :param name: name of the test + :param method: HTTP method: GET,PUT,POST,DELETE,... + :param url: complete URL or relative URL + :param headers: request headers to add to the base headers + :param payload: Can be a dict, transformed to json, a text or a file if starts with '@' + :param expected_codes: expected response codes, can be int, int tuple or int range + :param expected_headers: expected response headers, dict with key values + :param expected_payload: expected payload, 0 if empty, 'yaml', 'json', 'text', 'zip' + :return: + """ + try: + if not self.s: + self.s = requests.session() + if not url: + url = self.url_base + elif not url.startswith("http"): + url = self.url_base + url + if payload: + if isinstance(payload, str): + if payload.startswith("@"): + mode = "r" + file_name = payload[1:] + if payload.startswith("@b"): + mode = "rb" + file_name = payload[2:] + with open(file_name, mode) as f: + payload = f.read() + elif isinstance(payload, dict): + payload = json.dumps(payload) + + test = "Test {} {} {}".format(name, method, url) + logger.warning(test) + stream = False + # if expected_payload == "zip": + # stream = True + r = getattr(self.s, method.lower())(url, data=payload, headers=headers, verify=self.verify, stream=stream) + logger.debug("RX {}: {}".format(r.status_code, r.text)) + + # check response + if expected_codes: + if isinstance(expected_codes, int): + expected_codes = (expected_codes,) + if r.status_code not in expected_codes: + raise TestException( + "Got status {}. Expected {}. {}".format(r.status_code, expected_codes, r.text)) + + if expected_headers: + for header_key, header_val in expected_headers.items(): + if header_key.lower() not in r.headers: + raise TestException("Header {} not present".format(header_key)) + if header_val and header_val.lower() not in r.headers[header_key]: + raise TestException("Header {} does not contain {} but {}".format(header_key, header_val, + r.headers[header_key])) + + if expected_payload is not None: + if expected_payload == 0 and len(r.content) > 0: + raise TestException("Expected empty payload") + elif expected_payload == "json": + try: + r.json() + except Exception as e: + raise TestException("Expected json response payload, but got Exception {}".format(e)) + elif expected_payload == "yaml": + try: + yaml.safe_load(r.text) + except Exception as e: + raise TestException("Expected yaml response payload, but got Exception {}".format(e)) + elif expected_payload == "zip": + if len(r.content) == 0: + raise TestException("Expected some response payload, but got empty") + # try: + # tar = tarfile.open(None, 'r:gz', fileobj=r.raw) + # for tarinfo in tar: + # tarname = tarinfo.name + # print(tarname) + # except Exception as e: + # raise TestException("Expected zip response payload, but got Exception {}".format(e)) + elif expected_payload == "text": + if len(r.content) == 0: + raise TestException("Expected some response payload, but got empty") + #r.text + return r + except TestException as e: + logger.error("{} \nRX code{}: {}".format(e, r.status_code, r.text)) + exit(1) + except IOError as e: + logger.error("Cannot open file {}".format(e)) + exit(1) + + +if __name__ == "__main__": + global logger + test = "" + try: + logging.basicConfig(format="%(levelname)s %(message)s", level=logging.ERROR) + logger = logging.getLogger('NBI') + # load parameters and configuration + opts, args = getopt.getopt(sys.argv[1:], "hvu:p:", + ["url=", "user=", "password=", "help", "version", "verbose", "project=", "insecure"]) + url = "https://localhost:9999/osm" + user = password = project = "admin" + verbose = 0 + verify = True + + for o, a in opts: + if o == "--version": + print ("test version " + __version__ + ' ' + version_date) + sys.exit() + elif o in ("-v", "--verbose"): + verbose += 1 + elif o in ("no-verbose"): + verbose = -1 + elif o in ("-h", "--help"): + usage() + sys.exit() + elif o in ("--url"): + url = a + elif o in ("-u", "--user"): + user = a + elif o in ("-p", "--password"): + password = a + elif o in ("--project"): + project = a + elif o in ("--insecure"): + verify = False + else: + assert False, "Unhandled option" + if verbose == 0: + logger.setLevel(logging.WARNING) + elif verbose > 1: + logger.setLevel(logging.DEBUG) + else: + logger.setLevel(logging.ERROR) + + test_rest = TestRest(url) + + # tests without authorization + for t in test_not_authorized_list: + test_rest.test(*t) + + # get token + r = test_rest.test("Obtain token", "POST", "/admin/v1/tokens", headers_json, + {"username": user, "password": password, "project_id": project}, + (200, 201), {"Content-Type": "application/json"}, "json") + response = r.json() + token = response["id"] + test_rest.set_header({"Authorization": "Bearer {}".format(token)}) + + # tests once authorized + for t in test_authorized_list: + test_rest.test(*t) + + # nsd CREATE + r = test_rest.test("Onboard NSD step 1", "POST", "/nsd/v1/ns_descriptors", headers_json, None, + 201, {"Location": "/nsd/v1/ns_descriptors/", "Content-Type": "application/json"}, "json") + location = r.headers["Location"] + nsd_id = location[location.rfind("/")+1:] + # print(location, nsd_id) + + # nsd UPLOAD test + r = test_rest.test("Onboard NSD step 2 as TEXT", "PUT", "/nsd/v1/ns_descriptors/{}/nsd_content".format(nsd_id), + r_header_text, "@./cirros_ns/cirros_nsd.yaml", 204, None, 0) + + # nsd SHOW OSM format + r = test_rest.test("Show NSD OSM format", "GET", "/nsd/v1/ns_descriptors_content/{}".format(nsd_id), + headers_json, None, 200, r_header_json, "json") + + # nsd SHOW text + r = test_rest.test("Show NSD SOL005 text", "GET", "/nsd/v1/ns_descriptors/{}/nsd_content".format(nsd_id), + headers_text, None, 200, r_header_text, "text") + + # nsd UPLOAD ZIP + makedirs("temp", exist_ok=True) + tar = tarfile.open("temp/cirros_ns.tar.gz", "w:gz") + tar.add("cirros_ns") + tar.close() + r = test_rest.test("Onboard NSD step 3 replace with ZIP", "PUT", "/nsd/v1/ns_descriptors/{}/nsd_content".format(nsd_id), + r_header_zip, "@b./temp/cirros_ns.tar.gz", 204, None, 0) + + # nsd SHOW OSM format + r = test_rest.test("Show NSD OSM format", "GET", "/nsd/v1/ns_descriptors_content/{}".format(nsd_id), + headers_json, None, 200, r_header_json, "json") + + # nsd SHOW zip + r = test_rest.test("Show NSD SOL005 zip", "GET", "/nsd/v1/ns_descriptors/{}/nsd_content".format(nsd_id), + headers_zip, None, 200, r_header_zip, "zip") + + # nsd SHOW descriptor + r = test_rest.test("Show NSD descriptor", "GET", "/nsd/v1/ns_descriptors/{}/nsd".format(nsd_id), + headers_text, None, 200, r_header_text, "text") + # nsd SHOW actifact + r = test_rest.test("Show NSD artifact", "GET", "/nsd/v1/ns_descriptors/{}/artifacts/icons/osm_2x.png".format(nsd_id), + headers_text, None, 200, r_header_octect, "text") + + # nsd DELETE + r = test_rest.test("Delete NSD SOL005 text", "DELETE", "/nsd/v1/ns_descriptors/{}".format(nsd_id), + headers_yaml, None, 204, None, 0) + + # vnfd CREATE + r = test_rest.test("Onboard VNFD step 1", "POST", "/vnfpkgm/v1/vnf_packages", headers_json, None, + 201, {"Location": "/vnfpkgm/v1/vnf_packages/", "Content-Type": "application/json"}, "json") + location = r.headers["Location"] + vnfd_id = location[location.rfind("/")+1:] + # print(location, vnfd_id) + + # vnfd UPLOAD test + r = test_rest.test("Onboard VNFD step 2 as TEXT", "PUT", "/vnfpkgm/v1/vnf_packages/{}/package_content".format(vnfd_id), + r_header_text, "@./cirros_vnf/cirros_vnfd.yaml", 204, None, 0) + + # vnfd SHOW OSM format + r = test_rest.test("Show VNFD OSM format", "GET", "/vnfpkgm/v1/vnf_packages_content/{}".format(vnfd_id), + headers_json, None, 200, r_header_json, "json") + + # vnfd SHOW text + r = test_rest.test("Show VNFD SOL005 text", "GET", "/vnfpkgm/v1/vnf_packages/{}/package_content".format(vnfd_id), + headers_text, None, 200, r_header_text, "text") + + # vnfd UPLOAD ZIP + makedirs("temp", exist_ok=True) + tar = tarfile.open("temp/cirros_vnf.tar.gz", "w:gz") + tar.add("cirros_vnf") + tar.close() + r = test_rest.test("Onboard VNFD step 3 replace with ZIP", "PUT", "/vnfpkgm/v1/vnf_packages/{}/package_content".format(vnfd_id), + r_header_zip, "@b./temp/cirros_vnf.tar.gz", 204, None, 0) + + # vnfd SHOW OSM format + r = test_rest.test("Show VNFD OSM format", "GET", "/vnfpkgm/v1/vnf_packages_content/{}".format(vnfd_id), + headers_json, None, 200, r_header_json, "json") + + # vnfd SHOW zip + r = test_rest.test("Show VNFD SOL005 zip", "GET", "/vnfpkgm/v1/vnf_packages/{}/package_content".format(vnfd_id), + headers_zip, None, 200, r_header_zip, "zip") + # vnfd SHOW descriptor + r = test_rest.test("Show VNFD descriptor", "GET", "/vnfpkgm/v1/vnf_packages/{}/vnfd".format(vnfd_id), + headers_text, None, 200, r_header_text, "text") + # vnfd SHOW actifact + r = test_rest.test("Show VNFD artifact", "GET", "/vnfpkgm/v1/vnf_packages/{}/artifacts/icons/cirros-64.png".format(vnfd_id), + headers_text, None, 200, r_header_octect, "text") + + # vnfd DELETE + r = test_rest.test("Delete VNFD SOL005 text", "DELETE", "/vnfpkgm/v1/vnf_packages/{}".format(vnfd_id), + headers_yaml, None, 204, None, 0) + + print("PASS") + + except Exception as e: + if test: + logger.error(test + " Exception: " + str(e)) + exit(1) + else: + logger.critical(test + " Exception: " + str(e), exc_info=True) -- 2.17.1
Username
Password