diff --git a/src/service/service/service_handlers/l3nm_nce/ConfigRules.py b/src/service/service/service_handlers/l3nm_nce/ConfigRules.py index d6bcadb45ba0190ac31b2b8a0329fca92193e73c..0544d897606afe950725349bfeb68c365189aa21 100644 --- a/src/service/service/service_handlers/l3nm_nce/ConfigRules.py +++ b/src/service/service/service_handlers/l3nm_nce/ConfigRules.py @@ -1,4 +1,4 @@ -# Copyright 2022-2024 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) +# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/src/service/service/service_handlers/l3nm_nce/L3NMNCEServiceHandler.py b/src/service/service/service_handlers/l3nm_nce/L3NMNCEServiceHandler.py index cbf92ac802edc8ecfa09e2920180538752124f3d..1317bd0615e4789d7ba76e8c0c6b0923d8f2dec7 100644 --- a/src/service/service/service_handlers/l3nm_nce/L3NMNCEServiceHandler.py +++ b/src/service/service/service_handlers/l3nm_nce/L3NMNCEServiceHandler.py @@ -1,4 +1,4 @@ -# Copyright 2022-2024 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) +# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,7 +15,7 @@ import json import logging import re -from typing import Any, List, Optional, Tuple, Union +from typing import Any, List, Optional, Tuple, Union, TypedDict, Dict from uuid import uuid4 from deepdiff import DeepDiff @@ -40,6 +40,7 @@ CANDIDATE_RESOURCE_KEY = "candidate_ietf_slice" LOGGER = logging.getLogger(__name__) METRICS_POOL = MetricsPool("Service", "Handler", labels={"handler": "l3nm_nce"}) + SDP_DIFF_RE = re.compile( r"^root\[\'network-slice-services\'\]\[\'slice-service\'\]\[0\]\[\'sdps\'\]\[\'sdp\'\]\[(\d)\]$" ) @@ -51,9 +52,20 @@ MATCH_CRITERION_DIFF_RE = re.compile( ) +class Ipv4Info(TypedDict): + src_ip: str + dst_ip: str + src_port: str + dst_port: str + + def get_removed_items( candidate_ietf_slice_dict: dict, running_ietf_slice_dict: dict ) -> dict: + """ + For the 'iterable_item_removed' scenario, returns dict with removed sdp / connection_group / match_criterion info. + Raises an exception if there's inconsistent data or multiple items removed (which is not supported). + """ removed_items = { "sdp": {"sdp_idx": None, "value": {}}, "connection_group": {"connection_group_idx": None, "value": {}}, @@ -63,20 +75,24 @@ def get_removed_items( "value": {}, }, } + running_slice_services = running_ietf_slice_dict["network-slice-services"][ "slice-service" ][0] - running_slice_sdps = [sdp["id"] for sdp in running_slice_services["sdps"]["sdp"]] candidate_slice_services = candidate_ietf_slice_dict["network-slice-services"][ "slice-service" ][0] + + running_slice_sdps = [sdp["id"] for sdp in running_slice_services["sdps"]["sdp"]] candidiate_slice_sdps = [ sdp["id"] for sdp in candidate_slice_services["sdps"]["sdp"] ] removed_sdps = set(running_slice_sdps) - set(candidiate_slice_sdps) + if len(removed_sdps) > 1: - raise Exception("Multiple SDPs removed") - removed_sdp_id = list(removed_sdps)[0] + raise Exception("Multiple SDPs removed - not supported.") + removed_sdp_id = removed_sdps.pop() + removed_items["sdp"]["sdp_idx"] = running_slice_sdps.index(removed_sdp_id) removed_items["sdp"]["value"] = next( sdp @@ -88,7 +104,7 @@ def get_removed_items( "match-criterion" ] if len(match_criteria) > 1: - raise Exception("Multiple match criteria found") + raise Exception("Multiple match criteria found - not supported") match_criterion = match_criteria[0] connection_grp_id = match_criterion["target-connection-group-id"] connection_groups = running_slice_services["connection-groups"]["connection-group"] @@ -99,6 +115,7 @@ def get_removed_items( ) removed_items["connection_group"]["connection_group_idx"] = connection_group[0] removed_items["connection_group"]["value"] = connection_group[1] + for sdp in running_slice_services["sdps"]["sdp"]: if sdp["id"] == removed_sdp_id: continue @@ -119,41 +136,116 @@ def get_removed_items( or removed_items["connection_group"]["connection_group_idx"] is None ): raise Exception("sdp, connection group or match criterion not found") + return removed_items def get_custom_config_rule( service_config: ServiceConfig, resource_key: str ) -> Optional[ConfigRule]: + """ + Returns the ConfigRule from service_config matching the provided resource_key + if found, otherwise returns None. + """ for cr in service_config.config_rules: if ( cr.WhichOneof("config_rule") == "custom" and cr.custom.resource_key == resource_key ): return cr + return None -def get_running_candidate_ietf_slice_data_diff(service_config: ServiceConfig) -> dict: - running_ietf_slice_cr = get_custom_config_rule(service_config, RUNNING_RESOURCE_KEY) - running_resource_value_dict = json.loads( - running_ietf_slice_cr.custom.resource_value - ) - candidate_ietf_slice_cr = get_custom_config_rule( - service_config, CANDIDATE_RESOURCE_KEY - ) - candidate_resource_value_dict = json.loads( - candidate_ietf_slice_cr.custom.resource_value +def get_running_candidate_ietf_slice_data_diff(service_config: ServiceConfig) -> Dict: + """ + Loads the JSON from the running/candidate resource ConfigRules and returns + their DeepDiff comparison. + """ + running_cr = get_custom_config_rule(service_config, RUNNING_RESOURCE_KEY) + candidate_cr = get_custom_config_rule(service_config, CANDIDATE_RESOURCE_KEY) + + running_value_dict = json.loads(running_cr.custom.resource_value) + candidate_value_dict = json.loads(candidate_cr.custom.resource_value) + + return DeepDiff(running_value_dict, candidate_value_dict) + + +def extract_qos_info( + connection_groups: List, connection_grp_id: str, src_sdp_idx: str, dst_sdp_idx: str +) -> Dict: + """ + Extract QoS information from connection groups based on the connection group ID. + """ + qos_info = { + "upstream": {"max_delay": "0", "bw": "0", "packet_loss": "0"}, + "downstream": {"max_delay": "0", "bw": "0", "packet_loss": "0"}, + } + connection_group = next( + (cg for cg in connection_groups if cg["id"] == connection_grp_id), None ) - return ( - DeepDiff( - running_resource_value_dict, - candidate_resource_value_dict, - ), - DeepDiff( - running_resource_value_dict, - candidate_resource_value_dict, - ignore_order=True, - ), + + if not connection_group: + return qos_info + + for cc in connection_group["connectivity-construct"]: + if ( + cc["p2p-sender-sdp"] == src_sdp_idx + and cc["p2p-receiver-sdp"] == dst_sdp_idx + ): + direction = "upstream" + elif ( + cc["p2p-sender-sdp"] == dst_sdp_idx + and cc["p2p-receiver-sdp"] == src_sdp_idx + ): + direction = "downstream" + else: + raise Exception("invalid sender and receiver sdp ids") + for metric_bound in cc["service-slo-sle-policy"]["slo-policy"]["metric-bound"]: + if ( + metric_bound["metric-type"] + == "ietf-network-slice-service:one-way-delay-maximum" + and metric_bound["metric-unit"] == "milliseconds" + ): + qos_info[direction]["max_delay"] = metric_bound["bound"] + elif ( + metric_bound["metric-type"] + == "ietf-network-slice-service:one-way-bandwidth" + and metric_bound["metric-unit"] == "Mbps" + ): + qos_info[direction]["bw"] = metric_bound["bound"] + elif ( + metric_bound["metric-type"] + == "ietf-network-slice-service:two-way-packet-loss" + and metric_bound["metric-unit"] == "percentage" + ): + qos_info[direction]["packet_loss"] = metric_bound["percentile-value"] + + return qos_info + + +def extract_match_criterion_ipv4_info(match_criterion: Dict) -> Ipv4Info: + """ + Extracts IPv4 info from the match criterion dictionary. + """ + src_ip = dst_ip = src_port = dst_port = "" + + for type_value in match_criterion["match-type"]: + m_type = type_value["type"] + val = type_value["value"][0] + if m_type == "ietf-network-slice-service:source-ip-prefix": + src_ip = val.split("/")[0] + elif m_type == "ietf-network-slice-service:destination-ip-prefix": + dst_ip = val.split("/")[0] + elif m_type == "ietf-network-slice-service:source-tcp-port": + src_port = val + elif m_type == "ietf-network-slice-service:destination-tcp-port": + dst_port = val + + return Ipv4Info( + src_ip=src_ip, + dst_ip=dst_ip, + src_port=src_port, + dst_port=dst_port, ) @@ -174,22 +266,25 @@ class L3NMNCEServiceHandler(_ServiceHandler): chk_type("endpoints", endpoints, list) if len(endpoints) == 0: return [] + results = [] try: context_client = ContextClient() service_config = self.__service.service_config settings = self.__settings_handler.get("/settings") + src_device_uuid, src_endpoint_uuid = get_device_endpoint_uuids(endpoints[0]) src_device_obj = self.__task_executor.get_device( DeviceId(**json_device_id(src_device_uuid)) ) controller = self.__task_executor.get_device_controller(src_device_obj) + list_devices = context_client.ListDevices(Empty()) devices = list_devices.devices - device_name_device = {d.name: d for d in devices} - device_uuid_device = {d.device_id.device_uuid.uuid: d for d in devices} - running_candidate_diff, running_candidate_diff_no_order = ( - get_running_candidate_ietf_slice_data_diff(service_config) + device_name_map = {d.name: d for d in devices} + + running_candidate_diff = get_running_candidate_ietf_slice_data_diff( + service_config ) candidate_ietf_slice_cr = get_custom_config_rule( service_config, CANDIDATE_RESOURCE_KEY @@ -203,15 +298,17 @@ class L3NMNCEServiceHandler(_ServiceHandler): running_resource_value_dict = json.loads( running_ietf_slice_cr.custom.resource_value ) + service_name = running_resource_value_dict["network-slice-services"][ "slice-service" ][0]["id"] + if not running_candidate_diff: # Slice Creation operation_type = "create" - slice_services = candidate_resource_value_dict[ - "network-slice-services" - ]["slice-service"] - slice_service = slice_services[0] + + slice_service = candidate_resource_value_dict["network-slice-services"][ + "slice-service" + ][0] sdps = slice_service["sdps"]["sdp"] connection_groups = slice_service["connection-groups"][ "connection-group" @@ -219,7 +316,7 @@ class L3NMNCEServiceHandler(_ServiceHandler): sdp_ids = [sdp["id"] for sdp in sdps] for sdp in sdps: node_id = sdp["node-id"] - device_obj = device_name_device[node_id] + device_obj = device_name_map[node_id] device_controller = self.__task_executor.get_device_controller( device_obj ) @@ -237,15 +334,15 @@ class L3NMNCEServiceHandler(_ServiceHandler): else: raise Exception("connection group id not found") elif "iterable_item_added" in running_candidate_diff: # new SDP added - slice_services = candidate_resource_value_dict[ - "network-slice-services" - ]["slice-service"] - slice_service = slice_services[0] + operation_type = "create" + + slice_service = candidate_resource_value_dict["network-slice-services"][ + "slice-service" + ][0] sdps = slice_service["sdps"]["sdp"] connection_groups = slice_service["connection-groups"][ "connection-group" ] - operation_type = "create" added_items = { "sdp": {"sdp_idx": None, "value": {}}, "connection_group": {"connection_group_idx": None, "value": {}}, @@ -287,6 +384,7 @@ class L3NMNCEServiceHandler(_ServiceHandler): connection_grp_id = connection_groups[ added_items["connection_group"]["connection_group_idx"] ]["id"] + if ( connection_grp_id != added_items["match_criterion"]["value"][ @@ -299,15 +397,15 @@ class L3NMNCEServiceHandler(_ServiceHandler): match_criteria = new_sdp["service-match-criteria"]["match-criterion"] match_criterion = match_criteria[0] elif "iterable_item_removed" in running_candidate_diff: # new SDP added - slice_services = running_resource_value_dict["network-slice-services"][ + operation_type = "delete" + + slice_service = running_resource_value_dict["network-slice-services"][ "slice-service" - ] - slice_service = slice_services[0] + ][0] sdps = slice_service["sdps"]["sdp"] connection_groups = slice_service["connection-groups"][ "connection-group" ] - operation_type = "delete" removed_items = get_removed_items( candidate_resource_value_dict, running_resource_value_dict ) @@ -317,6 +415,7 @@ class L3NMNCEServiceHandler(_ServiceHandler): connection_grp_id = connection_groups[ removed_items["connection_group"]["connection_group_idx"] ]["id"] + if ( connection_grp_id != removed_items["match_criterion"]["value"][ @@ -330,65 +429,17 @@ class L3NMNCEServiceHandler(_ServiceHandler): "match-criterion" ] match_criterion = match_criteria[0] - for type_value in match_criterion["match-type"]: - if type_value["type"] == "ietf-network-slice-service:source-ip-prefix": - src_ip = type_value["value"][0].split("/")[0] - elif ( - type_value["type"] - == "ietf-network-slice-service:destination-ip-prefix" - ): - dst_ip = type_value["value"][0].split("/")[0] - elif type_value["type"] == "ietf-network-slice-service:source-tcp-port": - src_port = type_value["value"][0] - elif ( - type_value["type"] - == "ietf-network-slice-service:destination-tcp-port" - ): - dst_port = type_value["value"][0] - qos_info = { - "upstream": {"max_delay": "0", "bw": "0", "packet_loss": "0"}, - "downstream": {"max_delay": "0", "bw": "0", "packet_loss": "0"}, - } - for cg in connection_groups: - if cg["id"] != connection_grp_id: - continue - for cc in cg["connectivity-construct"]: - if ( - cc["p2p-sender-sdp"] == src_sdp_idx - and cc["p2p-receiver-sdp"] == dst_sdp_idx - ): - direction = "upstream" - elif ( - cc["p2p-sender-sdp"] == dst_sdp_idx - and cc["p2p-receiver-sdp"] == src_sdp_idx - ): - direction = "downstream" - else: - raise Exception("invalid sender and receiver sdp ids") - for metric_bound in cc["service-slo-sle-policy"]["slo-policy"][ - "metric-bound" - ]: - if ( - metric_bound["metric-type"] - == "ietf-network-slice-service:one-way-delay-maximum" - and metric_bound["metric-unit"] == "milliseconds" - ): - qos_info[direction]["max_delay"] = metric_bound["bound"] - elif ( - metric_bound["metric-type"] - == "ietf-network-slice-service:one-way-bandwidth" - and metric_bound["metric-unit"] == "Mbps" - ): - qos_info[direction]["bw"] = metric_bound["bound"] - elif ( - metric_bound["metric-type"] - == "ietf-network-slice-service:two-way-packet-loss" - and metric_bound["metric-unit"] == "percentage" - ): - qos_info[direction]["packet_loss"] = metric_bound[ - "percentile-value" - ] - break + else: + raise Exception( + "transition from candidate to running info not supported" + ) + + ip_info = extract_match_criterion_ipv4_info(match_criterion) + + qos_info = extract_qos_info( + connection_groups, connection_grp_id, src_sdp_idx, dst_sdp_idx + ) + resource_value_dict = { "uuid": service_name, "operation_type": operation_type, @@ -401,22 +452,23 @@ class L3NMNCEServiceHandler(_ServiceHandler): "upstream_max_bw": 2 * int(qos_info["upstream"]["bw"]) * 1e6, "downstream_assure_bw": int(qos_info["downstream"]["bw"]) * 1e6, "downstream_max_bw": 2 * int(qos_info["downstream"]["bw"]) * 1e6, - "src_ip": src_ip, - "src_port": src_port, - "dst_ip": dst_ip, - "dst_port": dst_port, + "src_ip": ip_info["src_ip"], + "src_port": ip_info["src_port"], + "dst_ip": ip_info["dst_ip"], + "dst_port": ip_info["dst_port"], } json_config_rules = setup_config_rules(service_name, resource_value_dict) - LOGGER.debug(f"Config Rules: {json_config_rules}") + del controller.device_config.config_rules[:] for jcr in json_config_rules: controller.device_config.config_rules.append(ConfigRule(**jcr)) + self.__task_executor.configure_device(controller) LOGGER.debug('Configured device "{:s}"'.format(controller.name)) + except Exception as e: # pylint: disable=broad-except - LOGGER.exception(f"P4: {e}") - raise e results.append(e) + return results @metered_subclass_method(METRICS_POOL) diff --git a/src/service/service/service_handlers/l3nm_nce/__init__.py b/src/service/service/service_handlers/l3nm_nce/__init__.py index 53d5157f750bfb085125cbd33faff1cec5924e14..3ccc21c7db78aac26daa1f8c5ff8e1ffd3f35460 100644 --- a/src/service/service/service_handlers/l3nm_nce/__init__.py +++ b/src/service/service/service_handlers/l3nm_nce/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2022-2024 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) +# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License.