From 210ec7ca0dc0df57a9302486eedea70f9afafb18 Mon Sep 17 00:00:00 2001 From: Dario Faccin Date: Wed, 31 May 2023 14:52:06 +0200 Subject: [PATCH] Update from master part 2 Squashed commit of the following: commit 364627c364a86a85696781766326dd690a362bc4 Author: vegall Date: Fri Mar 17 15:09:50 2023 +0000 Feature 10972: Support of volume multi-attach Change-Id: I6e88ee52e5e882dbb4ec7d66cf648fbe07d40509 Signed-off-by: vegall commit 0e51779fd37dc5c12f3bd19d78f7341ed0a67b7a Author: gifrerenom Date: Tue Apr 18 16:38:42 2023 +0000 Feature 10937: Transport API (TAPI) WIM connector for RO Change-Id: If0dac9f8ba2d00945eb86a89fb0b2f174c672794 Signed-off-by: gifrerenom commit 370e36bafdcb90f212e289b87290f39be141b3d4 Author: elumalai Date: Tue Apr 25 16:22:56 2023 +0530 Feature 10979: Static IPv6 Dual Stack Assignment Added support for static dual stack IP assignment Change-Id: Ief10ae955fb870a3417f68e1c5f7bda570cb6470 Signed-off-by: elumalai commit b1bc66933aa392b9d7518f7cebc711700335389c Author: Gabriel Cuba Date: Fri Aug 19 18:23:00 2022 -0500 Fix Bug 2098: Get VDUs from VNFR when Heal op has no additionalPrameters When Heal is requested without vdu or count-index parameters, RO will recreate all VDUs from VNFR Change-Id: Idf2cf158bcb33e7b0c307298c14504cc7aa77e2a Signed-off-by: Gabriel Cuba (cherry picked from commit 2fbb3a264e4117f4a6569fede6558836d67ac4a4) commit aba1518f487b4b65861eb30f553c4edb72ad972e Author: Gulsum Atici Date: Mon May 15 11:55:13 2023 +0300 Fix VimAdminThread run method The run_coroutine_threadsafe() function is used to schedule a coroutine object from a different thread and returns a concurrent.futures.Future. run_coroutine_threadsafe is unnecessary to run the main task and replaced with asyncio.run(). Change-Id: I8ea31828a9798140d596165443bdf26659b4eef8 Signed-off-by: Gulsum Atici commit f17e5bb6b6da4432628dd65ce9ad633e6441f67c Author: Gulsum Atici Date: Wed May 10 22:52:57 2023 +0300 Minor updates in Dockerfile Change-Id: I79b43654d181f6976a4e544d58fb92aa1b67e760 Signed-off-by: Gulsum Atici commit a264b7a460b28d7454fc95fe659da46f55b0c155 Author: Gulsum Atici Date: Tue May 9 14:57:22 2023 +0300 Ubuntu 22.04 and Python 3.10 preparation Change-Id: I87164827a8849c16b5e3a804d9673a578e5a5593 Signed-off-by: Gulsum Atici commit 1c89c08a0dd1c79b5adff3ac1cc123239762e06a Author: garciadeblas Date: Tue Apr 18 15:06:30 2023 +0200 Clean stage-archive.sh and use allowlist_extenals in tox.ini Change-Id: I18f0bc3e263063b5b1d2cf211f028f6bb0e4bceb Signed-off-by: garciadeblas commit 51e72a0f7479b3064b4b11891eb524d42f4738b0 Author: elumalai Date: Fri Apr 28 19:41:49 2023 +0530 Coverity CWE 330: Use of Insufficiently Random Values Added support to fix CWE 330: Use of Insufficiently Random Values Coverity issue Change-Id: Ib12ebeeb9b0cc10af9980fe8661eb6230c2f6d6d Signed-off-by: elumalai commit e17cd946aed699b5feca83d37591d04f129a8f52 Author: elumalai Date: Fri Apr 28 18:04:24 2023 +0530 Coverity CWE 688: Function Call With Incorrect Variable or Reference as Argument Added fix for CWE 688 Typo in Identifier Change-Id: I53b5142451b809be638d73626265531057722169 Signed-off-by: elumalai commit 730cfaff466fb3c9b1446ecef5213916195ff861 Author: Gabriel Cuba Date: Mon Mar 13 22:26:38 2023 -0500 Feature 10975: get flavor-id from additionalParams if specified Change-Id: I1c9b1ec43c80f3793b475187681f4c2005d77375 Signed-off-by: Gabriel Cuba commit 2d3f63b055e6a38e95bcff56a8ddef32767b11ef Author: garciadeblas Date: Tue Apr 11 10:08:26 2023 +0200 Update stage-build to run tox sequentially Change-Id: I967f19a8c35700290e93c9d8bd863b63b7c2d239 Signed-off-by: garciadeblas (cherry picked from commit ea063c7a6ae6a5d7e11e8c22f9707d5c8f674ac7) commit b3dbfcad6f4b2bebc9ebc20fd7129a18879cb20c Author: Gabriel Cuba Date: Tue Mar 14 10:58:39 2023 -0500 Feature 10978: Add support of ipv6_address_mode and ipv6_ra_mode to openstack connector Change-Id: I8ca741a215bd2c52999dee1ea301d4e02aafcb24 Signed-off-by: Gabriel Cuba commit 01619d5b596e01ac8cd6d27bf01a1174e6b3f97b Author: Gulsum Atici Date: Wed Mar 22 22:57:26 2023 +0300 Keep vim_details while reporting VM deletion Change-Id: I27577b2fc93a585affc947abcec8352562f23f49 Signed-off-by: Gulsum Atici commit 98740c03567ff8c5a22f06fd3f049248a9e5f98d Author: Pedro Escaleira Date: Wed Feb 22 10:48:52 2023 +0000 Bug 2217 fixed: modified the cloud-init merge configs and defined the default SSH keys within the system_info instead of users Change-Id: I12e26a88fb9b50c4a78b9fa8ee2cb5d4b4bf6d00 Signed-off-by: Pedro Escaleira commit d586d89bde00acaf22debd7f657d605c9d095571 Author: Gulsum Atici Date: Mon Feb 13 18:40:03 2023 +0300 Feature 10960 Performance optimizations for the polling of VM status in RO Change-Id: If785bbeaab66e0839541bf94184ce37114e67bd4 Signed-off-by: Gulsum Atici commit 4c1dd54ae02e82f11a60058a1b7c7b0137ac572e Author: Gabriel Cuba Date: Tue Feb 14 12:43:32 2023 -0500 Refactor ns.py so that RO uses the IP profile as it comes from LCM Change-Id: I36983c86d7c76ad8a9b93eb6eae254f844717e0e Signed-off-by: Gabriel Cuba commit 3822010a26b2e21290b6acdf288db277c7f36605 Author: garciadeblas Date: Mon Feb 13 17:48:32 2023 +0100 Fix bug 2216 to remove hardcoded numa affinity in VIO Change-Id: I0912c2841e7c5c1febe056ba092afedaea77f6a1 Signed-off-by: garciadeblas commit 778f3cc8c052bd17d0da32f07b880616d25f935a Author: Lovejeet Singh Date: Mon Feb 13 16:15:40 2023 +0530 Bug 2202: Adding support for cinder V3 API with V2 API for persistent volumes. Change-Id: I7034564b91b94e6be242cb2ce0f4a5b147b87d64 Signed-off-by: Lovejeet Singh Change-Id: I7ac1bd1d9896788812f456c678b1f5222a1f1ad6 Signed-off-by: Dario Faccin --- Dockerfile.local | 3 + NG-RO/osm_ng_ro/ns.py | 84 ++++- NG-RO/osm_ng_ro/ns_thread.py | 103 +++++- NG-RO/osm_ng_ro/tests/test_ns.py | 2 + NG-RO/osm_ng_ro/tests/test_ns_thread.py | 213 +++++++++++ NG-RO/osm_ng_ro/validation.py | 1 + RO-SDN-tapi/osm_rosdn_tapi/__init__.py | 23 ++ RO-SDN-tapi/osm_rosdn_tapi/conn_info.py | 77 ++++ RO-SDN-tapi/osm_rosdn_tapi/exceptions.py | 128 +++++++ RO-SDN-tapi/osm_rosdn_tapi/log_messages.py | 59 +++ .../osm_rosdn_tapi/message_composers.py | 93 +++++ .../osm_rosdn_tapi/message_templates.py | 54 +++ .../osm_rosdn_tapi/services_composer.py | 151 ++++++++ RO-SDN-tapi/osm_rosdn_tapi/tapi_client.py | 240 ++++++++++++ RO-SDN-tapi/osm_rosdn_tapi/tests/__init__.py | 23 ++ RO-SDN-tapi/osm_rosdn_tapi/tests/constants.py | 80 ++++ .../osm_rosdn_tapi/tests/exceptions.py | 45 +++ .../osm_rosdn_tapi/tests/mock_osm_ro.py | 99 +++++ .../osm_rosdn_tapi/tests/mock_tapi_handler.py | 174 +++++++++ .../osm_rosdn_tapi/tests/test_wim_tapi.py | 179 +++++++++ RO-SDN-tapi/osm_rosdn_tapi/tests/tools.py | 60 +++ RO-SDN-tapi/osm_rosdn_tapi/wimconn_tapi.py | 346 ++++++++++++++++++ RO-SDN-tapi/requirements.in | 17 + RO-SDN-tapi/setup.py | 58 +++ RO-SDN-tapi/stdeb.cfg | 17 + .../tests/test_vimconn_openstack.py | 93 ++++- .../osm_rovim_openstack/vimconn_openstack.py | 83 ++++- devops-stages/stage-build.sh | 3 +- ...WIM_connector_for_RO-9dc1e3aba7e1f3ad.yaml | 20 + ..._volume_multi_attach-57c1232c1a54ab18.yaml | 21 ++ requirements-dev.txt | 2 +- requirements.in | 1 + tox.ini | 21 +- 33 files changed, 2525 insertions(+), 48 deletions(-) create mode 100644 RO-SDN-tapi/osm_rosdn_tapi/__init__.py create mode 100644 RO-SDN-tapi/osm_rosdn_tapi/conn_info.py create mode 100644 RO-SDN-tapi/osm_rosdn_tapi/exceptions.py create mode 100644 RO-SDN-tapi/osm_rosdn_tapi/log_messages.py create mode 100644 RO-SDN-tapi/osm_rosdn_tapi/message_composers.py create mode 100644 RO-SDN-tapi/osm_rosdn_tapi/message_templates.py create mode 100644 RO-SDN-tapi/osm_rosdn_tapi/services_composer.py create mode 100644 RO-SDN-tapi/osm_rosdn_tapi/tapi_client.py create mode 100644 RO-SDN-tapi/osm_rosdn_tapi/tests/__init__.py create mode 100644 RO-SDN-tapi/osm_rosdn_tapi/tests/constants.py create mode 100644 RO-SDN-tapi/osm_rosdn_tapi/tests/exceptions.py create mode 100644 RO-SDN-tapi/osm_rosdn_tapi/tests/mock_osm_ro.py create mode 100644 RO-SDN-tapi/osm_rosdn_tapi/tests/mock_tapi_handler.py create mode 100644 RO-SDN-tapi/osm_rosdn_tapi/tests/test_wim_tapi.py create mode 100644 RO-SDN-tapi/osm_rosdn_tapi/tests/tools.py create mode 100644 RO-SDN-tapi/osm_rosdn_tapi/wimconn_tapi.py create mode 100644 RO-SDN-tapi/requirements.in create mode 100644 RO-SDN-tapi/setup.py create mode 100644 RO-SDN-tapi/stdeb.cfg create mode 100644 releasenotes/notes/feature_10937_Transport_API_WIM_connector_for_RO-9dc1e3aba7e1f3ad.yaml create mode 100644 releasenotes/notes/feature_10972_Support_of_volume_multi_attach-57c1232c1a54ab18.yaml diff --git a/Dockerfile.local b/Dockerfile.local index 36a53eef..843e0537 100644 --- a/Dockerfile.local +++ b/Dockerfile.local @@ -82,6 +82,9 @@ RUN python3 -m build /build/RO-SDN-juniper_contrail && \ RUN python3 -m build /build/RO-VIM-gcp && \ python3 -m pip install /build/RO-VIM-gcp/dist/*.whl +RUN python3 -m build /build/RO-SDN-tapi && \ + python3 -m pip install /build/RO-SDN-tapi/dist/*.whl + FROM ubuntu:20.04 RUN DEBIAN_FRONTEND=noninteractive apt-get --yes update && \ diff --git a/NG-RO/osm_ng_ro/ns.py b/NG-RO/osm_ng_ro/ns.py index fd0ad07a..75bae1b1 100644 --- a/NG-RO/osm_ng_ro/ns.py +++ b/NG-RO/osm_ng_ro/ns.py @@ -117,6 +117,7 @@ class Ns(object): "flavor": Ns._process_flavor_params, "vdu": Ns._process_vdu_params, "affinity-or-anti-affinity-group": Ns._process_affinity_group_params, + "shared-volumes": Ns._process_shared_volumes_params, } self.db_path_map = { "net": "vld", @@ -124,6 +125,7 @@ class Ns(object): "flavor": "flavor", "vdu": "vdur", "affinity-or-anti-affinity-group": "affinity-or-anti-affinity-group", + "shared-volumes": "shared-volumes", } def init_db(self, target_version): @@ -859,7 +861,6 @@ class Ns(object): flavor_data_name = flavor_data.copy() flavor_data_name["name"] = target_flavor["name"] extra_dict["params"] = {"flavor_data": flavor_data_name} - return extra_dict @staticmethod @@ -1053,10 +1054,37 @@ class Ns(object): if not virtual_storage_desc.get("vdu-storage-requirements"): return False for item in virtual_storage_desc.get("vdu-storage-requirements", {}): - if item.get("key") == "keep-volume" and item.get("value") == "true": + if item.get("key") == "keep-volume" and item.get("value").lower() == "true": return True return False + @staticmethod + def is_shared_volume( + virtual_storage_desc: Dict[str, Any], vnfd_id: str + ) -> (str, bool): + """Function to decide if the volume type is multi attached or not . + + Args: + virtual_storage_desc (Dict[str, Any]): virtual storage description dictionary + vnfd_id (str): vnfd id + + Returns: + bool (True/False) + name (str) New name if it is a multiattach disk + """ + + if vdu_storage_requirements := virtual_storage_desc.get( + "vdu-storage-requirements", {} + ): + for item in vdu_storage_requirements: + if ( + item.get("key") == "multiattach" + and item.get("value").lower() == "true" + ): + name = f"shared-{virtual_storage_desc['id']}-{vnfd_id}" + return name, True + return virtual_storage_desc["id"], False + @staticmethod def _sort_vdu_interfaces(target_vdu: dict) -> None: """Sort the interfaces according to position number. @@ -1385,7 +1413,6 @@ class Ns(object): "size": root_disk["size-of-storage"], "keep": Ns.is_volume_keeping_required(root_disk), } - disk_list.append(persistent_root_disk[vsd["id"]]) break @@ -1395,6 +1422,7 @@ class Ns(object): persistent_root_disk: dict, persistent_ordinary_disk: dict, disk_list: list, + vnf_id: str = None, ) -> None: """Fill the disk list by adding persistent ordinary disks. @@ -1412,9 +1440,12 @@ class Ns(object): == "persistent-storage:persistent-storage" and disk["id"] not in persistent_root_disk.keys() ): + name, multiattach = Ns.is_shared_volume(disk, vnf_id) persistent_ordinary_disk[disk["id"]] = { + "name": name, "size": disk["size-of-storage"], "keep": Ns.is_volume_keeping_required(disk), + "multiattach": multiattach, } disk_list.append(persistent_ordinary_disk[disk["id"]]) @@ -1486,7 +1517,6 @@ class Ns(object): image_text = ns_preffix + ":image." + target_vdu["ns-image-id"] extra_dict = {"depends_on": [image_text]} net_list = [] - persistent_root_disk = {} persistent_ordinary_disk = {} vdu_instantiation_volumes_list = [] @@ -1494,7 +1524,6 @@ class Ns(object): disk_list = [] vnfd_id = vnfr["vnfd-id"] vnfd = db.get_one("vnfds", {"_id": vnfd_id}) - # If the position info is provided for all the interfaces, it will be sorted # according to position number ascendingly. if all( @@ -1565,7 +1594,11 @@ class Ns(object): ) # Add the persistent non-root disks to disk_list Ns._add_persistent_ordinary_disks_to_disk_list( - target_vdu, persistent_root_disk, persistent_ordinary_disk, disk_list + target_vdu, + persistent_root_disk, + persistent_ordinary_disk, + disk_list, + vnfd["id"], ) affinity_group_list = Ns._prepare_vdu_affinity_group_list( @@ -1590,7 +1623,23 @@ class Ns(object): "availability_zone_index": None, # TODO "availability_zone_list": None, # TODO } + return extra_dict + @staticmethod + def _process_shared_volumes_params( + target_shared_volume: Dict[str, Any], + indata: Dict[str, Any], + vim_info: Dict[str, Any], + target_record_id: str, + **kwargs: Dict[str, Any], + ) -> Dict[str, Any]: + extra_dict = {} + shared_volume_data = { + "size": target_shared_volume["size-of-storage"], + "name": target_shared_volume["id"], + "type": target_shared_volume["type-of-storage"], + } + extra_dict["params"] = shared_volume_data return extra_dict @staticmethod @@ -1628,7 +1677,6 @@ class Ns(object): extra_dict["params"] = { "affinity_group_data": affinity_group_data, } - return extra_dict @staticmethod @@ -1664,6 +1712,7 @@ class Ns(object): vim_details = {} vim_details_text = existing_vdu["vim_info"][target_id].get("vim_details", None) + if vim_details_text: vim_details = yaml.safe_load(f"{vim_details_text}") @@ -1857,7 +1906,6 @@ class Ns(object): process_params = None vdu2cloud_init = indata.get("cloud_init_content") or {} ro_nsr_public_key = db_ro_nsr["public_key"] - # According to the type of item, the path, the target_list, # the existing_list and the method to process params are set db_path = self.db_path_map[item] @@ -1877,27 +1925,29 @@ class Ns(object): ) target_list = target_vnf.get(db_path, []) if target_vnf else [] existing_list = vnfr.get(db_path, []) - elif item in ("image", "flavor", "affinity-or-anti-affinity-group"): + elif item in ( + "image", + "flavor", + "affinity-or-anti-affinity-group", + "shared-volumes", + ): db_record = "nsrs:{}:{}".format(nsr_id, db_path) target_list = indata.get(item, []) existing_list = db_nsr.get(item, []) else: raise NsException("Item not supported: {}", item) - # ensure all the target_list elements has an "id". If not assign the index as id if target_list is None: target_list = [] for target_index, tl in enumerate(target_list): if tl and not tl.get("id"): tl["id"] = str(target_index) - # step 1 items (networks,vdus,...) to be deleted/updated for item_index, existing_item in enumerate(existing_list): target_item = next( (t for t in target_list if t["id"] == existing_item["id"]), None, ) - for target_vim, existing_viminfo in existing_item.get( "vim_info", {} ).items(): @@ -1941,7 +1991,6 @@ class Ns(object): # step 2 items (networks,vdus,...) to be created for target_item in target_list: item_index = -1 - for item_index, existing_item in enumerate(existing_list): if existing_item["id"] == target_item["id"]: break @@ -1998,7 +2047,6 @@ class Ns(object): } ) self.logger.debug("calculate_diff_items kwargs={}".format(kwargs)) - extra_dict = process_params( target_item, indata, @@ -2068,7 +2116,13 @@ class Ns(object): changes_list = [] # NS vld, image and flavor - for item in ["net", "image", "flavor", "affinity-or-anti-affinity-group"]: + for item in [ + "net", + "image", + "flavor", + "affinity-or-anti-affinity-group", + "shared-volumes", + ]: self.logger.debug("process NS={} {}".format(nsr_id, item)) diff_items, task_index = self.calculate_diff_items( indata=indata, diff --git a/NG-RO/osm_ng_ro/ns_thread.py b/NG-RO/osm_ng_ro/ns_thread.py index 03e8b30e..03255e3f 100644 --- a/NG-RO/osm_ng_ro/ns_thread.py +++ b/NG-RO/osm_ng_ro/ns_thread.py @@ -347,7 +347,6 @@ class VimInteractionVdu(VimInteractionBase): created = False created_items = {} target_vim = self.my_vims[ro_task["target_id"]] - try: created = True params = task["params"] @@ -389,7 +388,6 @@ class VimInteractionVdu(VimInteractionBase): ) affinity_group["affinity_group_id"] = affinity_group_id - vim_vm_id, created_items = target_vim.new_vminstance(**params_copy) interfaces = [iface["vim_id"] for iface in params_copy["net_list"]] @@ -691,6 +689,102 @@ class VimInteractionImage(VimInteractionBase): return "FAILED", ro_vim_item_update +class VimInteractionSharedVolume(VimInteractionBase): + def delete(self, ro_task, task_index): + task = ro_task["tasks"][task_index] + task_id = task["task_id"] + shared_volume_vim_id = ro_task["vim_info"]["vim_id"] + ro_vim_item_update_ok = { + "vim_status": "DELETED", + "created": False, + "vim_message": "DELETED", + "vim_id": None, + } + try: + if shared_volume_vim_id: + target_vim = self.my_vims[ro_task["target_id"]] + target_vim.delete_shared_volumes(shared_volume_vim_id) + except vimconn.VimConnNotFoundException: + ro_vim_item_update_ok["vim_message"] = "already deleted" + except vimconn.VimConnException as e: + self.logger.error( + "ro_task={} vim={} del-shared-volume={}: {}".format( + ro_task["_id"], ro_task["target_id"], shared_volume_vim_id, e + ) + ) + ro_vim_item_update = { + "vim_status": "VIM_ERROR", + "vim_message": "Error while deleting: {}".format(e), + } + + return "FAILED", ro_vim_item_update + + self.logger.debug( + "task={} {} del-shared-volume={} {}".format( + task_id, + ro_task["target_id"], + shared_volume_vim_id, + ro_vim_item_update_ok.get("vim_message", ""), + ) + ) + + return "DONE", ro_vim_item_update_ok + + def new(self, ro_task, task_index, task_depends): + task = ro_task["tasks"][task_index] + task_id = task["task_id"] + created = False + created_items = {} + target_vim = self.my_vims[ro_task["target_id"]] + + try: + shared_volume_name = None + shared_volume_vim_id = None + shared_volume_data = None + + if task.get("params"): + shared_volume_data = task["params"] + + if shared_volume_data: + self.logger.info( + f"Creating the new shared_volume for {shared_volume_data}\n" + ) + ( + shared_volume_name, + shared_volume_vim_id, + ) = target_vim.new_shared_volumes(shared_volume_data) + created = True + created_items[shared_volume_vim_id] = shared_volume_name + + ro_vim_item_update = { + "vim_id": shared_volume_vim_id, + "vim_status": "DONE", + "created": created, + "created_items": created_items, + "vim_details": None, + "vim_message": None, + } + self.logger.debug( + "task={} {} new-shared-volume={} created={}".format( + task_id, ro_task["target_id"], shared_volume_vim_id, created + ) + ) + + return "DONE", ro_vim_item_update + except (vimconn.VimConnException, NsWorkerException) as e: + self.logger.error( + "task={} vim={} new-shared-volume:" + " {}".format(task_id, ro_task["target_id"], e) + ) + ro_vim_item_update = { + "vim_status": "VIM_ERROR", + "created": created, + "vim_message": str(e), + } + + return "FAILED", ro_vim_item_update + + class VimInteractionFlavor(VimInteractionBase): def delete(self, ro_task, task_index): task = ro_task["tasks"][task_index] @@ -739,7 +833,6 @@ class VimInteractionFlavor(VimInteractionBase): created = False created_items = {} target_vim = self.my_vims[ro_task["target_id"]] - try: # FIND vim_flavor_id = None @@ -1540,6 +1633,9 @@ class NsWorker(threading.Thread): self.db = db self.item2class = { "net": VimInteractionNet(self.db, self.my_vims, self.db_vims, self.logger), + "shared-volumes": VimInteractionSharedVolume( + self.db, self.my_vims, self.db_vims, self.logger + ), "vdu": VimInteractionVdu(self.db, self.my_vims, self.db_vims, self.logger), "image": VimInteractionImage( self.db, self.my_vims, self.db_vims, self.logger @@ -2301,7 +2397,6 @@ class NsWorker(threading.Thread): lock_object = LockRenew.add_lock_object( "ro_tasks", ro_task, self ) - if task["action"] == "DELETE": ( new_status, diff --git a/NG-RO/osm_ng_ro/tests/test_ns.py b/NG-RO/osm_ng_ro/tests/test_ns.py index d966a861..a2368195 100644 --- a/NG-RO/osm_ng_ro/tests/test_ns.py +++ b/NG-RO/osm_ng_ro/tests/test_ns.py @@ -4547,6 +4547,8 @@ class TestProcessVduParams(unittest.TestCase): { "size": "10", "keep": False, + "multiattach": False, + "name": "persistent-volume2", } ] self.ns._add_persistent_ordinary_disks_to_disk_list( diff --git a/NG-RO/osm_ng_ro/tests/test_ns_thread.py b/NG-RO/osm_ng_ro/tests/test_ns_thread.py index 0914a066..4e42b4f3 100644 --- a/NG-RO/osm_ng_ro/tests/test_ns_thread.py +++ b/NG-RO/osm_ng_ro/tests/test_ns_thread.py @@ -27,6 +27,7 @@ from osm_ng_ro.ns_thread import ( VimInteractionMigration, VimInteractionNet, VimInteractionResize, + VimInteractionSharedVolume, ) from osm_ro_plugin.vimconn import VimConnConnectionException, VimConnException @@ -1450,6 +1451,218 @@ class TestVimInteractionNet(unittest.TestCase): instance.refresh(ro_task) +class TestVimInteractionSharedVolume(unittest.TestCase): + def setUp(self): + module_name = "osm_ro_plugin" + self.target_vim = MagicMock(name=f"{module_name}.vimconn.VimConnector") + self.task_depends = None + + patches = [patch(f"{module_name}.vimconn.VimConnector", self.target_vim)] + + # Enabling mocks and add cleanups + for mock in patches: + mock.start() + self.addCleanup(mock.stop) + + def test__new_shared_volume_ok(self): + """ + create a shared volume with attributes set in params + """ + db = "test_db" + logger = "test_logger" + my_vims = "test-vim" + db_vims = { + 0: { + "config": {}, + }, + } + + instance = VimInteractionSharedVolume(db, logger, my_vims, db_vims) + with patch.object(instance, "my_vims", [self.target_vim]), patch.object( + instance, "logger", logging + ), patch.object(instance, "db_vims", db_vims): + ro_task = { + "target_id": 0, + "tasks": { + "task_index_1": { + "target_id": 0, + "action_id": "123456", + "nsr_id": "654321", + "task_id": "123456:1", + "status": "SCHEDULED", + "action": "CREATE", + "item": "test_item", + "target_record": "test_target_record", + "target_record_id": "test_target_record_id", + # values coming from extra_dict + "params": { + "shared_volume_data": { + "size": "10", + "name": "shared-volume", + "type": "multiattach", + } + }, + "find_params": {}, + "depends_on": "test_depends_on", + }, + }, + } + task_index = "task_index_1" + self.target_vim.new_shared_volumes.return_value = ("", "shared-volume") + result = instance.new(ro_task, task_index, self.task_depends) + self.assertEqual(result[0], "DONE") + self.assertEqual(result[1].get("vim_id"), "shared-volume") + self.assertEqual(result[1].get("created"), True) + self.assertEqual(result[1].get("vim_status"), "DONE") + + def test__new_shared_volume_failed(self): + """ + create a shared volume with attributes set in params failed + """ + db = "test_db" + logger = "test_logger" + my_vims = "test-vim" + db_vims = { + 0: { + "config": {}, + }, + } + + instance = VimInteractionSharedVolume(db, logger, my_vims, db_vims) + with patch.object(instance, "my_vims", [self.target_vim]), patch.object( + instance, "logger", logging + ), patch.object(instance, "db_vims", db_vims): + ro_task = { + "target_id": 0, + "tasks": { + "task_index_1": { + "target_id": 0, + "action_id": "123456", + "nsr_id": "654321", + "task_id": "123456:1", + "status": "SCHEDULED", + "action": "CREATE", + "item": "test_item", + "target_record": "test_target_record", + "target_record_id": "test_target_record_id", + # values coming from extra_dict + "params": { + "shared_volume_data": { + "size": "10", + "name": "shared-volume", + "type": "multiattach", + } + }, + "find_params": {}, + "depends_on": "test_depends_on", + }, + }, + } + task_index = "task_index_1" + self.target_vim.new_shared_volumes.side_effect = VimConnException( + "Connection failed." + ) + result = instance.new(ro_task, task_index, self.task_depends) + self.assertEqual(result[0], "FAILED") + self.assertEqual(result[1].get("vim_message"), "Connection failed.") + self.assertEqual(result[1].get("created"), False) + self.assertEqual(result[1].get("vim_status"), "VIM_ERROR") + + def test__delete_shared_volume_ok(self): + """ + Delete a shared volume with attributes set in params + """ + db = "test_db" + logger = "test_logger" + my_vims = "test-vim" + db_vims = { + 0: { + "config": {}, + }, + } + + instance = VimInteractionSharedVolume(db, logger, my_vims, db_vims) + with patch.object(instance, "my_vims", [self.target_vim]), patch.object( + instance, "logger", logging + ), patch.object(instance, "db_vims", db_vims): + ro_task = { + "target_id": 0, + "tasks": { + "task_index_3": { + "target_id": 0, + "task_id": "123456:1", + }, + }, + "vim_info": { + "created": False, + "created_items": None, + "vim_id": "sample_shared_volume_id_3", + "vim_name": "sample_shared_volume_3", + "vim_status": None, + "vim_details": "some-details", + "vim_message": None, + "refresh_at": None, + }, + } + + task_index = "task_index_3" + self.target_vim.delete_shared_volumes.return_value = True + result = instance.delete(ro_task, task_index) + self.assertEqual(result[0], "DONE") + self.assertEqual(result[1].get("vim_id"), None) + self.assertEqual(result[1].get("created"), False) + self.assertEqual(result[1].get("vim_status"), "DELETED") + + def test__delete_shared_volume_failed(self): + """ + Delete a shared volume with attributes set in params failed + """ + db = "test_db" + logger = "test_logger" + my_vims = "test-vim" + db_vims = { + 0: { + "config": {}, + }, + } + + instance = VimInteractionSharedVolume(db, logger, my_vims, db_vims) + with patch.object(instance, "my_vims", [self.target_vim]), patch.object( + instance, "logger", logging + ), patch.object(instance, "db_vims", db_vims): + ro_task = { + "_id": "122436:1", + "target_id": 0, + "tasks": { + "task_index_3": { + "target_id": 0, + "task_id": "123456:1", + }, + }, + "vim_info": { + "created": False, + "created_items": None, + "vim_id": "sample_shared_volume_id_3", + "vim_name": "sample_shared_volume_3", + "vim_status": None, + "vim_details": "some-details", + "vim_message": None, + "refresh_at": None, + }, + } + + task_index = "task_index_3" + self.target_vim.delete_shared_volumes.side_effect = VimConnException( + "Connection failed." + ) + result = instance.delete(ro_task, task_index) + self.assertEqual(result[0], "FAILED") + self.assertEqual( + result[1].get("vim_message"), "Error while deleting: Connection failed." + ) + self.assertEqual(result[1].get("vim_status"), "VIM_ERROR") + + class TestVimInteractionAffinityGroup(unittest.TestCase): def setUp(self): module_name = "osm_ro_plugin" diff --git a/NG-RO/osm_ng_ro/validation.py b/NG-RO/osm_ng_ro/validation.py index 2601e90c..e4eed742 100644 --- a/NG-RO/osm_ng_ro/validation.py +++ b/NG-RO/osm_ng_ro/validation.py @@ -101,6 +101,7 @@ deploy_schema = { }, "image": deploy_item_list, "flavor": deploy_item_list, + "shared-volumes": deploy_item_list, "ns": { "type": "object", "properties": { diff --git a/RO-SDN-tapi/osm_rosdn_tapi/__init__.py b/RO-SDN-tapi/osm_rosdn_tapi/__init__.py new file mode 100644 index 00000000..ab3006ab --- /dev/null +++ b/RO-SDN-tapi/osm_rosdn_tapi/__init__.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- + +####################################################################################### +# This file is part of OSM RO module +# +# Copyright ETSI Contributors and Others. +# +# 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. +####################################################################################### +# This work has been performed in the context of the TeraFlow Project - +# funded by the European Commission under Grant number 101015857 through the +# Horizon 2020 program. +####################################################################################### diff --git a/RO-SDN-tapi/osm_rosdn_tapi/conn_info.py b/RO-SDN-tapi/osm_rosdn_tapi/conn_info.py new file mode 100644 index 00000000..70c6e590 --- /dev/null +++ b/RO-SDN-tapi/osm_rosdn_tapi/conn_info.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- + +####################################################################################### +# This file is part of OSM RO module +# +# Copyright ETSI Contributors and Others. +# +# 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. +####################################################################################### +# This work has been performed in the context of the TeraFlow Project - +# funded by the European Commission under Grant number 101015857 through the +# Horizon 2020 program. +# Contributors: +# - Lluis Gifre +# - Ricard Vilalta +####################################################################################### + +"""This file contains the methods to compose the conn_info data structures for the +Transport API (TAPI) WIM connector.""" + + +def conn_info_compose_unidirectional( + service_az_uuid, + service_az_endpoints, + service_za_uuid, + service_za_endpoints, + requested_capacity=None, + vlan_constraint=None, +): + conn_info_az = { + "uuid": service_az_uuid, + "endpoints": service_az_endpoints, + } + conn_info_za = { + "uuid": service_za_uuid, + "endpoints": service_za_endpoints, + } + if requested_capacity is not None: + conn_info_az["requested_capacity"] = requested_capacity + conn_info_za["requested_capacity"] = requested_capacity + if vlan_constraint is not None: + conn_info_az["vlan_constraint"] = vlan_constraint + conn_info_za["vlan_constraint"] = vlan_constraint + conn_info = { + "az": conn_info_az, + "za": conn_info_za, + "bidirectional": False, + } + return conn_info + + +def conn_info_compose_bidirectional( + service_uuid, + service_endpoints, + requested_capacity=None, + vlan_constraint=None, +): + conn_info = { + "uuid": service_uuid, + "endpoints": service_endpoints, + "bidirectional": True, + } + if requested_capacity is not None: + conn_info["requested_capacity"] = requested_capacity + if vlan_constraint is not None: + conn_info["vlan_constraint"] = vlan_constraint + return conn_info diff --git a/RO-SDN-tapi/osm_rosdn_tapi/exceptions.py b/RO-SDN-tapi/osm_rosdn_tapi/exceptions.py new file mode 100644 index 00000000..66344227 --- /dev/null +++ b/RO-SDN-tapi/osm_rosdn_tapi/exceptions.py @@ -0,0 +1,128 @@ +# -*- coding: utf-8 -*- + +####################################################################################### +# This file is part of OSM RO module +# +# Copyright ETSI Contributors and Others. +# +# 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. +####################################################################################### +# This work has been performed in the context of the TeraFlow Project - +# funded by the European Commission under Grant number 101015857 through the +# Horizon 2020 program. +# Contributors: +# - Lluis Gifre +# - Ricard Vilalta +####################################################################################### + +"""This file contains the exception classes the Transport API (TAPI) WIM connector +can raise in case of error.""" + + +from http import HTTPStatus + +from osm_ro_plugin.sdnconn import SdnConnectorError + +from .log_messages import ( + _PREFIX, +) + + +class WimTapiError(SdnConnectorError): + """Base Exception for all WIM TAPI related errors.""" + + def __init__(self, message, http_code=HTTPStatus.INTERNAL_SERVER_ERROR.value): + super().__init__(_PREFIX + message) + self.http_code = http_code + + +class WimTapiConnectionPointsBadFormat(SdnConnectorError): + def __init__(self, connection_points): + MESSAGE = "ConnectionPoints({:s}) must be a list or tuple of length 2" + message = MESSAGE.format(str(connection_points)) + super().__init__(message, http_code=HTTPStatus.BAD_REQUEST) + + +class WimTapiIncongruentDirectionality(WimTapiError): + def __init__(self, services, service_endpoint_id): + MESSAGE = "Incongruent directionality: services={:s} service_endpoint_id={:s}" + message = MESSAGE.format(str(services), str(service_endpoint_id)) + super().__init__(message, http_code=HTTPStatus.INTERNAL_SERVER_ERROR) + + +class WimTapiIncongruentEndPoints(WimTapiError): + def __init__(self, services, service_endpoint_id): + MESSAGE = "Incongruent endpoints: services={:s} service_endpoint_id={:s}" + message = MESSAGE.format(str(services), str(service_endpoint_id)) + super().__init__(message, http_code=HTTPStatus.INTERNAL_SERVER_ERROR) + + +class WimTapiMissingConnPointField(WimTapiError): + def __init__(self, connection_point, field_name): + MESSAGE = "ConnectionPoint({:s}) has no field '{:s}'" + message = MESSAGE.format(str(connection_point), str(field_name)) + super().__init__(message, http_code=HTTPStatus.INTERNAL_SERVER_ERROR) + + +class WimTapiMissingMappingField(WimTapiError): + def __init__(self, mapping, field_name): + MESSAGE = "Mapping({:s}) has no field '{:s}'" + message = MESSAGE.format(str(mapping), str(field_name)) + super().__init__(message, http_code=HTTPStatus.INTERNAL_SERVER_ERROR) + + +class WimTapiServerNotAvailable(WimTapiError): + def __init__(self, message): + message = "Server not available: " + message + super().__init__(message, http_code=HTTPStatus.SERVICE_UNAVAILABLE) + + +class WimTapiServerRequestFailed(WimTapiError): + def __init__(self, message, http_code): + message = "Server request failed: " + message + super().__init__(message, http_code=http_code) + + +class WimTapiSipNotFound(WimTapiError): + def __init__(self, sip_id, sips): + MESSAGE = "SIP({:s}) not found in context SIPs({:s})" + message = MESSAGE.format(str(sip_id), str(sips)) + super().__init__(message, http_code=HTTPStatus.INTERNAL_SERVER_ERROR) + + +class WimTapiConnectivityServiceCreateFailed(WimTapiError): + def __init__(self, name, service_id, status_code, reply): + MESSAGE = "Create ConnectivityService({:s}, {:s}) Failed: reply={:s}" + message = MESSAGE.format(str(name), str(service_id), str(reply)) + super().__init__(message, http_code=status_code) + + +class WimTapiConnectivityServiceGetStatusFailed(WimTapiError): + def __init__(self, name, service_id, status_code, reply): + MESSAGE = "Get Status of ConnectivityService({:s}, {:s}) Failed: reply={:s}" + message = MESSAGE.format(str(name), str(service_id), str(reply)) + super().__init__(message, http_code=status_code) + + +class WimTapiConnectivityServiceDeleteFailed(WimTapiError): + def __init__(self, name, service_id, status_code, reply): + MESSAGE = "Delete ConnectivityService({:s}, {:s}) Failed: reply={:s}" + message = MESSAGE.format(str(name), str(service_id), str(reply)) + super().__init__(message, http_code=status_code) + + +class WimTapiUnsupportedServiceType(SdnConnectorError): + def __init__(self, service_type, supported_service_types): + MESSAGE = "Unsupported ServiceType({:s}). Supported ServiceTypes({:s})" + message = MESSAGE.format(str(service_type), str(supported_service_types)) + super().__init__(message, http_code=HTTPStatus.BAD_REQUEST) diff --git a/RO-SDN-tapi/osm_rosdn_tapi/log_messages.py b/RO-SDN-tapi/osm_rosdn_tapi/log_messages.py new file mode 100644 index 00000000..f8fc02ad --- /dev/null +++ b/RO-SDN-tapi/osm_rosdn_tapi/log_messages.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- + +####################################################################################### +# This file is part of OSM RO module +# +# Copyright ETSI Contributors and Others. +# +# 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. +####################################################################################### +# This work has been performed in the context of the TeraFlow Project - +# funded by the European Commission under Grant number 101015857 through the +# Horizon 2020 program. +# Contributors: +# - Lluis Gifre +# - Ricard Vilalta +####################################################################################### + +"""This file contains the templete strings used to generate log messages for the +Transport API (TAPI) WIM connector.""" + +_PREFIX = "WIM TAPI Connector: " + + +LOG_MSG_CREATE_REQUEST = ( + _PREFIX + "Create Connectivity Service: Request {:s} {:s}: {:s}" +) + +LOG_MSG_CREATE_REPLY = ( + _PREFIX + + "Create Connectivity Service: Reply {:s} {:s}: status_code={:d} reply={:s}" +) + +LOG_MSG_GET_STATUS_REQUEST = ( + _PREFIX + "Get Connectivity Service Status: Request {:s} {:s}" +) + +LOG_MSG_GET_STATUS_REPLY = ( + _PREFIX + + "Get Connectivity Service Status: Reply {:s} {:s}: status_code={:d} reply={:s}" +) + +LOG_MSG_DELETE_REQUEST = ( + _PREFIX + "Delete Connectivity Service: Request {:s} {:s}: {:s}" +) + +LOG_MSG_DELETE_REPLY = ( + _PREFIX + + "Delete Connectivity Service: Reply {:s} {:s}: status_code={:d} reply={:s}" +) diff --git a/RO-SDN-tapi/osm_rosdn_tapi/message_composers.py b/RO-SDN-tapi/osm_rosdn_tapi/message_composers.py new file mode 100644 index 00000000..f5bcdae2 --- /dev/null +++ b/RO-SDN-tapi/osm_rosdn_tapi/message_composers.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- + +####################################################################################### +# This file is part of OSM RO module +# +# Copyright ETSI Contributors and Others. +# +# 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. +####################################################################################### +# This work has been performed in the context of the TeraFlow Project - +# funded by the European Commission under Grant number 101015857 through the +# Horizon 2020 program. +# Contributors: +# - Lluis Gifre +# - Ricard Vilalta +####################################################################################### + +"""This file contains the helper methods used to compose the Transport API (TAPI) +messages sent by the TAPI WIM connector to the WIM.""" + + +import copy + +from .message_templates import ( + CREATE_TEMPLATE, + DELETE_TEMPLATE, + ENDPOINT_TEMPLATE, + REQUESTED_CAPACITY_TEMPLATE, + VLAN_CONSTRAINT_TEMPLATE, +) + + +def compose_requested_capacity(capacity, unit="GBPS"): + requested_capacity = copy.deepcopy(REQUESTED_CAPACITY_TEMPLATE) + total_size = requested_capacity["total-size"] + total_size["value"] = capacity + total_size["unit"] = "GBPS" + return requested_capacity + + +def compose_vlan_constraint(vlan_id): + vlan_constraint = copy.deepcopy(VLAN_CONSTRAINT_TEMPLATE) + vlan_constraint["vlan-id"] = vlan_id + return vlan_constraint + + +def compose_endpoint(sip): + sip_uuid = sip["uuid"] + endpoint = copy.deepcopy(ENDPOINT_TEMPLATE) + endpoint["service-interface-point"]["service-interface-point-uuid"] = sip_uuid + endpoint["layer-protocol-name"] = sip["layer-protocol-name"] + # TODO: implement smart selection of layer-protocol-qualifier instead of selecting first one available + supported_layer_protocol_qualifier = sip["supported-layer-protocol-qualifier"][0] + endpoint["layer-protocol-qualifier"] = supported_layer_protocol_qualifier + endpoint["local-id"] = sip_uuid + return endpoint + + +def compose_create_request( + service_uuid, + endpoints, + bidirectional=False, + requested_capacity=None, + vlan_constraint=None, +): + request = copy.deepcopy(CREATE_TEMPLATE) + con_svc = request["tapi-connectivity:connectivity-service"][0] + con_svc["uuid"] = service_uuid + con_svc["connectivity-direction"] = ( + "BIDIRECTIONAL" if bidirectional else "UNIDIRECTIONAL" + ) + con_svc["end-point"] = endpoints + if requested_capacity is not None: + con_svc["requested-capacity"] = requested_capacity + if vlan_constraint is not None: + con_svc["vlan-constraint"] = vlan_constraint + return request + + +def compose_delete_request(service_uuid): + request = copy.deepcopy(DELETE_TEMPLATE) + request["tapi-connectivity:input"]["uuid"] = service_uuid + return request diff --git a/RO-SDN-tapi/osm_rosdn_tapi/message_templates.py b/RO-SDN-tapi/osm_rosdn_tapi/message_templates.py new file mode 100644 index 00000000..ec2f0a16 --- /dev/null +++ b/RO-SDN-tapi/osm_rosdn_tapi/message_templates.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- + +####################################################################################### +# This file is part of OSM RO module +# +# Copyright ETSI Contributors and Others. +# +# 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. +####################################################################################### +# This work has been performed in the context of the TeraFlow Project - +# funded by the European Commission under Grant number 101015857 through the +# Horizon 2020 program. +# Contributors: +# - Lluis Gifre +# - Ricard Vilalta +####################################################################################### + +"""This file contains the template JSON-encoded messages used to compose the Transport +API (TAPI) messages sent by the TAPI WIM connector to the WIM.""" + +REQUESTED_CAPACITY_TEMPLATE = {"total-size": {"value": None, "unit": "GBPS"}} + +VLAN_CONSTRAINT_TEMPLATE = {"vlan-id": None} + +ENDPOINT_TEMPLATE = { + "service-interface-point": {"service-interface-point-uuid": None}, + "layer-protocol-name": None, + "layer-protocol-qualifier": None, + "local-id": None, +} + +CREATE_TEMPLATE = { + "tapi-connectivity:connectivity-service": [ + { + "uuid": None, + # "requested-capacity": REQUESTED_CAPACITY_TEMPLATE, + "connectivity-direction": "UNIDIRECTIONAL", + "end-point": [], + # "vlan-constraint": VLAN_CONSTRAINT_TEMPLATE, + } + ] +} + +DELETE_TEMPLATE = {"tapi-connectivity:input": {"uuid": None}} diff --git a/RO-SDN-tapi/osm_rosdn_tapi/services_composer.py b/RO-SDN-tapi/osm_rosdn_tapi/services_composer.py new file mode 100644 index 00000000..720e4230 --- /dev/null +++ b/RO-SDN-tapi/osm_rosdn_tapi/services_composer.py @@ -0,0 +1,151 @@ +# -*- coding: utf-8 -*- + +####################################################################################### +# This file is part of OSM RO module +# +# Copyright ETSI Contributors and Others. +# +# 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. +####################################################################################### +# This work has been performed in the context of the TeraFlow Project - +# funded by the European Commission under Grant number 101015857 through the +# Horizon 2020 program. +# Contributors: +# - Lluis Gifre +# - Ricard Vilalta +####################################################################################### + +"""This file contains the ServiceComposer class used by the Transport API (TAPI) WIM +connector to compose the services based on the service_endpoint_ids and their +directionality.""" + +from .exceptions import ( + WimTapiIncongruentDirectionality, + WimTapiIncongruentEndPoints, + WimTapiMissingMappingField, + WimTapiSipNotFound, +) +from .message_composers import ( + compose_endpoint, + compose_requested_capacity, + # compose_vlan_constraint, +) + + +class ServicesComposer: + def __init__(self, service_interface_points) -> None: + self.sips = service_interface_points + + # if unidirectional + # - a single service_endpoint item is created + # - the service_endpoint item contains with the 2 bidirectional SIPs + # if bidirectional + # - two service_endpoint items are created + # - each service_endpoint item containing a list of 2 unidirectional SIPs (in, out) + self.services = list() + + # TODO: populate dynamically capacity of the connection + self.requested_capacity = compose_requested_capacity(1, unit="GBPS") + + self.vlan_constraint = None + # TODO: VLAN needs to be processed by connection point; by now deactivated + # if connection_point.get("service_endpoint_encapsulation_type") == "dot1q": + # encap_info = connection_point.get("service_endpoint_encapsulation_info", {}) + # vlan_id = encap_info.get("vlan") + # if vlan_id is not None: + # vlan_constraint = compose_vlan_constraint(vlan_id) + + def add_bidirectional(self, service_endpoint_id): + if len(self.services) == 0: + # assume bidirectional, SIP is service_endpoint_id + service_interface_point = self.sips[service_endpoint_id] + self.services.append([compose_endpoint(service_interface_point)]) + elif len(self.services) == 1: + # is bidirectional, SIP is service_endpoint_id + if len(self.services[0]) > 1: + # too much endpoints per service + raise WimTapiIncongruentEndPoints(self.services, service_endpoint_id) + self.services[0].append(compose_endpoint(self.sips[service_endpoint_id])) + else: + raise WimTapiIncongruentDirectionality(self.services, service_endpoint_id) + + def add_unidirectional(self, service_endpoint_id, sip_input, sip_output): + if len(self.services) == 0: + # assume unidirectional + self.services.append([compose_endpoint(self.sips[sip_output])]) # AZ + self.services.append([compose_endpoint(self.sips[sip_input])]) # ZA + elif len(self.services) == 2: + # is unidirectional + + if len(self.services[0]) > 1: + # too much endpoints per service + raise WimTapiIncongruentEndPoints(self.services[0], service_endpoint_id) + self.services[0].append(compose_endpoint(self.sips[sip_input])) # AZ + + if len(self.services[1]) > 1: + # too much endpoints per service + raise WimTapiIncongruentEndPoints(self.services[1], service_endpoint_id) + self.services[1].insert(0, compose_endpoint(self.sips[sip_output])) # ZA + else: + raise WimTapiIncongruentDirectionality(self.services, service_endpoint_id) + + def add_service_endpoint(self, service_endpoint_id, mapping): + service_mapping_info = mapping.get("service_mapping_info", {}) + + if ( + len(service_mapping_info) == 0 + or "sip_input" not in service_mapping_info + or "sip_output" not in service_mapping_info + ): + # bidirectional (no mapping or no sip_input or no sip_output) + if service_endpoint_id not in self.sips: + raise WimTapiSipNotFound(service_endpoint_id, self.sips) + self.add_bidirectional(service_endpoint_id) + + else: + # unidirectional, sip_input and sip_output provided in mapping + + sip_input = service_mapping_info.get("sip_input") + if sip_input is None: + raise WimTapiMissingMappingField( + mapping, "service_mapping_info.sip_input" + ) + + if sip_input not in self.sips: + raise WimTapiSipNotFound(sip_input, self.sips) + + sip_output = service_mapping_info.get("sip_output") + if sip_output is None: + raise WimTapiMissingMappingField( + mapping, "service_mapping_info.sip_output" + ) + + if sip_output not in self.sips: + raise WimTapiSipNotFound(sip_output, self.sips) + + self.add_unidirectional(service_endpoint_id, sip_input, sip_output) + + def is_bidirectional(self): + return len(self.services) == 1 + + def dump(self, logger): + str_data = "\n".join( + [ + "services_composer {", + " services={:s}".format(str(self.services)), + " requested_capacity={:s}".format(str(self.requested_capacity)), + " vlan_constraint={:s}".format(str(self.vlan_constraint)), + "}", + ] + ) + logger.debug(str_data) diff --git a/RO-SDN-tapi/osm_rosdn_tapi/tapi_client.py b/RO-SDN-tapi/osm_rosdn_tapi/tapi_client.py new file mode 100644 index 00000000..ad5f9f3a --- /dev/null +++ b/RO-SDN-tapi/osm_rosdn_tapi/tapi_client.py @@ -0,0 +1,240 @@ +# -*- coding: utf-8 -*- + +####################################################################################### +# This file is part of OSM RO module +# +# Copyright ETSI Contributors and Others. +# +# 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. +####################################################################################### +# This work has been performed in the context of the TeraFlow Project - +# funded by the European Commission under Grant number 101015857 through the +# Horizon 2020 program. +# Contributors: +# - Lluis Gifre +# - Ricard Vilalta +####################################################################################### + +"""This file contains the TransportApiClient class used by the Transport API +(TAPI) WIM connector to interact with the underlying WIM.""" + +import requests + +from .exceptions import ( + WimTapiConnectivityServiceCreateFailed, + WimTapiConnectivityServiceDeleteFailed, + WimTapiConnectivityServiceGetStatusFailed, + WimTapiServerNotAvailable, + WimTapiServerRequestFailed, +) +from .log_messages import ( + LOG_MSG_CREATE_REPLY, + LOG_MSG_CREATE_REQUEST, + LOG_MSG_DELETE_REPLY, + LOG_MSG_DELETE_REQUEST, + LOG_MSG_GET_STATUS_REPLY, + LOG_MSG_GET_STATUS_REQUEST, +) +from .message_composers import ( + compose_create_request, + compose_delete_request, +) + +DEFAULT_TIMEOUT = 30 + +SUCCESS_HTTP_CODES = { + requests.codes.ok, # pylint: disable=no-member + requests.codes.created, # pylint: disable=no-member + requests.codes.accepted, # pylint: disable=no-member + requests.codes.no_content, # pylint: disable=no-member +} + +RESTCONF_DATA_URL = "{:s}/restconf/data" +RESTCONF_OPER_URL = "{:s}/restconf/operations" + +CONTEXT_URL = RESTCONF_DATA_URL + "/tapi-common:context" +CTX_SIPS_URL = CONTEXT_URL + "/service-interface-point" +CONN_CTX_URL = CONTEXT_URL + "/tapi-connectivity:connectivity-context" +CONN_SVC_URL = CONN_CTX_URL + "/connectivity-service" +DELETE_URL = RESTCONF_OPER_URL + "/tapi-connectivity:delete-connectivity-service" + + +class TransportApiClient: + def __init__(self, logger, wim, wim_account, config) -> None: + self.logger = logger + self.wim_url = wim["wim_url"] + + user = wim_account.get("user") + password = wim_account.get("password") + self.auth = ( + None + if user is None or user == "" or password is None or password == "" + else (user, password) + ) + + self.headers = {"Content-Type": "application/json"} + self.timeout = int(config.get("timeout", DEFAULT_TIMEOUT)) + + def get_root_context(self): + context_url = CONTEXT_URL.format(self.wim_url) + + try: + response = requests.get( + context_url, auth=self.auth, headers=self.headers, timeout=self.timeout + ) + http_code = response.status_code + except requests.exceptions.RequestException as e: + raise WimTapiServerNotAvailable(str(e)) + + if http_code != 200: + raise WimTapiServerRequestFailed( + "Unexpected status code", http_code=http_code + ) + + return response.json() + + def get_service_interface_points(self): + get_sips_url = CTX_SIPS_URL.format(self.wim_url) + + try: + response = requests.get( + get_sips_url, auth=self.auth, headers=self.headers, timeout=self.timeout + ) + http_code = response.status_code + except requests.exceptions.RequestException as e: + raise WimTapiServerNotAvailable(str(e)) + + if http_code != 200: + raise WimTapiServerRequestFailed( + "Unexpected status code", http_code=http_code + ) + + response = response.json() + response = response.get("tapi-common:service-interface-point", []) + return {sip["uuid"]: sip for sip in response} + + def get_service_status(self, name, service_uuid): + self.logger.debug(LOG_MSG_GET_STATUS_REQUEST.format(name, service_uuid)) + + try: + services_url = CONN_SVC_URL.format(self.wim_url) + response = requests.get( + services_url, auth=self.auth, headers=self.headers, timeout=self.timeout + ) + self.logger.debug( + LOG_MSG_GET_STATUS_REPLY.format( + name, service_uuid, response.status_code, response.text + ) + ) + except requests.exceptions.ConnectionError as e: + status_code = e.response.status_code if e.response is not None else 500 + content = e.response.text if e.response is not None else "" + raise WimTapiConnectivityServiceGetStatusFailed( + name, service_uuid, status_code, content + ) + + if response.status_code not in SUCCESS_HTTP_CODES: + raise WimTapiConnectivityServiceGetStatusFailed( + name, service_uuid, response.status_code, response.text + ) + + json_response = response.json() + connectivity_services = json_response.get( + "tapi-connectivity:connectivity-service", [] + ) + connectivity_service = next( + iter( + [ + connectivity_service + for connectivity_service in connectivity_services + if connectivity_service.get("uuid") == service_uuid + ] + ), + None, + ) + + if connectivity_service is None: + service_status = {"sdn_status": "ERROR"} + else: + service_status = {"sdn_status": "ACTIVE"} + return service_status + + def create_service( + self, + name, + service_uuid, + service_endpoints, + bidirectional=False, + requested_capacity=None, + vlan_constraint=None, + ): + request_create = compose_create_request( + service_uuid, + service_endpoints, + bidirectional=bidirectional, + requested_capacity=requested_capacity, + vlan_constraint=vlan_constraint, + ) + self.logger.debug( + LOG_MSG_CREATE_REQUEST.format(name, service_uuid, str(request_create)) + ) + + try: + create_url = CONN_CTX_URL.format(self.wim_url) + response = requests.post( + create_url, headers=self.headers, json=request_create, auth=self.auth + ) + self.logger.debug( + LOG_MSG_CREATE_REPLY.format( + name, service_uuid, response.status_code, response.text + ) + ) + except requests.exceptions.ConnectionError as e: + status_code = e.response.status_code if e.response is not None else 500 + content = e.response.text if e.response is not None else "" + raise WimTapiConnectivityServiceCreateFailed( + name, service_uuid, status_code, content + ) + + if response.status_code not in SUCCESS_HTTP_CODES: + raise WimTapiConnectivityServiceCreateFailed( + name, service_uuid, response.status_code, response.text + ) + + def delete_service(self, name, service_uuid): + request_delete = compose_delete_request(service_uuid) + self.logger.debug( + LOG_MSG_DELETE_REQUEST.format(name, service_uuid, str(request_delete)) + ) + + try: + delete_url = DELETE_URL.format(self.wim_url) + response = requests.post( + delete_url, headers=self.headers, json=request_delete, auth=self.auth + ) + self.logger.debug( + LOG_MSG_DELETE_REPLY.format( + name, service_uuid, response.status_code, response.text + ) + ) + except requests.exceptions.ConnectionError as e: + status_code = e.response.status_code if e.response is not None else 500 + content = e.response.text if e.response is not None else "" + raise WimTapiConnectivityServiceDeleteFailed( + name, service_uuid, status_code, content + ) + + if response.status_code not in SUCCESS_HTTP_CODES: + raise WimTapiConnectivityServiceDeleteFailed( + name, service_uuid, response.status_code, response.text + ) diff --git a/RO-SDN-tapi/osm_rosdn_tapi/tests/__init__.py b/RO-SDN-tapi/osm_rosdn_tapi/tests/__init__.py new file mode 100644 index 00000000..ab3006ab --- /dev/null +++ b/RO-SDN-tapi/osm_rosdn_tapi/tests/__init__.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- + +####################################################################################### +# This file is part of OSM RO module +# +# Copyright ETSI Contributors and Others. +# +# 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. +####################################################################################### +# This work has been performed in the context of the TeraFlow Project - +# funded by the European Commission under Grant number 101015857 through the +# Horizon 2020 program. +####################################################################################### diff --git a/RO-SDN-tapi/osm_rosdn_tapi/tests/constants.py b/RO-SDN-tapi/osm_rosdn_tapi/tests/constants.py new file mode 100644 index 00000000..a9808ba6 --- /dev/null +++ b/RO-SDN-tapi/osm_rosdn_tapi/tests/constants.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- + +####################################################################################### +# This file is part of OSM RO module +# +# Copyright ETSI Contributors and Others. +# +# 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. +####################################################################################### +# This work has been performed in the context of the TeraFlow Project - +# funded by the European Commission under Grant number 101015857 through the +# Horizon 2020 program. +# Contributors: +# - Lluis Gifre +# - Ricard Vilalta +####################################################################################### + +"""This file contains the WIM settings for the unit test used to validate the +Transport API (TAPI) WIM connector.""" + + +from osm_rosdn_tapi.tests.tools import wim_port_mapping + + +WIM_HOST_PORT = ("127.0.0.127", 49000) + +# WIM_URL should be populated with the WIM url provided for the WIM connector during its instantiation +WIM_URL = "http://{:s}:{:d}".format(*WIM_HOST_PORT) + +# WIM_ACCOUNT should be populated with the WIM credentials provided for the WIM connector during its instantiation +WIM_ACCOUNT = {"user": "admin", "password": "admin"} + +# WIM_PORT_MAPPING should be populated with the port mapping provided for the WIM connector during its instantiation +# In this example, SIPs are taken from mock_tapi_handler.py file. +WIM_PORT_MAPPING = [ + wim_port_mapping( + "dc1", + "dc1r1", + "eth0", + "R1-eth0", + service_mapping_info={}, + ), + wim_port_mapping( + "dc2", + "dc2r2", + "eth0", + "R2-eth0", + service_mapping_info={}, + ), + wim_port_mapping( + "dc3", + "dc3r3", + "eth0", + "R3-opt1", + service_mapping_info={ + "sip_input": "R3-opt1-rx", + "sip_output": "R3-opt1-tx", + }, + ), + wim_port_mapping( + "dc4", + "dc4r4", + "eth0", + "R4-opt1", + service_mapping_info={ + "sip_input": "R4-opt1-rx", + "sip_output": "R4-opt1-tx", + }, + ), +] diff --git a/RO-SDN-tapi/osm_rosdn_tapi/tests/exceptions.py b/RO-SDN-tapi/osm_rosdn_tapi/tests/exceptions.py new file mode 100644 index 00000000..f52a020d --- /dev/null +++ b/RO-SDN-tapi/osm_rosdn_tapi/tests/exceptions.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- + +####################################################################################### +# This file is part of OSM RO module +# +# Copyright ETSI Contributors and Others. +# +# 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. +####################################################################################### +# This work has been performed in the context of the TeraFlow Project - +# funded by the European Commission under Grant number 101015857 through the +# Horizon 2020 program. +# Contributors: +# - Lluis Gifre +# - Ricard Vilalta +####################################################################################### + +"""This file contains the exception classes the Mock OSM RO module can raise.""" + + +_PREFIX = "Mock OSM RO: " + + +class MockOsmRoError(Exception): + """Base Exception for all Mock OSM RO related errors.""" + + def __init__(self, message): + super().__init__(_PREFIX + message) + + +class MockOsmRoServiceNotFound(MockOsmRoError): + def __init__(self, service_id): + MESSAGE = "ServiceId({:s}) not found" + message = MESSAGE.format(str(service_id)) + super().__init__(message) diff --git a/RO-SDN-tapi/osm_rosdn_tapi/tests/mock_osm_ro.py b/RO-SDN-tapi/osm_rosdn_tapi/tests/mock_osm_ro.py new file mode 100644 index 00000000..71c21486 --- /dev/null +++ b/RO-SDN-tapi/osm_rosdn_tapi/tests/mock_osm_ro.py @@ -0,0 +1,99 @@ +# -*- coding: utf-8 -*- + +####################################################################################### +# This file is part of OSM RO module +# +# Copyright ETSI Contributors and Others. +# +# 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. +####################################################################################### +# This work has been performed in the context of the TeraFlow Project - +# funded by the European Commission under Grant number 101015857 through the +# Horizon 2020 program. +# Contributors: +# - Lluis Gifre +# - Ricard Vilalta +####################################################################################### + +"""This file contains a Mock OSM RO component that can be used for rapid unit testing. + +This code is based on code taken with permission from ETSI TeraFlowSDN project at: + https://labs.etsi.org/rep/tfs/controller +""" + + +from typing import Dict, List + +from osm_ro_plugin.sdnconn import SdnConnectorBase + +from .exceptions import MockOsmRoServiceNotFound + + +class MockOsmRo: + def __init__( + self, + klass: SdnConnectorBase, + url: str, + wim_account: Dict, + wim_port_mapping: Dict, + ) -> None: + wim = {"wim_url": url} + config = { + "mapping_not_needed": False, + "service_endpoint_mapping": wim_port_mapping, + } + + # Instantiate WIM connector + self.wim_connector = klass(wim, wim_account, config=config) + + # Internal DB emulating OSM RO storage provided to WIM Connectors + self.conn_info = {} + + def create_connectivity_service( + self, service_type: str, connection_points: List[Dict] + ) -> str: + self.wim_connector.check_credentials() + service_uuid, conn_info = self.wim_connector.create_connectivity_service( + service_type, connection_points + ) + self.conn_info[service_uuid] = conn_info + return service_uuid + + def get_connectivity_service_status(self, service_uuid: str) -> Dict: + conn_info = self.conn_info.get(service_uuid) + if conn_info is None: + raise MockOsmRoServiceNotFound(service_uuid) + self.wim_connector.check_credentials() + return self.wim_connector.get_connectivity_service_status( + service_uuid, conn_info=conn_info + ) + + def edit_connectivity_service( + self, service_uuid: str, connection_points: List[Dict] + ) -> None: + conn_info = self.conn_info.get(service_uuid) + if conn_info is None: + raise MockOsmRoServiceNotFound(service_uuid) + self.wim_connector.check_credentials() + self.wim_connector.edit_connectivity_service( + service_uuid, conn_info=conn_info, connection_points=connection_points + ) + + def delete_connectivity_service(self, service_uuid: str) -> None: + conn_info = self.conn_info.get(service_uuid) + if conn_info is None: + raise MockOsmRoServiceNotFound(service_uuid) + self.wim_connector.check_credentials() + self.wim_connector.delete_connectivity_service( + service_uuid, conn_info=conn_info + ) diff --git a/RO-SDN-tapi/osm_rosdn_tapi/tests/mock_tapi_handler.py b/RO-SDN-tapi/osm_rosdn_tapi/tests/mock_tapi_handler.py new file mode 100644 index 00000000..5f38210a --- /dev/null +++ b/RO-SDN-tapi/osm_rosdn_tapi/tests/mock_tapi_handler.py @@ -0,0 +1,174 @@ +# -*- coding: utf-8 -*- + +####################################################################################### +# This file is part of OSM RO module +# +# Copyright ETSI Contributors and Others. +# +# 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. +####################################################################################### +# This work has been performed in the context of the TeraFlow Project - +# funded by the European Commission under Grant number 101015857 through the +# Horizon 2020 program. +# Contributors: +# - Lluis Gifre +# - Ricard Vilalta +####################################################################################### + +"""This file contains a minimalistic Mock Transport API (TAPI) WIM server.""" + +import http.server +import json +import uuid + + +PHOTONIC_PROTOCOL_QUALIFIER = "tapi-photonic-media:PHOTONIC_LAYER_QUALIFIER_NMC" +DSR_PROTOCOL_QUALIFIER = "tapi-dsr:DIGITAL_SIGNAL_TYPE" + + +def compose_sip( + uuid, layer_protocol_name, supported_layer_protocol_qualifier, direction +): + return { + "uuid": uuid, + "layer-protocol-name": layer_protocol_name, + "supported-layer-protocol-qualifier": [supported_layer_protocol_qualifier], + "administrative-state": "UNLOCKED", + "operational-state": "ENABLED", + "direction": direction, + } + + +def compose_sip_dsr(uuid): + return compose_sip(uuid, "DSR", DSR_PROTOCOL_QUALIFIER, "BIDIRECTIONAL") + + +def compose_sip_photonic_input(uuid): + return compose_sip(uuid, "PHOTONIC_MEDIA", PHOTONIC_PROTOCOL_QUALIFIER, "INPUT") + + +def compose_sip_photonic_output(uuid): + return compose_sip(uuid, "PHOTONIC_MEDIA", PHOTONIC_PROTOCOL_QUALIFIER, "OUTPUT") + + +CONTEXT = { + "uuid": str(uuid.uuid4()), + "service-interface-point": [ + compose_sip_dsr("R1-eth0"), + compose_sip_dsr("R2-eth0"), + compose_sip_photonic_input("R3-opt1-rx"), + compose_sip_photonic_output("R3-opt1-tx"), + compose_sip_photonic_input("R4-opt1-rx"), + compose_sip_photonic_output("R4-opt1-tx"), + ], + # topology details not used by the WIM connector + "topology-context": {}, + "connectivity-context": {"connectivity-service": [], "connection": []}, +} + + +class MockTapiRequestHandler(http.server.BaseHTTPRequestHandler): + """Mock TAPI Request Handler for the unit tests""" + + def do_GET(self): # pylint: disable=invalid-name + """Handle GET requests""" + path = self.path.replace("tapi-common:", "").replace("tapi-connectivity:", "") + + if path == "/restconf/data/context": + status = 200 # ok + headers = {"Content-Type": "application/json"} + data = CONTEXT + elif path == "/restconf/data/context/service-interface-point": + status = 200 # ok + headers = {"Content-Type": "application/json"} + data = CONTEXT["service-interface-point"] + data = {"tapi-common:service-interface-point": data} + elif path == "/restconf/data/context/connectivity-context/connectivity-service": + status = 200 # ok + headers = {"Content-Type": "application/json"} + data = CONTEXT["connectivity-context"]["connectivity-service"] + data = {"tapi-connectivity:connectivity-service": data} + else: + status = 404 # not found + headers = {} + data = {"error": "Not found"} + + self.send_response(status) + for header_name, header_value in headers.items(): + self.send_header(header_name, header_value) + self.end_headers() + data = json.dumps(data) + self.wfile.write(data.encode("UTF-8")) + + def do_POST(self): # pylint: disable=invalid-name + """Handle POST requests""" + path = self.path.replace("tapi-common:", "").replace("tapi-connectivity:", "") + length = int(self.headers["content-length"]) + data = json.loads(self.rfile.read(length)) + + if path == "/restconf/data/context/connectivity-context": + if "tapi-connectivity:connectivity-service" in data: + data["connectivity-service"] = data.pop( + "tapi-connectivity:connectivity-service" + ) + + if ( + isinstance(data["connectivity-service"], list) + and len(data["connectivity-service"]) > 0 + ): + data["connectivity-service"] = data["connectivity-service"][0] + + conn_svc = data["connectivity-service"] + if "connectivity-constraint" in conn_svc: + conn_constr = conn_svc.pop("connectivity-constraint") + if "requested-capacity" in conn_constr: + req_cap = conn_constr.pop("requested-capacity") + conn_svc["requested-capacity"] = req_cap + if "connectivity-direction" in conn_constr: + conn_dir = conn_constr.pop("connectivity-direction") + conn_svc["connectivity-direction"] = conn_dir + + connection = {"uuid": conn_svc["uuid"], "connection-end-point": []} + conn_svc["connection"] = [{"connection_uuid": conn_svc["uuid"]}] + + CONTEXT["connectivity-context"]["connection"].append(connection) + CONTEXT["connectivity-context"]["connectivity-service"].append(conn_svc) + + status = 201 # created + headers = {} + elif path == "/restconf/operations/delete-connectivity-service": + if "tapi-connectivity:input" in data: + data["input"] = data.pop("tapi-connectivity:input") + conn_svc_uuid = data["input"]["uuid"] + conn_ctx = CONTEXT["connectivity-context"] + + # keep connectivity services and connections with different uuid + conn_ctx["connection"] = [ + conn for conn in conn_ctx["connection"] if conn["uuid"] != conn_svc_uuid + ] + conn_ctx["connectivity-service"] = [ + conn_svc + for conn_svc in conn_ctx["connectivity-service"] + if conn_svc["uuid"] != conn_svc_uuid + ] + + status = 204 # ok, no content + headers = {} + else: + status = 404 # not found + headers = {} + + self.send_response(status) + for header_name, header_value in headers.items(): + self.send_header(header_name, header_value) + self.end_headers() diff --git a/RO-SDN-tapi/osm_rosdn_tapi/tests/test_wim_tapi.py b/RO-SDN-tapi/osm_rosdn_tapi/tests/test_wim_tapi.py new file mode 100644 index 00000000..8c18b86b --- /dev/null +++ b/RO-SDN-tapi/osm_rosdn_tapi/tests/test_wim_tapi.py @@ -0,0 +1,179 @@ +# -*- coding: utf-8 -*- + +####################################################################################### +# This file is part of OSM RO module +# +# Copyright ETSI Contributors and Others. +# +# 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. +####################################################################################### +# This work has been performed in the context of the TeraFlow Project - +# funded by the European Commission under Grant number 101015857 through the +# Horizon 2020 program. +# Contributors: +# - Lluis Gifre +# - Ricard Vilalta +####################################################################################### + +"""This file contains the unit tests for the Transport API (TAPI) WIM connector.""" + +import http.server +import threading +import unittest + +from osm_rosdn_tapi.exceptions import ( + WimTapiConnectionPointsBadFormat, + WimTapiMissingConnPointField, + WimTapiUnsupportedServiceType, +) +from osm_rosdn_tapi.tests.constants import ( + WIM_ACCOUNT, + WIM_HOST_PORT, + WIM_PORT_MAPPING, + WIM_URL, +) +from osm_rosdn_tapi.tests.mock_osm_ro import MockOsmRo +from osm_rosdn_tapi.tests.mock_tapi_handler import MockTapiRequestHandler +from osm_rosdn_tapi.wimconn_tapi import WimconnectorTAPI + + +SERVICE_TYPE = "ELINE" +SERVICE_CONNECTION_POINTS_BIDIRECTIONAL = [ + # SIPs taken from mock_tapi_handler.py + {"service_endpoint_id": "R1-eth0"}, + {"service_endpoint_id": "R2-eth0"}, +] +SERVICE_CONNECTION_POINTS_UNIDIRECTIONAL = [ + # SIPs taken from mock_tapi_handler.py + {"service_endpoint_id": "R3-opt1"}, + {"service_endpoint_id": "R4-opt1"}, +] + + +class UnitTests(unittest.TestCase): + """Unit tests for Transport API WIM connector""" + + def setUp(self) -> None: + self.wim_server = http.server.ThreadingHTTPServer( + WIM_HOST_PORT, MockTapiRequestHandler + ) + + def test_wrong_cases(self): + with self.wim_server: + wim_server_thread = threading.Thread(target=self.wim_server.serve_forever) + wim_server_thread.daemon = True + wim_server_thread.start() + + mock_osm_ro_tapi = MockOsmRo( + WimconnectorTAPI, WIM_URL, WIM_ACCOUNT, WIM_PORT_MAPPING + ) + + # Unsupported service type + with self.assertRaises(WimTapiUnsupportedServiceType) as test_context: + mock_osm_ro_tapi.create_connectivity_service( + "ELAN", SERVICE_CONNECTION_POINTS_BIDIRECTIONAL + ) + self.assertEqual( + str(test_context.exception.args[0]), + "Unsupported ServiceType(ELAN). Supported ServiceTypes({'ELINE'})", + ) + + # Wrong number of connection_points + with self.assertRaises(WimTapiConnectionPointsBadFormat) as test_context: + mock_osm_ro_tapi.create_connectivity_service(SERVICE_TYPE, []) + self.assertEqual( + str(test_context.exception.args[0]), + "ConnectionPoints([]) must be a list or tuple of length 2", + ) + + # Wrong type of connection_points + with self.assertRaises(WimTapiConnectionPointsBadFormat) as test_context: + mock_osm_ro_tapi.create_connectivity_service( + SERVICE_TYPE, {"a": "b", "c": "d"} + ) + self.assertEqual( + str(test_context.exception.args[0]), + "ConnectionPoints({'a': 'b', 'c': 'd'}) must be a list or tuple of length 2", + ) + + with self.assertRaises(WimTapiMissingConnPointField) as test_context: + mock_osm_ro_tapi.create_connectivity_service( + SERVICE_TYPE, + [ + {"wrong_service_endpoint_id": "value"}, + {"service_endpoint_id": "value"}, + ], + ) + self.assertEqual( + str(test_context.exception.args[0]), + "WIM TAPI Connector: ConnectionPoint({'wrong_service_endpoint_id': 'value'}) has no field 'service_endpoint_id'", + ) + + self.wim_server.shutdown() + wim_server_thread.join() + + def test_correct_bidirectional(self): + with self.wim_server: + wim_server_thread = threading.Thread(target=self.wim_server.serve_forever) + wim_server_thread.daemon = True + wim_server_thread.start() + + mock_osm_ro_tapi = MockOsmRo( + WimconnectorTAPI, WIM_URL, WIM_ACCOUNT, WIM_PORT_MAPPING + ) + + # Create bidirectional TAPI service + service_uuid = mock_osm_ro_tapi.create_connectivity_service( + SERVICE_TYPE, SERVICE_CONNECTION_POINTS_BIDIRECTIONAL + ) + self.assertIsInstance(service_uuid, str) + + # Check status of bidirectional TAPI service + status = mock_osm_ro_tapi.get_connectivity_service_status(service_uuid) + self.assertIsInstance(status, dict) + self.assertIn("sdn_status", status) + self.assertEqual(status["sdn_status"], "ACTIVE") + + # Delete bidirectional TAPI service + mock_osm_ro_tapi.delete_connectivity_service(service_uuid) + + self.wim_server.shutdown() + wim_server_thread.join() + + def test_correct_unidirectional(self): + with self.wim_server: + wim_server_thread = threading.Thread(target=self.wim_server.serve_forever) + wim_server_thread.daemon = True + wim_server_thread.start() + + mock_osm_ro_tapi = MockOsmRo( + WimconnectorTAPI, WIM_URL, WIM_ACCOUNT, WIM_PORT_MAPPING + ) + + # Create unidirectional TAPI service + service_uuid = mock_osm_ro_tapi.create_connectivity_service( + SERVICE_TYPE, SERVICE_CONNECTION_POINTS_UNIDIRECTIONAL + ) + self.assertIsInstance(service_uuid, str) + + # Check status of unidirectional TAPI service + status = mock_osm_ro_tapi.get_connectivity_service_status(service_uuid) + self.assertIsInstance(status, dict) + self.assertIn("sdn_status", status) + self.assertEqual(status["sdn_status"], "ACTIVE") + + # Delete unidirectional TAPI service + mock_osm_ro_tapi.delete_connectivity_service(service_uuid) + + self.wim_server.shutdown() + wim_server_thread.join() diff --git a/RO-SDN-tapi/osm_rosdn_tapi/tests/tools.py b/RO-SDN-tapi/osm_rosdn_tapi/tests/tools.py new file mode 100644 index 00000000..b75543f4 --- /dev/null +++ b/RO-SDN-tapi/osm_rosdn_tapi/tests/tools.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- + +####################################################################################### +# This file is part of OSM RO module +# +# Copyright ETSI Contributors and Others. +# +# 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. +####################################################################################### +# This work has been performed in the context of the TeraFlow Project - +# funded by the European Commission under Grant number 101015857 through the +# Horizon 2020 program. +# Contributors: +# - Lluis Gifre +# - Ricard Vilalta +####################################################################################### + +"""This file contains a helper methods for the Mock OSM RO component that can be used +for rapid unit testing. + +This code is based on code taken with permission from ETSI TeraFlowSDN project at: + https://labs.etsi.org/rep/tfs/controller +""" + +from typing import Dict, Optional + + +# Ref: https://osm.etsi.org/wikipub/index.php/WIM +# Fields defined according to from osm_ro_plugin.sdnconn import SdnConnectorBase +def wim_port_mapping( + datacenter_id: str, + device_id: str, + device_interface_id: str, + service_endpoint_id: str, + switch_dpid: Optional[str] = None, + switch_port: Optional[str] = None, + service_mapping_info: Dict = {}, +): + mapping = { + "datacenter_id": datacenter_id, + "device_id": device_id, + "device_interface_id": device_interface_id, + "service_endpoint_id": service_endpoint_id, + "service_mapping_info": service_mapping_info, + } + if switch_dpid is not None: + mapping["switch_dpid"] = switch_dpid + if switch_port is not None: + mapping["switch_port"] = switch_port + return mapping diff --git a/RO-SDN-tapi/osm_rosdn_tapi/wimconn_tapi.py b/RO-SDN-tapi/osm_rosdn_tapi/wimconn_tapi.py new file mode 100644 index 00000000..a89f4c86 --- /dev/null +++ b/RO-SDN-tapi/osm_rosdn_tapi/wimconn_tapi.py @@ -0,0 +1,346 @@ +# -*- coding: utf-8 -*- + +####################################################################################### +# This file is part of OSM RO module +# +# Copyright ETSI Contributors and Others. +# +# 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. +####################################################################################### +# This work has been performed in the context of the TeraFlow Project - +# funded by the European Commission under Grant number 101015857 through the +# Horizon 2020 program. +# Contributors: +# - Lluis Gifre +# - Ricard Vilalta +####################################################################################### + +"""The SDN/WIM connector is responsible for establishing wide area network +connectivity. + +This SDN/WIM connector implements the standard ONF Transport API (TAPI). + +It receives the endpoints and the necessary details to request the Layer 2 +service through the use of the ONF Transport API. +""" + +import logging +import uuid + +from osm_ro_plugin.sdnconn import SdnConnectorBase + +from .conn_info import ( + conn_info_compose_bidirectional, + conn_info_compose_unidirectional, +) +from .exceptions import ( + WimTapiConnectionPointsBadFormat, + WimTapiMissingConnPointField, + WimTapiUnsupportedServiceType, +) +from .services_composer import ServicesComposer +from .tapi_client import TransportApiClient + + +class WimconnectorTAPI(SdnConnectorBase): + """ONF TAPI WIM connector""" + + def __init__(self, wim, wim_account, config=None, logger=None): + """ONF TAPI WIM connector + + Arguments: + wim (dict): WIM record, as stored in the database + wim_account (dict): WIM account record, as stored in the database + config (optional dict): optional configuration from the configuration database + logger (optional Logger): logger to use with this WIM connector + The arguments of the constructor are converted to object attributes. + An extra property, ``service_endpoint_mapping`` is created from ``config``. + """ + logger = logger or logging.getLogger("ro.sdn.tapi") + + super().__init__(wim, wim_account, config, logger) + + self.logger.debug("self.config={:s}".format(str(self.config))) + + if len(self.service_endpoint_mapping) == 0 and self.config.get( + "wim_port_mapping" + ): + self.service_endpoint_mapping = self.config.get("wim_port_mapping", []) + + self.mappings = { + m["service_endpoint_id"]: m for m in self.service_endpoint_mapping + } + + self.logger.debug("self.mappings={:s}".format(str(self.mappings))) + + self.tapi_client = TransportApiClient(self.logger, wim, wim_account, config) + + self.logger.info("TAPI WIM Connector Initialized.") + + def check_credentials(self): + """Check if the connector itself can access the SDN/WIM with the provided url (wim.wim_url), + user (wim_account.user), and password (wim_account.password) + + Raises: + SdnConnectorError: Issues regarding authorization, access to + external URLs, etc are detected. + """ + _ = self.tapi_client.get_root_context() + self.logger.info("Credentials checked") + + def get_connectivity_service_status(self, service_uuid, conn_info=None): + """Monitor the status of the connectivity service established + + Arguments: + service_uuid (str): UUID of the connectivity service + conn_info (dict or None): Information returned by the connector + during the service creation/edition and subsequently stored in + the database. + + Returns: + dict: JSON/YAML-serializable dict that contains a mandatory key + ``sdn_status`` associated with one of the following values:: + + {'sdn_status': 'ACTIVE'} + # The service is up and running. + + {'sdn_status': 'INACTIVE'} + # The service was created, but the connector + # cannot determine yet if connectivity exists + # (ideally, the caller needs to wait and check again). + + {'sdn_status': 'DOWN'} + # Connection was previously established, + # but an error/failure was detected. + + {'sdn_status': 'ERROR'} + # An error occurred when trying to create the service/ + # establish the connectivity. + + {'sdn_status': 'BUILD'} + # Still trying to create the service, the caller + # needs to wait and check again. + + Additionally ``error_msg``(**str**) and ``sdn_info``(**dict**) + keys can be used to provide additional status explanation or + new information available for the connectivity service. + """ + sdn_status = set() + bidirectional = conn_info["bidirectional"] + + tapi_client = self.tapi_client + if bidirectional: + service_uuid = conn_info["uuid"] + service_status = tapi_client.get_service_status("<>", service_uuid) + sdn_status.add(service_status["sdn_status"]) + else: + service_az_uuid = conn_info["az"]["uuid"] + service_za_uuid = conn_info["za"]["uuid"] + service_az_status = tapi_client.get_service_status(">>", service_az_uuid) + service_za_status = tapi_client.get_service_status("<<", service_za_uuid) + sdn_status.add(service_az_status["sdn_status"]) + sdn_status.add(service_za_status["sdn_status"]) + + if len(sdn_status) == 1 and "ACTIVE" in sdn_status: + service_status = {"sdn_status": "ACTIVE"} + else: + service_status = {"sdn_status": "ERROR"} + + return service_status + + def create_connectivity_service(self, service_type, connection_points, **kwargs): + """ + Establish SDN/WAN connectivity between the endpoints + :param service_type: (str): ``ELINE`` (L2), ``ELAN`` (L2), ``ETREE`` (L2), ``L3``. + :param connection_points: (list): each point corresponds to + an entry point to be connected. For WIM: from the DC to the transport network. + For SDN: Compute/PCI to the transport network. One + connection point serves to identify the specific access and + some other service parameters, such as encapsulation type. + Each item of the list is a dict with: + "service_endpoint_id": (str)(uuid) Same meaning that for 'service_endpoint_mapping' (see __init__) + In case the config attribute mapping_not_needed is True, this value is not relevant. In this case + it will contain the string "device_id:device_interface_id" + "service_endpoint_encapsulation_type": None, "dot1q", ... + "service_endpoint_encapsulation_info": (dict) with: + "vlan": ..., (int, present if encapsulation is dot1q) + "vni": ... (int, present if encapsulation is vxlan), + "peers": [(ipv4_1), (ipv4_2)] (present if encapsulation is vxlan) + "mac": ... + "device_id": ..., same meaning that for 'service_endpoint_mapping' (see __init__) + "device_interface_id": same meaning that for 'service_endpoint_mapping' (see __init__) + "switch_dpid": ..., present if mapping has been found for this device_id,device_interface_id + "swith_port": ... present if mapping has been found for this device_id,device_interface_id + "service_mapping_info": present if mapping has been found for this device_id,device_interface_id + :param kwargs: For future versions: + bandwidth (int): value in kilobytes + latency (int): value in milliseconds + Other QoS might be passed as keyword arguments. + :return: tuple: ``(service_id, conn_info)`` containing: + - *service_uuid* (str): UUID of the established connectivity service + - *conn_info* (dict or None): Information to be stored at the database (or ``None``). + This information will be provided to the :meth:`~.edit_connectivity_service` and :obj:`~.delete`. + **MUST** be JSON/YAML-serializable (plain data structures). + :raises: SdnConnectorException: In case of error. Nothing should be created in this case. + Provide the parameter http_code + """ + supported_service_types = {"ELINE"} + if service_type not in supported_service_types: + raise WimTapiUnsupportedServiceType(service_type, supported_service_types) + + self.logger.debug("connection_points={:s}".format(str(connection_points))) + + if not isinstance(connection_points, (list, tuple)): + raise WimTapiConnectionPointsBadFormat(connection_points) + + if len(connection_points) != 2: + raise WimTapiConnectionPointsBadFormat(connection_points) + + sips = self.tapi_client.get_service_interface_points() + services_composer = ServicesComposer(sips) + + for connection_point in connection_points: + service_endpoint_id = connection_point.get("service_endpoint_id") + if service_endpoint_id is None: + raise WimTapiMissingConnPointField( + connection_point, "service_endpoint_id" + ) + + mapping = self.mappings.get(service_endpoint_id, {}) + services_composer.add_service_endpoint(service_endpoint_id, mapping) + + services_composer.dump(self.logger) + + service_uuid, conn_info = self._create_services_and_conn_info(services_composer) + return service_uuid, conn_info + + def _create_services_and_conn_info(self, services_composer: ServicesComposer): + services = services_composer.services + requested_capacity = services_composer.requested_capacity + vlan_constraint = services_composer.vlan_constraint + + service_uuid = str(uuid.uuid4()) + + if services_composer.is_bidirectional(): + service_endpoints = services[0] + self.tapi_client.create_service( + "<>", + service_uuid, + service_endpoints, + bidirectional=True, + requested_capacity=requested_capacity, + vlan_constraint=vlan_constraint, + ) + conn_info = conn_info_compose_bidirectional( + service_uuid, + service_endpoints, + requested_capacity=requested_capacity, + vlan_constraint=vlan_constraint, + ) + + else: + service_uuid = service_uuid[0 : len(service_uuid) - 4] + "00**" + service_az_uuid = service_uuid.replace("**", "af") + service_az_endpoints = services[0] + service_za_uuid = service_uuid.replace("**", "fa") + service_za_endpoints = services[1] + + self.tapi_client.create_service( + ">>", + service_az_uuid, + service_az_endpoints, + bidirectional=False, + requested_capacity=requested_capacity, + vlan_constraint=vlan_constraint, + ) + self.tapi_client.create_service( + "<<", + service_za_uuid, + service_za_endpoints, + bidirectional=False, + requested_capacity=requested_capacity, + vlan_constraint=vlan_constraint, + ) + conn_info = conn_info_compose_unidirectional( + service_az_uuid, + service_az_endpoints, + service_za_uuid, + service_za_endpoints, + requested_capacity=requested_capacity, + vlan_constraint=vlan_constraint, + ) + + return service_uuid, conn_info + + def delete_connectivity_service(self, service_uuid, conn_info=None): + """ + Disconnect multi-site endpoints previously connected + + :param service_uuid: The one returned by create_connectivity_service + :param conn_info: The one returned by last call to 'create_connectivity_service' or 'edit_connectivity_service' + if they do not return None + :return: None + :raises: SdnConnectorException: In case of error. The parameter http_code must be filled + """ + bidirectional = conn_info["bidirectional"] + if bidirectional: + service_uuid = conn_info["uuid"] + self.tapi_client.delete_service("<>", service_uuid) + else: + service_az_uuid = conn_info["az"]["uuid"] + service_za_uuid = conn_info["za"]["uuid"] + self.tapi_client.delete_service(">>", service_az_uuid) + self.tapi_client.delete_service("<<", service_za_uuid) + + def edit_connectivity_service( + self, service_uuid, conn_info=None, connection_points=None, **kwargs + ): + """Change an existing connectivity service. + + This method's arguments and return value follow the same convention as + :meth:`~.create_connectivity_service`. + + :param service_uuid: UUID of the connectivity service. + :param conn_info: (dict or None): Information previously returned by last call to create_connectivity_service + or edit_connectivity_service + :param connection_points: (list): If provided, the old list of connection points will be replaced. + :param kwargs: Same meaning that create_connectivity_service + :return: dict or None: Information to be updated and stored at the database. + When ``None`` is returned, no information should be changed. + When an empty dict is returned, the database record will be deleted. + **MUST** be JSON/YAML-serializable (plain data structures). + Raises: + SdnConnectorException: In case of error. + """ + raise NotImplementedError + + def clear_all_connectivity_services(self): + """Delete all WAN Links in a WIM. + + This method is intended for debugging only, and should delete all the + connections controlled by the WIM/SDN, not only the connections that + a specific RO is aware of. + + Raises: + SdnConnectorException: In case of error. + """ + raise NotImplementedError + + def get_all_active_connectivity_services(self): + """Provide information about all active connections provisioned by a + WIM. + + Raises: + SdnConnectorException: In case of error. + """ + raise NotImplementedError diff --git a/RO-SDN-tapi/requirements.in b/RO-SDN-tapi/requirements.in new file mode 100644 index 00000000..25e572e9 --- /dev/null +++ b/RO-SDN-tapi/requirements.in @@ -0,0 +1,17 @@ +# Copyright ETSI Contributors and Others. +# +# 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. +## + +requests diff --git a/RO-SDN-tapi/setup.py b/RO-SDN-tapi/setup.py new file mode 100644 index 00000000..107778a6 --- /dev/null +++ b/RO-SDN-tapi/setup.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +## +# 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. +## + +from setuptools import setup + +_name = "osm_rosdn_tapi" +_version_command = ("git describe --match v* --tags --long --dirty", "pep440-git-full") +_description = "OSM ro sdn plugin for tapi" +_author = "OSM Support" +_author_email = "osmsupport@etsi.org" +_maintainer = "OSM Support" +_maintainer_email = "osmsupport@etsi.org" +_license = "Apache 2.0" +_url = "https://osm.etsi.org/gitweb/?p=osm/RO.git;a=summary" + +_readme = """ +=========== +osm-rosdn_tapi +=========== + +osm-ro pluging for tapi SDN +""" + +setup( + name=_name, + description=_description, + long_description=_readme, + version_command=_version_command, + author=_author, + author_email=_author_email, + maintainer=_maintainer, + maintainer_email=_maintainer_email, + url=_url, + license=_license, + packages=[_name], + include_package_data=True, + setup_requires=["setuptools-version-command"], + entry_points={ + "osm_rosdn.plugins": [ + "rosdn_tapi = osm_rosdn_tapi.wimconn_tapi:WimconnectorTAPI" + ], + }, +) diff --git a/RO-SDN-tapi/stdeb.cfg b/RO-SDN-tapi/stdeb.cfg new file mode 100644 index 00000000..262a47c8 --- /dev/null +++ b/RO-SDN-tapi/stdeb.cfg @@ -0,0 +1,17 @@ +# +# 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. +# + +[DEFAULT] +X-Python3-Version : >= 3.5 diff --git a/RO-VIM-openstack/osm_rovim_openstack/tests/test_vimconn_openstack.py b/RO-VIM-openstack/osm_rovim_openstack/tests/test_vimconn_openstack.py index b6fff180..2e1ddc6a 100644 --- a/RO-VIM-openstack/osm_rovim_openstack/tests/test_vimconn_openstack.py +++ b/RO-VIM-openstack/osm_rovim_openstack/tests/test_vimconn_openstack.py @@ -68,6 +68,7 @@ ip_addr1 = "20.3.4.5" volume_id = "ac408b73-b9cc-4a6a-a270-82cc4811bd4a" volume_id2 = "o4e0e83-b9uu-4akk-a234-89cc4811bd4a" volume_id3 = "44e0e83-t9uu-4akk-a234-p9cc4811bd4a" +volume_id4 = "91bf5674-5b85-41d1-aa3b-4848e2691088" virtual_mac_id = "64e0e83-t9uu-4akk-a234-p9cc4811bd4a" created_items_all_true = { f"floating_ip:{floating_network_vim_id}": True, @@ -108,12 +109,12 @@ def check_if_assert_not_called(mocks: list): mocking.assert_not_called() -class Status: - def __init__(self, s): +class Volume: + def __init__(self, s, type="__DEFAULT__", name="", id=""): self.status = s - - def __str__(self): - return self.status + self.volume_type = type + self.name = name + self.id = id class CopyingMock(MagicMock): @@ -1312,6 +1313,43 @@ class TestNewVmInstance(unittest.TestCase): self.assertEqual(existing_vim_volumes, expected_existing_vim_volumes) self.vimconn.cinder.volumes.create.assert_not_called() + @patch.object(vimconnector, "update_block_device_mapping") + def test__prepare_shared_volumes_vim_using_volume_id( + self, mock_update_block_device_mapping + ): + """Existing persistent non root volume with vim_volume_id. + class Volume: + def __init__(self, s, type="__DEFAULT__", name="", id=""): + self.status = s + self.volume_type = type + self.name = name + self.id = id + volumes = {"shared-volume": volume_id4} + + The device mappeing BEFORE is: {} + The device mappeing AFTER is: {'vdb': '8ca50cc6-a779-4513-a1f3-900b8b3987d2'} + """ + base_disk_index = ord("b") + disk = {"name": "shared-volume"} + block_device_mapping = {} + existing_vim_volumes = [] + created_items = {} + expected_block_device_mapping = {} + self.vimconn.cinder.volumes.list.return_value = [ + Volume("avaible", "multiattach", "shared-volume", volume_id4) + ] + self.vimconn.cinder.volumes.get.return_value.id = volume_id4 + self.vimconn._prepare_shared_volumes( + name, + disk, + base_disk_index, + block_device_mapping, + existing_vim_volumes, + created_items, + ) + self.vimconn.cinder.volumes.get.assert_called_with(volume_id4) + self.assertDictEqual(block_device_mapping, expected_block_device_mapping) + @patch.object(vimconnector, "update_block_device_mapping") def test_prepare_persistent_non_root_volumes_vim_using_volume_id( self, mock_update_block_device_mapping @@ -1577,6 +1615,22 @@ class TestNewVmInstance(unittest.TestCase): _call_mock_update_block_device_mapping[0].kwargs["created_items"], {} ) + @patch.object(vimconnector, "update_block_device_mapping") + def test_new_shared_volumes(self, mock_update_block_device_mapping): + """Create shared volume.""" + self.vimconn.cinder = CopyingMock() + self.vimconn.cinder.volumes.create.return_value.id = volume_id4 + shared_volume_data = {"size": 10, "name": "shared-volume"} + self.vimconn.cinder.volumes.create.side_effect = [ + Volume("avaible", "multiattach", "shared-volume", volume_id4) + ] + result = self.vimconn.new_shared_volumes(shared_volume_data) + self.vimconn.cinder.volumes.create.assert_called_once_with( + size=10, name="shared-volume", volume_type="multiattach" + ) + self.assertEqual(result[0], "shared-volume") + self.assertEqual(result[1], volume_id4) + @patch.object(vimconnector, "update_block_device_mapping") def test_prepare_persistent_root_volumes_create_raise_exception( self, mock_update_block_device_mapping @@ -1689,9 +1743,9 @@ class TestNewVmInstance(unittest.TestCase): f"volume:{volume_id3}": True, } self.vimconn.cinder.volumes.get.side_effect = [ - Status("processing"), - Status("available"), - Status("available"), + Volume("processing"), + Volume("available"), + Volume("available"), ] result = self.vimconn._wait_for_created_volumes_availability( @@ -1716,9 +1770,9 @@ class TestNewVmInstance(unittest.TestCase): {"id": "44e0e83-b9uu-4akk-t234-p9cc4811bd4a"}, ] self.vimconn.cinder.volumes.get.side_effect = [ - Status("processing"), - Status("available"), - Status("available"), + Volume("processing"), + Volume("available", "multiattach"), + Volume("available"), ] result = self.vimconn._wait_for_existing_volumes_availability( @@ -1742,8 +1796,8 @@ class TestNewVmInstance(unittest.TestCase): elapsed_time = 1805 created_items = {f"volume:{volume_id2}": True} self.vimconn.cinder.volumes.get.side_effect = [ - Status("processing"), - Status("processing"), + Volume("processing"), + Volume("processing"), ] with patch("time.sleep", mock_sleep): result = self.vimconn._wait_for_created_volumes_availability( @@ -1761,8 +1815,8 @@ class TestNewVmInstance(unittest.TestCase): elapsed_time = 1805 existing_vim_volumes = [{"id": volume_id2}] self.vimconn.cinder.volumes.get.side_effect = [ - Status("processing"), - Status("processing"), + Volume("processing"), + Volume("processing"), ] result = self.vimconn._wait_for_existing_volumes_availability( @@ -3950,6 +4004,15 @@ class TestNewVmInstance(unittest.TestCase): self.vimconn.logger.error.assert_not_called() self.assertEqual(created_items, expected_created_items) + def test_delete_shared_volumes(self): + """cinder delete shared volumes""" + shared_volume_vim_id = volume_id4 + self.vimconn.cinder.volumes.get.return_value.status = "available" + self.vimconn.delete_shared_volumes(shared_volume_vim_id) + self.vimconn.cinder.volumes.get.assert_called_once_with(shared_volume_vim_id) + self.vimconn.cinder.volumes.delete.assert_called_once_with(shared_volume_vim_id) + self.vimconn.logger.error.assert_not_called() + def test_delete_volumes_by_id_with_cinder_get_volume_raise_exception(self): """cinder get volume raises exception.""" created_items = { diff --git a/RO-VIM-openstack/osm_rovim_openstack/vimconn_openstack.py b/RO-VIM-openstack/osm_rovim_openstack/vimconn_openstack.py index f1df8a73..82a1e37a 100644 --- a/RO-VIM-openstack/osm_rovim_openstack/vimconn_openstack.py +++ b/RO-VIM-openstack/osm_rovim_openstack/vimconn_openstack.py @@ -339,7 +339,7 @@ class vimconnector(vimconn.VimConnector): version = self.config.get("microversion") if not version: - version = "2.1" + version = "2.60" # addedd region_name to keystone, nova, neutron and cinder to support distributed cloud for Wind River # Titanium cloud and StarlingX @@ -2151,6 +2151,38 @@ class vimconnector(vimconn.VimConnector): created_items[volume_txt] = True block_device_mapping["vd" + chr(base_disk_index)] = volume.id + def new_shared_volumes(self, shared_volume_data) -> (str, str): + try: + volume = self.cinder.volumes.create( + size=shared_volume_data["size"], + name=shared_volume_data["name"], + volume_type="multiattach", + ) + return (volume.name, volume.id) + except (ConnectionError, KeyError) as e: + self._format_exception(e) + + def _prepare_shared_volumes( + self, + name: str, + disk: dict, + base_disk_index: int, + block_device_mapping: dict, + existing_vim_volumes: list, + created_items: dict, + ): + volumes = {volume.name: volume.id for volume in self.cinder.volumes.list()} + if volumes.get(disk["name"]): + sv_id = volumes[disk["name"]] + volume = self.cinder.volumes.get(sv_id) + self.update_block_device_mapping( + volume=volume, + block_device_mapping=block_device_mapping, + base_disk_index=base_disk_index, + disk=disk, + created_items=created_items, + ) + def _prepare_non_root_persistent_volumes( self, name: str, @@ -2175,17 +2207,15 @@ class vimconnector(vimconn.VimConnector): # Non-root persistent volumes # Disk may include only vim_volume_id or only vim_id." key_id = "vim_volume_id" if "vim_volume_id" in disk.keys() else "vim_id" - if disk.get(key_id): # Use existing persistent volume block_device_mapping["vd" + chr(base_disk_index)] = disk[key_id] existing_vim_volumes.append({"id": disk[key_id]}) - else: - # Create persistent volume + volume_name = f"{name}vd{chr(base_disk_index)}" volume = self.cinder.volumes.create( size=disk["size"], - name=name + "vd" + chr(base_disk_index), + name=volume_name, # Make sure volume is in the same AZ as the VM to be attached to availability_zone=vm_av_zone, ) @@ -2210,7 +2240,6 @@ class vimconnector(vimconn.VimConnector): elapsed_time (int): Time spent while waiting """ - while elapsed_time < volume_timeout: for created_item in created_items: v, volume_id = ( @@ -2218,7 +2247,13 @@ class vimconnector(vimconn.VimConnector): created_item.split(":")[1], ) if v == "volume": - if self.cinder.volumes.get(volume_id).status != "available": + volume = self.cinder.volumes.get(volume_id) + if ( + volume.volume_type == "multiattach" + and volume.status == "in-use" + ): + return elapsed_time + elif volume.status != "available": break else: # All ready: break from while @@ -2245,7 +2280,10 @@ class vimconnector(vimconn.VimConnector): while elapsed_time < volume_timeout: for volume in existing_vim_volumes: - if self.cinder.volumes.get(volume["id"]).status != "available": + v = self.cinder.volumes.get(volume["id"]) + if v.volume_type == "multiattach" and v.status == "in-use": + return elapsed_time + elif v.status != "available": break else: # all ready: break from while break @@ -2279,7 +2317,6 @@ class vimconnector(vimconn.VimConnector): base_disk_index = ord("b") boot_volume_id = None elapsed_time = 0 - for disk in disk_list: if "image_id" in disk: # Root persistent volume @@ -2293,6 +2330,15 @@ class vimconnector(vimconn.VimConnector): existing_vim_volumes=existing_vim_volumes, created_items=created_items, ) + elif disk.get("multiattach"): + self._prepare_shared_volumes( + name=name, + disk=disk, + base_disk_index=base_disk_index, + block_device_mapping=block_device_mapping, + existing_vim_volumes=existing_vim_volumes, + created_items=created_items, + ) else: # Non-root persistent volume self._prepare_non_root_persistent_volumes( @@ -2748,7 +2794,6 @@ class vimconnector(vimconn.VimConnector): server_group_id, ) ) - # Create VM server = self.nova.servers.create( name=name, @@ -2918,6 +2963,23 @@ class vimconnector(vimconn.VimConnector): except Exception as e: self.logger.error("Error deleting port: {}: {}".format(type(e).__name__, e)) + def delete_shared_volumes(self, shared_volume_vim_id: str) -> bool: + """Cinder delete volume by id. + Args: + shared_volume_vim_id (str): ID of shared volume in VIM + """ + try: + if self.cinder.volumes.get(shared_volume_vim_id).status != "available": + return True + + else: + self.cinder.volumes.delete(shared_volume_vim_id) + + except Exception as e: + self.logger.error( + "Error deleting volume: {}: {}".format(type(e).__name__, e) + ) + def _delete_volumes_by_id_wth_cinder( self, k: str, k_id: str, volumes_to_hold: list, created_items: dict ) -> bool: @@ -2995,7 +3057,6 @@ class vimconnector(vimconn.VimConnector): try: k_item, k_id = self._get_item_name_id(k) - if k_item == "volume": unavailable_vol = self._delete_volumes_by_id_wth_cinder( k, k_id, volumes_to_hold, created_items diff --git a/devops-stages/stage-build.sh b/devops-stages/stage-build.sh index 3ac7ab60..3d570c78 100755 --- a/devops-stages/stage-build.sh +++ b/devops-stages/stage-build.sh @@ -29,6 +29,7 @@ dist_ro_sdn_juniper_contrail dist_ro_sdn_odl_of dist_ro_sdn_onos_of dist_ro_sdn_onos_vpls +dist_ro_sdn_tapi dist_ro_vim_aws dist_ro_vim_azure dist_ro_vim_openstack @@ -53,7 +54,7 @@ do cp ${vim_plugin}/deb_dist/python3-osm-rovim*.deb deb_dist/ done -# SDN plugins: DynPac, Ietfl2vpn, Onosof Floodlightof +# SDN plugins: DynPac, Ietfl2vpn, Onosof Floodlightof, Transport API (TAPI) for sdn_plugin in RO-SDN-* do cp ${sdn_plugin}/deb_dist/python3-osm-rosdn*.deb deb_dist/ diff --git a/releasenotes/notes/feature_10937_Transport_API_WIM_connector_for_RO-9dc1e3aba7e1f3ad.yaml b/releasenotes/notes/feature_10937_Transport_API_WIM_connector_for_RO-9dc1e3aba7e1f3ad.yaml new file mode 100644 index 00000000..70c79ea4 --- /dev/null +++ b/releasenotes/notes/feature_10937_Transport_API_WIM_connector_for_RO-9dc1e3aba7e1f3ad.yaml @@ -0,0 +1,20 @@ +####################################################################################### +# Copyright ETSI Contributors and Others. +# +# 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. +####################################################################################### +--- +features: + - | + Feature 10937: Transport API (TAPI) WIM connector for RO diff --git a/releasenotes/notes/feature_10972_Support_of_volume_multi_attach-57c1232c1a54ab18.yaml b/releasenotes/notes/feature_10972_Support_of_volume_multi_attach-57c1232c1a54ab18.yaml new file mode 100644 index 00000000..040aef3c --- /dev/null +++ b/releasenotes/notes/feature_10972_Support_of_volume_multi_attach-57c1232c1a54ab18.yaml @@ -0,0 +1,21 @@ +####################################################################################### +# Copyright ETSI Contributors and Others. +# +# 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. +####################################################################################### +--- +features: + - | + Feature 10972: Support of volume multi-attach + Support of volume multi-attach for Openstack based VIMs (server groups) \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt index 25cd5710..64a1b2cd 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -20,7 +20,7 @@ async-timeout==4.0.2 # via # -r https://osm.etsi.org/gitweb/?p=osm/common.git;a=blob_plain;f=requirements.txt;hb=paas # aiokafka -charset-normalizer==3.0.1 +charset-normalizer==3.1.0 # via -r https://osm.etsi.org/gitweb/?p=osm/common.git;a=blob_plain;f=requirements.txt;hb=paas dataclasses==0.6 # via -r https://osm.etsi.org/gitweb/?p=osm/common.git;a=blob_plain;f=requirements.txt;hb=paas diff --git a/requirements.in b/requirements.in index d7e02f1f..f4344b44 100644 --- a/requirements.in +++ b/requirements.in @@ -25,6 +25,7 @@ -r RO-SDN-odl_openflow/requirements.in -r RO-SDN-onos_openflow/requirements.in -r RO-SDN-onos_vpls/requirements.in +-r RO-SDN-tapi/requirements.in -r RO-VIM-aws/requirements.in -r RO-VIM-azure/requirements.in -r RO-VIM-openstack/requirements.in diff --git a/tox.ini b/tox.ini index c971bc17..44220840 100644 --- a/tox.ini +++ b/tox.ini @@ -57,6 +57,7 @@ commands = black --check --diff RO-SDN-odl_openflow black --check --diff RO-SDN-onos_openflow black --check --diff RO-SDN-onos_vpls + black --check --diff RO-SDN-tapi black --check --diff RO-VIM-aws black --check --diff RO-VIM-azure black --check --diff RO-VIM-openstack @@ -109,6 +110,9 @@ commands = # RO-SDN-onos_vpls # nose2 -C --coverage RO-SDN-onos_vpls/osm_rosdn_onos_vpls -s RO-SDN-onos_vpls/osm_rosdn_onos_vpls # sh -c 'mv .coverage .coverage_rosdn_onos_vpls' + # RO-SDN-tapi + nose2 -C --coverage RO-SDN-tapi/osm_rosdn_tapi -s RO-SDN-tapi/osm_rosdn_tapi + sh -c 'mv .coverage .coverage_rosdn_tapi' # RO-VIM-aws # nose2 -C --coverage RO-VIM-aws/osm_rovim_aws -s RO-VIM-aws/osm_rovim_aws # sh -c 'mv .coverage .coverage_rovim_aws' @@ -128,7 +132,7 @@ commands = # nose2 -C --coverage RO-VIM-gcp/osm_rovim_gcp -s RO-VIM-gcp/osm_rovim_gcp # sh -c 'mv .coverage .coverage_rovim_gcp' # Combine results and generate reports - # coverage combine .coverage_ng_ro .coverage_ro_plugin .coverage_rosdn_arista_cloudvision .coverage_rosdn_dpb .coverage_rosdn_dynpac .coverage_rosdn_floodlightof .coverage_rosdn_ietfl2vpn .coverage_rosdn_juniper_contrail .coverage_rosdn_odlof .coverage_rosdn_onos_vpls .coverage_rosdn_onosof .coverage_rovim_aws .coverage_rovim_azure .coverage_rovim_openvim .coverage_rovim_gcp # .coverage_rovim_openstack .coverage_rovim_vmware + # coverage combine .coverage_ng_ro .coverage_ro_plugin .coverage_rosdn_arista_cloudvision .coverage_rosdn_dpb .coverage_rosdn_dynpac .coverage_rosdn_floodlightof .coverage_rosdn_ietfl2vpn .coverage_rosdn_juniper_contrail .coverage_rosdn_odlof .coverage_rosdn_onos_vpls .coverage_rosdn_onosof .coverage_rosdn_tapi .coverage_rovim_aws .coverage_rovim_azure .coverage_rovim_openvim .coverage_rovim_gcp # .coverage_rovim_openstack .coverage_rovim_vmware coverage combine .coverage_ng_ro .coverage_rovim_openstack .coverage_rosdn_juniper_contrail coverage report --omit='*tests*' coverage html -d ./cover --omit='*tests*' @@ -153,6 +157,7 @@ commands = flake8 RO-SDN-odl_openflow/osm_rosdn_odlof/ RO-SDN-odl_openflow/setup.py flake8 RO-SDN-onos_openflow/osm_rosdn_onosof/ RO-SDN-onos_openflow/setup.py flake8 RO-SDN-onos_vpls/osm_rosdn_onos_vpls/ RO-SDN-onos_vpls/setup.py + flake8 RO-SDN-tapi/osm_rosdn_tapi/ RO-SDN-tapi/setup.py flake8 RO-VIM-aws/osm_rovim_aws/ RO-VIM-aws/setup.py flake8 RO-VIM-azure/osm_rovim_azure/ RO-VIM-azure/setup.py flake8 RO-VIM-openstack/osm_rovim_openstack/ RO-VIM-openstack/setup.py @@ -181,6 +186,7 @@ commands = pylint -E RO-SDN-odl_openflow/osm_rosdn_odlof pylint -E RO-SDN-onos_openflow/osm_rosdn_onosof pylint -E RO-SDN-onos_vpls/osm_rosdn_onos_vpls --disable=E1101 + pylint -E RO-SDN-tapi/osm_rosdn_tapi pylint -E RO-VIM-aws/osm_rovim_aws pylint -E RO-VIM-azure/osm_rovim_azure --disable=all pylint -E RO-VIM-openstack/osm_rovim_openstack --disable=E1101 @@ -362,6 +368,19 @@ commands = sh -c 'cd deb_dist/osm-rosdn-onos-vpls*/ && dpkg-buildpackage -rfakeroot -uc -us' +####################################################################################### +[testenv:dist_ro_sdn_tapi] +deps = {[testenv]deps} + -r{toxinidir}/requirements-dist.txt +skip_install = true +whitelist_externals = sh +changedir = {toxinidir}/RO-SDN-tapi +commands = + sh -c 'rm -rf deb_dist dist osm_rosdn_tapi.egg-info osm_rosdn_tapi*.tar.gz' + python3 setup.py --command-packages=stdeb.command sdist_dsc + sh -c 'cd deb_dist/osm-rosdn-tapi*/ && dpkg-buildpackage -rfakeroot -uc -us' + + ####################################################################################### [testenv:dist_ro_vim_aws] deps = {[testenv]deps} -- 2.25.1