X-Git-Url: https://osm.etsi.org/gitweb/?p=osm%2FNBI.git;a=blobdiff_plain;f=osm_nbi%2Fdescriptor_topics.py;h=8efde1e1cff3bdeb0a909c2c4ffc1cb545f1386e;hp=ddec65cecc4e2299e043e7108df56a785ffd7161;hb=HEAD;hpb=bc5a52409c5d3690ebf6810a31662c0846847020 diff --git a/osm_nbi/descriptor_topics.py b/osm_nbi/descriptor_topics.py index ddec65c..b165b76 100644 --- a/osm_nbi/descriptor_topics.py +++ b/osm_nbi/descriptor_topics.py @@ -20,6 +20,7 @@ import copy import os import shutil import functools +import re # import logging from deepdiff import DeepDiff @@ -30,6 +31,7 @@ from time import time from uuid import uuid4 from re import fullmatch from zipfile import ZipFile +from urllib.parse import urlparse from osm_nbi.validation import ( ValidationError, pdu_new_schema, @@ -37,7 +39,12 @@ from osm_nbi.validation import ( validate_input, vnfpkgop_new_schema, ) -from osm_nbi.base_topic import BaseTopic, EngineException, get_iterable +from osm_nbi.base_topic import ( + BaseTopic, + EngineException, + get_iterable, + detect_descriptor_usage, +) from osm_im import etsi_nfv_vnfd, etsi_nfv_nsd from osm_im.nst import nst as nst_im from pyangbind.lib.serialise import pybindJSONDecoder @@ -46,11 +53,17 @@ from osm_nbi import utils __author__ = "Alfonso Tierno " +valid_helm_chart_re = re.compile( + r"^[a-z0-9]([-a-z0-9]*[a-z0-9]/)?([a-z0-9]([-a-z0-9]*[a-z0-9])?)*$" +) + class DescriptorTopic(BaseTopic): def __init__(self, db, fs, msg, auth): + super().__init__(db, fs, msg, auth) - BaseTopic.__init__(self, db, fs, msg, auth) + def _validate_input_new(self, indata, storage_params, force=False): + return indata def check_conflict_on_edit(self, session, final_content, edit_content, _id): final_content = super().check_conflict_on_edit( @@ -117,7 +130,7 @@ class DescriptorTopic(BaseTopic): if self.db.get_one(self.topic, _filter, fail_on_empty=False): raise EngineException( "{} with id '{}' already exists for this project".format( - self.topic[:-1], final_content["id"] + (str(self.topic))[:-1], final_content["id"] ), HTTPStatus.CONFLICT, ) @@ -149,7 +162,6 @@ class DescriptorTopic(BaseTopic): self.fs.file_delete(_id + ":" + str(revision), ignore_non_exist=True) revision = revision - 1 - @staticmethod def get_one_by_id(db, session, topic, id): # find owned by this project @@ -213,10 +225,7 @@ class DescriptorTopic(BaseTopic): # Avoid override in this case as the target is userDefinedData, but not vnfd,nsd descriptors # indata = DescriptorTopic._validate_input_new(self, indata, project_id=session["force"]) - content = {"_admin": { - "userDefinedData": indata, - "revision": 0 - }} + content = {"_admin": {"userDefinedData": indata, "revision": 0}} self.format_on_new( content, session["project_id"], make_public=session["public"] @@ -250,10 +259,7 @@ class DescriptorTopic(BaseTopic): or "application/x-gzip" in content_type ): compressed = "gzip" - if ( - content_type - and "application/zip" in content_type - ): + if content_type and "application/zip" in content_type: compressed = "zip" filename = headers.get("Content-Filename") if not filename and compressed: @@ -403,14 +409,12 @@ class DescriptorTopic(BaseTopic): ) if ( - ( - zipfilename.endswith(".yaml") - or zipfilename.endswith(".json") - or zipfilename.endswith(".yml") - ) and ( - zipfilename.find("/") < 0 - or zipfilename.find("Definitions") >= 0 - ) + zipfilename.endswith(".yaml") + or zipfilename.endswith(".json") + or zipfilename.endswith(".yml") + ) and ( + zipfilename.find("/") < 0 + or zipfilename.find("Definitions") >= 0 ): storage["pkg-dir"] = "" if descriptor_file_name: @@ -439,7 +443,7 @@ class DescriptorTopic(BaseTopic): indata = json.load(content) else: error_text = "Invalid yaml format " - indata = yaml.load(content, Loader=yaml.SafeLoader) + indata = yaml.safe_load(content) # Need to close the file package here so it can be copied from the # revision to the current, unrevisioned record @@ -456,18 +460,23 @@ class DescriptorTopic(BaseTopic): if revision > 1: try: self._validate_descriptor_changes( + _id, descriptor_file_name, current_revision_path, - proposed_revision_path) + proposed_revision_path, + ) except Exception as e: - shutil.rmtree(self.fs.path + current_revision_path, ignore_errors=True) - shutil.rmtree(self.fs.path + proposed_revision_path, ignore_errors=True) + shutil.rmtree( + self.fs.path + current_revision_path, ignore_errors=True + ) + shutil.rmtree( + self.fs.path + proposed_revision_path, ignore_errors=True + ) # Only delete the new revision. We need to keep the original version in place # as it has not been changed. self.fs.file_delete(proposed_revision_path, ignore_non_exist=True) raise e - indata = self._remove_envelop(indata) # Override descriptor with query string kwargs @@ -487,7 +496,10 @@ class DescriptorTopic(BaseTopic): # Copy the revision to the active package name by its original id shutil.rmtree(self.fs.path + current_revision_path, ignore_errors=True) - os.rename(self.fs.path + proposed_revision_path, self.fs.path + current_revision_path) + os.rename( + self.fs.path + proposed_revision_path, + self.fs.path + current_revision_path, + ) self.fs.file_delete(current_revision_path, ignore_non_exist=True) self.fs.mkdir(current_revision_path) self.fs.reverse_sync(from_path=current_revision_path) @@ -568,7 +580,7 @@ class DescriptorTopic(BaseTopic): ) storage = content["_admin"]["storage"] if path is not None and path != "$DESCRIPTOR": # artifacts - if not storage.get("pkg-dir"): + if not storage.get("pkg-dir") and not storage.get("folder"): raise EngineException( "Packages does not contains artifacts", http_code=HTTPStatus.BAD_REQUEST, @@ -670,7 +682,7 @@ class DescriptorTopic(BaseTopic): # to preserve current expected behaviour if "userDefinedData" in indata: data = indata.pop("userDefinedData") - if type(data) == dict: + if isinstance(data, dict): indata["_admin"]["userDefinedData"] = data else: raise EngineException( @@ -694,11 +706,13 @@ class DescriptorTopic(BaseTopic): return indata - def _validate_descriptor_changes(self, + def _validate_descriptor_changes( + self, + descriptor_id, descriptor_file_name, old_descriptor_directory, - new_descriptor_directory): - # Todo: compare changes and throw a meaningful exception for the user to understand + new_descriptor_directory, + ): # Example: # raise EngineException( # "Error in validating new descriptor: cannot be modified", @@ -706,6 +720,7 @@ class DescriptorTopic(BaseTopic): # ) pass + class VnfdTopic(DescriptorTopic): topic = "vnfds" topic_msg = "vnfd" @@ -838,9 +853,29 @@ class VnfdTopic(DescriptorTopic): self.validate_internal_virtual_links(indata) self.validate_monitoring_params(indata) self.validate_scaling_group_descriptor(indata) + self.validate_helm_chart(indata) return indata + @staticmethod + def validate_helm_chart(indata): + def is_url(url): + result = urlparse(url) + return all([result.scheme, result.netloc]) + + kdus = indata.get("kdu", []) + for kdu in kdus: + helm_chart_value = kdu.get("helm-chart") + if not helm_chart_value: + continue + if not ( + valid_helm_chart_re.match(helm_chart_value) or is_url(helm_chart_value) + ): + raise EngineException( + "helm-chart '{}' is not valid".format(helm_chart_value), + http_code=HTTPStatus.UNPROCESSABLE_ENTITY, + ) + @staticmethod def validate_mgmt_interface_connection_point(indata): if not indata.get("vdu"): @@ -974,13 +1009,9 @@ class VnfdTopic(DescriptorTopic): return False elif not storage_params.get("pkg-dir"): if self.fs.file_exists("{}_".format(storage_params["folder"]), "dir"): - f = "{}_/{}".format( - storage_params["folder"], folder - ) + f = "{}_/{}".format(storage_params["folder"], folder) else: - f = "{}/{}".format( - storage_params["folder"], folder - ) + f = "{}/{}".format(storage_params["folder"], folder) if file: return self.fs.file_exists("{}/{}".format(f, file), "file") else: @@ -1178,7 +1209,7 @@ class VnfdTopic(DescriptorTopic): """ super().delete_extra(session, _id, db_content, not_send_msg) self.db.del_list("vnfpkgops", {"vnfPkgId": _id}) - self.db.del_list(self.topic+"_revisions", {"_id": {"$regex": _id}}) + self.db.del_list(self.topic + "_revisions", {"_id": {"$regex": _id}}) def sol005_projection(self, data): data["onboardingState"] = data["_admin"]["onboardingState"] @@ -1223,7 +1254,14 @@ class VnfdTopic(DescriptorTopic): Returns: vnfd (dict): VNFD which does not include policies """ - # TODO: Extract the policy related parts from the VNFD + for df in vnfd.get("df", {}): + for policy in ["scaling-aspect", "healing-aspect"]: + if df.get(policy, {}): + df.pop(policy) + for vdu in vnfd.get("vdu", {}): + for alarm_policy in ["alarm", "monitoring-parameter"]: + if vdu.get(alarm_policy, {}): + vdu.pop(alarm_policy) return vnfd @staticmethod @@ -1283,6 +1321,7 @@ class VnfdTopic(DescriptorTopic): def _validate_descriptor_changes( self, + descriptor_id: str, descriptor_file_name: str, old_descriptor_directory: str, new_descriptor_directory: str, @@ -1291,7 +1330,7 @@ class VnfdTopic(DescriptorTopic): Args: old_descriptor_directory (str): Directory of descriptor which is in-use - new_descriptor_directory (str): Directory of directory which is proposed to update (new revision) + new_descriptor_directory (str): Directory of descriptor which is proposed to update (new revision) Returns: None @@ -1300,27 +1339,35 @@ class VnfdTopic(DescriptorTopic): EngineException: In case of error when there are unallowed changes """ try: + # If VNFD does not exist in DB or it is not in use by any NS, + # validation is not required. + vnfd = self.db.get_one("vnfds", {"_id": descriptor_id}) + if not vnfd or not detect_descriptor_usage(vnfd, "vnfds", self.db): + return + + # Get the old and new descriptor contents in order to compare them. with self.fs.file_open( (old_descriptor_directory.rstrip("/"), descriptor_file_name), "r" ) as old_descriptor_file: with self.fs.file_open( - (new_descriptor_directory, descriptor_file_name), "r" + (new_descriptor_directory.rstrip("/"), descriptor_file_name), "r" ) as new_descriptor_file: - old_content = yaml.load( - old_descriptor_file.read(), Loader=yaml.SafeLoader - ) - new_content = yaml.load( - new_descriptor_file.read(), Loader=yaml.SafeLoader - ) + old_content = yaml.safe_load(old_descriptor_file.read()) + new_content = yaml.safe_load(new_descriptor_file.read()) + + # If software version has changed, we do not need to validate + # the differences anymore. if old_content and new_content: if self.find_software_version( old_content ) != self.find_software_version(new_content): return + disallowed_change = DeepDiff( self.remove_modifiable_items(old_content), self.remove_modifiable_items(new_content), ) + if disallowed_change: changed_nodes = functools.reduce( lambda a, b: a + " , " + b, @@ -1331,6 +1378,7 @@ class VnfdTopic(DescriptorTopic): ).keys() ], ) + raise EngineException( f"Error in validating new descriptor: {changed_nodes} cannot be modified, " "there are disallowed changes in the vnf descriptor.", @@ -1353,7 +1401,7 @@ class NsdTopic(DescriptorTopic): topic_msg = "nsd" def __init__(self, db, fs, msg, auth): - DescriptorTopic.__init__(self, db, fs, msg, auth) + super().__init__(db, fs, msg, auth) def pyangbind_validation(self, item, data, force=False): if self._descriptor_data_is_in_old_format(data): @@ -1419,6 +1467,8 @@ class NsdTopic(DescriptorTopic): # TODO validata that if contains cloud-init-file or charms, have artifacts _admin.storage."pkg-dir" is not none for vld in get_iterable(indata.get("virtual-link-desc")): self.validate_vld_mgmt_network_with_virtual_link_protocol_data(vld, indata) + for fg in get_iterable(indata.get("vnffgd")): + self.validate_vnffgd_data(fg, indata) self.validate_vnf_profiles_vnfd_id(indata) @@ -1440,6 +1490,45 @@ class NsdTopic(DescriptorTopic): http_code=HTTPStatus.UNPROCESSABLE_ENTITY, ) + @staticmethod + def validate_vnffgd_data(fg, indata): + position_list = [] + all_vnf_ids = set(get_iterable(fg.get("vnf-profile-id"))) + for fgposition in get_iterable(fg.get("nfp-position-element")): + position_list.append(fgposition["id"]) + + for nfpd in get_iterable(fg.get("nfpd")): + nfp_position = [] + for position in get_iterable(nfpd.get("position-desc-id")): + nfp_position = position.get("nfp-position-element-id") + if position == "nfp-position-element-id": + nfp_position = position.get("nfp-position-element-id") + if nfp_position[0] not in position_list: + raise EngineException( + "Error at vnffgd nfpd[id='{}']:nfp-position-element-id='{}' " + "does not match any nfp-position-element".format( + nfpd["id"], nfp_position[0] + ), + http_code=HTTPStatus.UNPROCESSABLE_ENTITY, + ) + + for cp in get_iterable(position.get("cp-profile-id")): + for cpe in get_iterable(cp.get("constituent-profile-elements")): + constituent_base_element_id = cpe.get( + "constituent-base-element-id" + ) + if ( + constituent_base_element_id + and constituent_base_element_id not in all_vnf_ids + ): + raise EngineException( + "Error at vnffgd constituent_profile[id='{}']:vnfd-id='{}' " + "does not match any constituent-base-element-id".format( + cpe["id"], constituent_base_element_id + ), + http_code=HTTPStatus.UNPROCESSABLE_ENTITY, + ) + @staticmethod def validate_vnf_profiles_vnfd_id(indata): all_vnfd_ids = set(get_iterable(indata.get("vnfd-id"))) @@ -1483,7 +1572,7 @@ class NsdTopic(DescriptorTopic): # to preserve current expected behaviour if "userDefinedData" in indata: data = indata.pop("userDefinedData") - if type(data) == dict: + if isinstance(data, dict): indata["_admin"]["userDefinedData"] = data else: raise EngineException( @@ -1619,7 +1708,7 @@ class NsdTopic(DescriptorTopic): :raises: FsException in case of error while deleting associated storage """ super().delete_extra(session, _id, db_content, not_send_msg) - self.db.del_list(self.topic+"_revisions", { "_id": { "$regex": _id}}) + self.db.del_list(self.topic + "_revisions", {"_id": {"$regex": _id}}) @staticmethod def extract_day12_primitives(nsd: dict) -> dict: @@ -1666,6 +1755,7 @@ class NsdTopic(DescriptorTopic): def _validate_descriptor_changes( self, + descriptor_id: str, descriptor_file_name: str, old_descriptor_directory: str, new_descriptor_directory: str, @@ -1674,7 +1764,7 @@ class NsdTopic(DescriptorTopic): Args: old_descriptor_directory: Directory of descriptor which is in-use - new_descriptor_directory: Directory of directory which is proposed to update (new revision) + new_descriptor_directory: Directory of descriptor which is proposed to update (new revision) Returns: None @@ -1684,23 +1774,28 @@ class NsdTopic(DescriptorTopic): """ try: + # If NSD does not exist in DB, or it is not in use by any NS, + # validation is not required. + nsd = self.db.get_one("nsds", {"_id": descriptor_id}, fail_on_empty=False) + if not nsd or not detect_descriptor_usage(nsd, "nsds", self.db): + return + + # Get the old and new descriptor contents in order to compare them. with self.fs.file_open( - (old_descriptor_directory, descriptor_file_name), "r" + (old_descriptor_directory.rstrip("/"), descriptor_file_name), "r" ) as old_descriptor_file: with self.fs.file_open( (new_descriptor_directory.rstrip("/"), descriptor_file_name), "r" ) as new_descriptor_file: - old_content = yaml.load( - old_descriptor_file.read(), Loader=yaml.SafeLoader - ) - new_content = yaml.load( - new_descriptor_file.read(), Loader=yaml.SafeLoader - ) + old_content = yaml.safe_load(old_descriptor_file.read()) + new_content = yaml.safe_load(new_descriptor_file.read()) + if old_content and new_content: disallowed_change = DeepDiff( self.remove_modifiable_items(old_content), self.remove_modifiable_items(new_content), ) + if disallowed_change: changed_nodes = functools.reduce( lambda a, b: a + ", " + b, @@ -1711,6 +1806,7 @@ class NsdTopic(DescriptorTopic): ).keys() ], ) + raise EngineException( f"Error in validating new descriptor: {changed_nodes} cannot be modified, " "there are disallowed changes in the ns descriptor. ",