From 717626111a55c312b0b9cf2264c79cacc8d9b64b Mon Sep 17 00:00:00 2001 From: velazquez Date: Thu, 19 Feb 2026 12:15:56 +0100 Subject: [PATCH 1/4] Change mapper and realizer --- src/config/.env.example | 11 +- src/config/config.py | 5 + src/main.py | 14 +- src/mapper/extract_sdp_info.py | 31 +++++ src/mapper/get_service_template.py | 20 +++ src/mapper/get_template.py | 10 ++ src/mapper/main.py | 90 ++++++++++++- src/mapper/process_connnectivity.py | 73 +++++++++++ src/realizer/main.py | 6 +- .../restconf/connectors/cisco_connector.py | 121 ++++++++++++++++++ .../restconf/connectors/frr_connector.py | 89 +++++++++++++ .../restconf/connectors/tfs_connector.py | 103 +++++++++++++++ src/realizer/restconf/main.py | 40 ++++++ src/realizer/restconf/restconf_connect.py | 81 ++++++++++++ .../builders/apply_metric_constraint.py | 49 +++++++ .../builders/configure_match_criteria.py | 35 +++++ .../service_types/builders/configure_slos.py | 26 ++++ .../builders/create_network_access.py | 53 ++++++++ .../builders/create_site_from_sdp.py | 43 +++++++ .../builders/initialize_structure.py | 22 ++++ src/realizer/restconf/service_types/l2vpn.py | 52 ++++++++ src/realizer/restconf/service_types/l3vpn.py | 49 +++++++ src/realizer/select_way.py | 9 +- src/realizer/send_controller.py | 5 +- src/utils/slice_manager.py | 65 ++++++++++ 25 files changed, 1086 insertions(+), 16 deletions(-) create mode 100644 src/mapper/extract_sdp_info.py create mode 100644 src/mapper/get_service_template.py create mode 100644 src/mapper/get_template.py create mode 100644 src/mapper/process_connnectivity.py create mode 100644 src/realizer/restconf/connectors/cisco_connector.py create mode 100644 src/realizer/restconf/connectors/frr_connector.py create mode 100644 src/realizer/restconf/connectors/tfs_connector.py create mode 100644 src/realizer/restconf/main.py create mode 100644 src/realizer/restconf/restconf_connect.py create mode 100644 src/realizer/restconf/service_types/builders/apply_metric_constraint.py create mode 100644 src/realizer/restconf/service_types/builders/configure_match_criteria.py create mode 100644 src/realizer/restconf/service_types/builders/configure_slos.py create mode 100644 src/realizer/restconf/service_types/builders/create_network_access.py create mode 100644 src/realizer/restconf/service_types/builders/create_site_from_sdp.py create mode 100644 src/realizer/restconf/service_types/builders/initialize_structure.py create mode 100644 src/realizer/restconf/service_types/l2vpn.py create mode 100644 src/realizer/restconf/service_types/l3vpn.py create mode 100644 src/utils/slice_manager.py diff --git a/src/config/.env.example b/src/config/.env.example index 3730fb7..03ec8f1 100644 --- a/src/config/.env.example +++ b/src/config/.env.example @@ -19,7 +19,7 @@ # ------------------------- NSC_PORT=8086 # Options: CRITICAL, ERROR, WARNING, INFO, DEBUG, NOTSET -LOGGING_LEVEL=INFO +LOGGING_LEVEL=DEBUG DUMP_TEMPLATES=false # ------------------------- @@ -63,6 +63,15 @@ IXIA_IP=127.0.0.1 # ------------------------- TFS_E2E_IP=127.0.0.1 +# ------------------------- +# Restconf Controller +# ------------------------- +RESTCONF_IP=127.0.0.1 +# Options: TFS or IXIA +SDN_CONTROLLER_TYPE=TFS +# Options: FRR, CISCO +DATAPLANE_SUPPORT=FRR + # ------------------------- # WebUI # ------------------------- diff --git a/src/config/config.py b/src/config/config.py index 04b72aa..145418d 100644 --- a/src/config/config.py +++ b/src/config/config.py @@ -64,4 +64,9 @@ def create_config(app: Flask): # WebUI app.config["WEBUI_DEPLOY"] = os.getenv("WEBUI_DEPLOY", "false").lower() == "true" + # Restconf Controller + app.config["RESTCONF_IP"] = os.getenv("RESTCONF_IP", "127.0.0.1") + app.config["SDN_CONTROLLER_TYPE"] = os.getenv("SDN_CONTROLLER_TYPE", "TFS") + app.config["DATAPLANE_SUPPORT"] = os.getenv("DATAPLANE_SUPPORT", "FRR") + return app diff --git a/src/main.py b/src/main.py index 4ef517d..3e533a7 100644 --- a/src/main.py +++ b/src/main.py @@ -96,15 +96,17 @@ class NSController: for intent in ietf_intents: # Mapper - rules = mapper(intent) + services, rules = mapper(intent) + logging.info(f"Services: {services}") # Build response self.response = build_response(intent, self.response, controller_type= self.controller_type) # Realizer - request = realizer(intent, controller_type=self.controller_type, response = self.response, rules = rules) - # Store slice request details - if request: - requests["services"].append(request) - store_data(intent, slice_id, controller_type=self.controller_type) + for service in services: + request = realizer(service, controller_type=self.controller_type, response = self.response, rules = rules) + # Store slice request details + if request: + requests["services"].append(request) + store_data(intent, slice_id, controller_type=self.controller_type) # Store the generated template for debugging dump_templates(intent_json, ietf_intents, requests) diff --git a/src/mapper/extract_sdp_info.py b/src/mapper/extract_sdp_info.py new file mode 100644 index 0000000..caad0a3 --- /dev/null +++ b/src/mapper/extract_sdp_info.py @@ -0,0 +1,31 @@ + + +import logging +from src.utils.safe_get import safe_get + + +def extract_sdp_info(sdp_id, slice_service, connection_group_id, connectivity_construct_id): + logging.debug(f"Extracting SDP info for SDP ID: {sdp_id}, Connection Group ID: {connection_group_id}, Connectivity Construct ID: {connectivity_construct_id}") + logging.debug(f"Slice Service: {slice_service}") + selected_sdp = next((sdp for sdp in safe_get(slice_service, ["sdps", "sdp"]) if sdp.get("id") == sdp_id), None) + selected_match_criteria = None + match_criteria_list = safe_get(selected_sdp, ["service-match-criteria", "match-criterion"]) + logging.debug(f"Available match criteria: {match_criteria_list}") + logging.debug(f"Selected SDP: {selected_sdp}") + if match_criteria_list: + # Buscar por connectivity construct + if connectivity_construct_id: + logging.debug(f"Looking for match criteria with target-connectivity-construct-id: {connectivity_construct_id}") + selected_match_criteria = next((mc for mc in match_criteria_list if safe_get(mc, ["target-connectivity-construct-id"]) == connectivity_construct_id), None) + + # Si no, buscar por connection group + if not selected_match_criteria and connection_group_id: + logging.debug(f"Looking for match criteria with target-connection-group-id: {connection_group_id}") + selected_match_criteria = next((mc for mc in match_criteria_list if safe_get(mc, ["target-connection-group-id"]) == connection_group_id), None) + + # Si no, usar el primero disponible + if not selected_match_criteria: + logging.debug("No specific match criteria found for connectivity construct or connection group. Using the first available match criteria.") + selected_match_criteria = match_criteria_list[0] + + return selected_sdp, selected_match_criteria \ No newline at end of file diff --git a/src/mapper/get_service_template.py b/src/mapper/get_service_template.py new file mode 100644 index 0000000..a72e642 --- /dev/null +++ b/src/mapper/get_service_template.py @@ -0,0 +1,20 @@ +from src.utils.safe_get import safe_get +from .get_template import get_template + +def get_service_template(service_element, available_templates): + """ + Extract template from service element. + + Args: + service_element: Dictionary containing service configuration + available_templates: List of available templates + + Returns: + Template object or None + """ + if "slo-sle-template" in service_element: + template_ref = safe_get(service_element, ["slo-sle-template"]) + return get_template(template_ref, available_templates) + elif "service-slo-sle-policy" in service_element: + return safe_get(service_element, ["service-slo-sle-policy"]) + return None \ No newline at end of file diff --git a/src/mapper/get_template.py b/src/mapper/get_template.py new file mode 100644 index 0000000..07c46ff --- /dev/null +++ b/src/mapper/get_template.py @@ -0,0 +1,10 @@ +def get_template(template_ref, available_templates): + if not template_ref or not available_templates: + raise ValueError("Template not found") + + for template in available_templates: + template_id = template.get("id") + if template_id == template_ref: + return template + + raise ValueError("Template not found") \ No newline at end of file diff --git a/src/mapper/main.py b/src/mapper/main.py index 9e0a4c7..4cac67d 100644 --- a/src/mapper/main.py +++ b/src/mapper/main.py @@ -16,9 +16,13 @@ import logging from src.planner.planner import Planner +from src.utils.safe_get import safe_get from .slo_viability import slo_viability +from .get_service_template import get_service_template +from .process_connnectivity import process_connectivity from src.realizer.main import realizer from flask import current_app +from src.database.sysrepo_store import get_data_store, create_data_store, delete_data_store, update_data_store, normalize_libyang_data def mapper(ietf_intent): """ @@ -73,5 +77,87 @@ def mapper(ietf_intent): if current_app.config["PLANNER_ENABLED"]: optimal_path = Planner().planner(ietf_intent, current_app.config["PLANNER_TYPE"]) logging.debug(f"Optimal path: {optimal_path}") - return optimal_path - return None \ No newline at end of file + #return optimal_path + # Initialize available templates + templates = get_data_store("/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template") + normalized_templates = normalize_libyang_data(templates) + logging.debug(f"Normalized templates: {normalized_templates}") + available_templates = safe_get(normalized_templates, ["slo-sle-templates", "slo-sle-template"]) or [] + + # Add templates from intent + for template in safe_get(ietf_intent, ["ietf-network-slice-service:network-slice-services","slo-sle-templates", "slo-sle-template"]): + available_templates.append(template) + logging.debug(f"Available templates: {available_templates}") + + services = [] + + # Process each slice service + for slice_service in safe_get(ietf_intent, ["ietf-network-slice-service:network-slice-services", "slice-service"]): + service_id = safe_get(slice_service, ["id"]) + logging.debug(f"Service ID: {service_id}") + + way = safe_get(slice_service, ['service-tags', 'tag-type', 0, 'tag-type-value', 0]) + logging.debug(f"Way: {way}") + + # Get service-level template + service_template = get_service_template(slice_service, available_templates) + logging.debug(f"Service Template: {service_template}") + + # Process connection groups + for connection_group in safe_get(slice_service, ["connection-groups", "connection-group"]): + connection_group_id = safe_get(connection_group, ['id']) + group_id = f"{service_id}-{connection_group_id}" + logging.debug(f"Group ID: {group_id}") + + # Start with service-level template for this group + template = service_template # Reset template for each connection group + + # Override template if specified at group level + group_template = get_service_template(connection_group, available_templates) + if group_template is not None: + template = group_template + logging.debug(f"Group Template: {template}") + + connectivity_type = safe_get(connection_group, ['connectivity-type']) + logging.debug(f"Connectivity Type: {connectivity_type}") + + # Process connectivity constructs + for connectivity_construct in safe_get(connection_group, ["connectivity-construct"]): + connectivity_construct_id = safe_get(connectivity_construct, ['id']) + construct_id = f"{group_id}-{connectivity_construct_id}" + logging.debug(f"Construct ID: {construct_id}") + + # Start with group-level template for this construct + final_template = template # Reset template for each connectivity construct + + # Override template if specified at construct level + construct_template = get_service_template(connectivity_construct, available_templates) + if construct_template is not None: + final_template = construct_template + logging.debug(f"Final Template: {final_template}") + + # Process SDPs based on connectivity type + sdps = process_connectivity( + connection_group_id, + connectivity_type, + connectivity_construct, + connectivity_construct_id, + slice_service + ) + logging.debug(f"SDPs: {sdps}") + if sdps: # Only append if SDPs were found + service = { + "id": construct_id, + "template": final_template, + "connectivity_type": connectivity_type, + "sdps": sdps, + "way": way + } + services.append(service) + logging.debug(f"Service added: {service}") + + # Break only for point-to-point + if connectivity_type == "point-to-point": + break + + return services, optimal_path \ No newline at end of file diff --git a/src/mapper/process_connnectivity.py b/src/mapper/process_connnectivity.py new file mode 100644 index 0000000..e691d06 --- /dev/null +++ b/src/mapper/process_connnectivity.py @@ -0,0 +1,73 @@ +from src.utils.safe_get import safe_get +from .extract_sdp_info import extract_sdp_info +import logging + +def process_connectivity(connection_group_id, connectivity_type, connectivity_construct, connectivity_construct_id, slice_service): + """ + Process connectivity construct based on type. + + Args: + connectivity_type: Type of connectivity + connectivity_construct: Dictionary with connectivity configuration + slice_service: Parent slice service + + Returns: + List of tuples: (sdp_info, direction) + """ + sdps = [] + if connectivity_type == "ietf-vpn-common:any-to-any": + for sdp in safe_get(connectivity_construct, ["a2a-sdp"]): + sdp, match_criteria = extract_sdp_info(sdp, slice_service, connection_group_id, connectivity_construct_id) + sdp = { + "sdp": sdp, + "match_criteria": match_criteria, + "type": "both" + } + sdps.append(sdp) + + elif connectivity_type == "ietf-vpn-common:hub-spoke": + # Process sender + sender_sdp = safe_get(connectivity_construct, ["p2mp-sender-sdp"]) + if sender_sdp: + sdp_source, match_criteria = extract_sdp_info(sender_sdp, slice_service, connection_group_id, connectivity_construct_id) + sdp = { + "sdp": sdp_source, + "match_criteria": match_criteria, + "type": "sender" + } + sdps.append(sdp) + + # Process receivers + for sdp in safe_get(connectivity_construct, ["p2mp-receiver-sdp"]): + sdp_info, match_criteria = extract_sdp_info(sdp, slice_service, connection_group_id, connectivity_construct_id) + sdp = { + "sdp": sdp_info, + "match_criteria": match_criteria, + "type": "receiver" + } + sdps.append(sdp) + + elif connectivity_type == "point-to-point": + # Process sender + sender_sdp = safe_get(connectivity_construct, ["p2p-sender-sdp"]) + if sender_sdp: + sdp_source, match_criteria = extract_sdp_info(sender_sdp, slice_service, connection_group_id, connectivity_construct_id) + sdp = { + "sdp": sdp_source, + "match_criteria": match_criteria, + "type": "sender" + } + sdps.append(sdp) + + # Process receiver + receiver_sdp = safe_get(connectivity_construct, ["p2p-receiver-sdp"]) + if receiver_sdp: + sdp_destination, match_criteria = extract_sdp_info(receiver_sdp, slice_service, connection_group_id, connectivity_construct_id) + sdp = { + "sdp": sdp_destination, + "match_criteria": match_criteria, + "type": "receiver" + } + sdps.append(sdp) + + return sdps \ No newline at end of file diff --git a/src/realizer/main.py b/src/realizer/main.py index eb6908a..204be88 100644 --- a/src/realizer/main.py +++ b/src/realizer/main.py @@ -19,7 +19,7 @@ from .select_way import select_way from .nrp_handler import nrp_handler from src.utils.safe_get import safe_get -def realizer(ietf_intent, need_nrp=False, order=None, nrp=None, controller_type=None, response=None, rules = None): +def realizer(service, need_nrp=False, order=None, nrp=None, controller_type=None, response=None, rules = None): """ Manage the slice creation workflow. @@ -77,7 +77,7 @@ def realizer(ietf_intent, need_nrp=False, order=None, nrp=None, controller_type= return None way = selected_way else: - way = safe_get(ietf_intent, ['ietf-network-slice-service:network-slice-services', 'slice-service', 0, 'service-tags', 'tag-type', 0, 'tag-type-value', 0]) + way = service["way"] logging.info(f"Selected way: {way}") - request = select_way(controller=controller_type, way=way, ietf_intent=ietf_intent, response=response, rules = rules) + request = select_way(controller=controller_type, way=way, ietf_intent=service, response=response, rules = rules) return request diff --git a/src/realizer/restconf/connectors/cisco_connector.py b/src/realizer/restconf/connectors/cisco_connector.py new file mode 100644 index 0000000..4812006 --- /dev/null +++ b/src/realizer/restconf/connectors/cisco_connector.py @@ -0,0 +1,121 @@ +# 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. +# 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 file is an original contribution from Telefonica Innovación Digital S.L. + +import logging +from netmiko import ConnectHandler + +class cisco_connector(): + """Class to interact with Cisco devices via SSH using Netmiko.""" + def __init__(self, address, configs=None): + self.address=address + self.configs=configs + + def execute_commands(self, commands): + """ + Execute a list of commands on the Cisco device. + Args: + commands (list): List of commands to execute on the device. + """ + try: + # Device configuration + device = { + 'device_type': 'cisco_xr', # This depends on the Cisco device type + 'host': self.address, + 'username': 'cisco', + 'password': 'cisco12345', + } + + # SSH connection + connection = ConnectHandler(**device) + + # Send commands + output = connection.send_config_set(commands) + logging.debug(output) + + # Close connection + connection.disconnect() + + except Exception as e: + logging.error(f"Failed to execute commands on {self.address}: {str(e)}") + + def create_command_template(self, config): + """ + Create command template for configuring a Cisco device. + + Args: + config (dict): Configuration parameters for the device. + + Returns: + list: List of commands to configure the device. + """ + commands = [ + "l2vpn", + f"pw-class l2vpn_vpws_profile_example_{config['number']}", + "encapsulation mpls" + ] + + commands.extend([ + "transport-mode vlan passthrough", + "control-word" + ]) + + commands.extend([ + f"preferred-path interface tunnel-te {config['number']}", + "exit", + "exit" + ]) + + commands.extend([ + "xconnect group l2vpn_vpws_group_example", + f"p2p {config['ni_name']}", + f"interface {config['interface']}.{config['vlan']}", + f"neighbor ipv4 {config['remote_router']} pw-id {config['vlan']}", + "no pw-class l2vpn_vpws_profile_example", + f"pw-class l2vpn_vpws_profile_example_{config['number']}" + ]) + + + return commands + + def full_create_command_template(self): + """ + Create full command template for configuring a Cisco device based on the provided configurations. + + Returns: + list: List of commands to configure the device. + """ + commands =[] + for config in self.configs: + commands_temp = self.create_command_template(config) + commands.extend(commands_temp) + commands.append("commit") + commands.append("end") + return commands + + def create_command_template_delete(self): + """ + Create command template for deleting L2VPN configuration on a Cisco device. + Returns: + list: List of commands to delete the L2VPN configuration. + """ + commands = [ + "no l2vpn", + ] + + commands.append("commit") + commands.append("end") + + return commands \ No newline at end of file diff --git a/src/realizer/restconf/connectors/frr_connector.py b/src/realizer/restconf/connectors/frr_connector.py new file mode 100644 index 0000000..e3cb79f --- /dev/null +++ b/src/realizer/restconf/connectors/frr_connector.py @@ -0,0 +1,89 @@ +# frr_connector.py + +import logging +from netmiko import ConnectHandler + +class frr_connector(): + """Class to interact with FRR devices via SSH using Netmiko.""" + + def __init__(self, address): + self.address = address + + def execute_commands(self, commands): + try: + device = { + 'device_type': 'linux', + 'host': self.address, + 'username': 'root', + 'password': 'root', + } + connection = ConnectHandler(**device) + output = connection.send_config_set(commands) + logging.debug(output) + connection.disconnect() + + except Exception as e: + logging.error(f"Failed to execute commands on {self.address}: {str(e)}") + raise + + def setup_slice(self, config: dict, assignments: dict[int, int]) -> list[str]: + """ + Build PBR commands dynamically based on active slot assignments. + + Args: + config: Device config (interfaces, addresses, etc.) + assignments: {slot_number: dscp_value} for currently active slices. + e.g. {1: 52, 2: 51} + Returns: + List of FRR/vtysh commands. + """ + commands = ["vtysh", "conf te"] + + # Nexthop groups - one per non-default slice + DEFAULT + for i, iface in enumerate(config['output_interfaces'], start=1): + commands += [ + f"nexthop-group SLICE-{i}", + f"nexthop {iface}", + "exit", + ] + + # PBR rules - one per active slot, ordered by seq + seq = 10 + for slot, dscp in sorted(assignments.items()): + commands += [ + f"pbr-map PBR-DSCP seq {seq}", + f"match mark {dscp}", + f"set nexthop-group SLICE-{slot}", + "exit", + ] + seq += 10 + + # Default catch-all rule always last + commands += [ + "pbr-map PBR-DSCP seq 100", + "match dst-ip 0.0.0.0/0", + "set nexthop-group DEFAULT", + "exit", + ] + + # Apply PBR policy to input interface + commands += [ + f"interface {config['input_interface']}", + f"ip address {config['input_address']}/24", + "pbr-policy PBR-DSCP", + "exit", + "end", + ] + + return commands + + def delete_all_slices(self) -> list[str]: + return [ + "vtysh", + "conf te", + "no pbr-map PBR-DSCP", + "no nexthop-group SLICE-1", + "no nexthop-group SLICE-2", + "no nexthop-group DEFAULT", + "end", + ] \ No newline at end of file diff --git a/src/realizer/restconf/connectors/tfs_connector.py b/src/realizer/restconf/connectors/tfs_connector.py new file mode 100644 index 0000000..91c64f4 --- /dev/null +++ b/src/realizer/restconf/connectors/tfs_connector.py @@ -0,0 +1,103 @@ +# 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. +# 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 file includes original contributions from Telefonica Innovación Digital S.L. + +import logging, requests, json +from src.config.constants import NBI_L2_PATH, NBI_L3_PATH + +class tfs_connector(): + """ + Helper class to interact with TeraFlowSDN Northbound Interface (NBI) and WebUI. + """ + def webui_post(self, tfs_ip, service): + """ + Post service descriptor to TFS WebUI. + + Args: + tfs_ip (str): IP address of the TFS instance + service (dict): Service descriptor to be posted + + Returns: + requests.Response: Response object from the POST request + """ + user="admin" + password="admin" + token="" + session = requests.Session() + session.auth = (user, password) + url=f'http://{tfs_ip}/webui' + response=session.get(url=url) + for item in response.iter_lines(): + if("csrf_token" in str(item)): + string=str(item).split(' requests.Response: + """ + Delete service from TFS NBI. + Args: + tfs_ip (str): IP address of the TFS instance + service_type (str): Type of the service ('L2' or 'L3') + service_id (str): Unique identifier of the service to delete + Returns: + requests.Response: Response object from the DELETE request + """ + user="admin" + password="admin" + url = f'http://{user}:{password}@{tfs_ip}' + if service_type == 'L2': + url = url + f'/{NBI_L2_PATH}/vpn-service={service_id}' + elif service_type == 'L3': + url = url + f'/{NBI_L3_PATH}/vpn-service={service_id}' + else: + raise ValueError("Invalid service type. Use 'L2' or 'L3'.") + response = requests.delete(url, timeout=60) + response.raise_for_status() + logging.debug('Service deleted successfully') + logging.debug("Http response: %s",response.text) + return response \ No newline at end of file diff --git a/src/realizer/restconf/main.py b/src/realizer/restconf/main.py new file mode 100644 index 0000000..cf415d3 --- /dev/null +++ b/src/realizer/restconf/main.py @@ -0,0 +1,40 @@ +# 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. +# 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 file is an original contribution from Telefonica Innovación Digital S.L. + +import logging +from .service_types.l2vpn import l2vpn +from .service_types.l3vpn import l3vpn + +def restconf(ietf_intent, way=None, response=None): + """ + Generates a TFS realizing request based on the specified way (L2 or L3). + + Args: + ietf_intent (dict): The IETF intent to be realized. Defaults to None. + way (str): The type of service to realize ("L2" or "L3"). Defaults to None. + response (dict): Response built for user feedback. Defaults to None. + + Returns: + dict: A realization request for the specified network slice type. + """ + if way == "L2": + realizing_request = l2vpn(ietf_intent) + elif way == "L3": + realizing_request = l3vpn(ietf_intent) + else: + logging.warning(f"Unsupported way: {way}. Defaulting to L2 realization.") + realizing_request = l2vpn(ietf_intent) + return realizing_request \ No newline at end of file diff --git a/src/realizer/restconf/restconf_connect.py b/src/realizer/restconf/restconf_connect.py new file mode 100644 index 0000000..e4d75af --- /dev/null +++ b/src/realizer/restconf/restconf_connect.py @@ -0,0 +1,81 @@ +from src.utils.slice_manager import SliceManager +from .connectors.frr_connector import frr_connector +from .connectors.tfs_connector import tfs_connector +from src.utils.send_response import send_response +from src.utils.safe_get import safe_get +from flask import current_app +from src.config.constants import NBI_L2_PATH, NBI_L3_PATH +import logging + +FRR_DEVICES = [ + { + "management_address": "10.60.125.20", + "input_address": "192.168.24.2", + "input_interface": "eth2", + "output_interfaces": ["192.168.23.3", "192.168.25.5", "192.168.12.1"], + }, + { + "management_address": "10.60.125.50", + "input_address": "192.168.56.5", + "input_interface": "eth4", + "output_interfaces": ["192.168.35.3", "192.168.25.2", "192.168.15.1"], + }, +] + +_slice_manager = SliceManager() +def restconf_connect(requests, restconf_ip): + """ + Connect to controller and upload services. + + Args: + requests (dict): Dictionary containing services to upload + tfs_ip (str): IP address of the TFS controller + + Returns: + response (requests.Response): Response from TFS controller + """ + for intent in requests["services"]: + if current_app.config["SDN_CONTROLLER_TYPE"] == "TFS": + key = next(iter(intent)) + if key == "ietf-l2vpn-svc:l2vpn-svc": + path = NBI_L2_PATH + + elif key == "ietf-l3vpn-svc:l3vpn-svc": + path = NBI_L3_PATH + dscp = safe_get(intent, [ + "sites", 0, "site-network-accesses", [0], + "service", "qos", "qos-classification-policy", + "rule", 0, "match-flow", "dscp" + ]) + + if dscp is not None and current_app.config["DATAPLANE_SUPPORT"] == "FRR": + if _slice_manager.is_full(): + return send_response( + False, code=429, + message=f"No available slices. Current assignments: {_slice_manager.get_active_assignments()}" + ) + + slot = _slice_manager.assign_slot(dscp) + if slot is None: + return send_response(False, code=429, message=f"Could not assign slot for DSCP {dscp}") + + logging.info(f"Assigned DSCP {dscp} to SLICE-{slot}") + + assignments = _slice_manager.get_active_assignments() + for device_config in FRR_DEVICES: + connector = frr_connector(device_config["management_address"]) + commands = connector.setup_slice(device_config, assignments) + try: + connector.execute_commands(commands) + except Exception as e: + return send_response(False, code=500, + message=f"FRR config failed on {device_config['management_address']}: {str(e)}") + else: + return send_response(False, code=400, message=f"Unsupported service type: {key}") + + response = tfs_connector().nbi_post(restconf_ip, intent, path) + if not response.ok: + return send_response(False, code=response.status_code, + message=f"Controller upload failed. Response: {response.text}") + + return response \ No newline at end of file diff --git a/src/realizer/restconf/service_types/builders/apply_metric_constraint.py b/src/realizer/restconf/service_types/builders/apply_metric_constraint.py new file mode 100644 index 0000000..60354fa --- /dev/null +++ b/src/realizer/restconf/service_types/builders/apply_metric_constraint.py @@ -0,0 +1,49 @@ +import logging +def apply_metric_constraint(service, qos_class, constraint, vpn_id, layer_type): + """Aplica una restricción de métrica específica.""" + metric_type = constraint.get("metric-type") + metric_value = float(constraint.get("bound", 0)) + + if metric_type == "two-way-bandwidth": + unit = constraint.get("metric-unit", "bps") + multiplier = {"bps": 1, "kbps": 1_000, "Mbps": 1_000_000, "Gbps": 1_000_000_000}.get(unit, 1) + bandwidth = int(metric_value * multiplier) + if layer_type == "l2": + service["svc-bandwidth"] = { + "bandwidth":[ + { + "type": "bw-per-svc", + "direction": "input-bw", + "vpn-id": vpn_id, + "cir": bandwidth, + "cbs": bandwidth*0.05 + }, + { + "type": "bw-per-svc", + "direction": "output-bw", + "vpn-id": vpn_id, + "cir": bandwidth, + "cbs": bandwidth*0.05 + }, + ] + } + elif layer_type == "l3": + service["svc-input-bandwidth"] = bandwidth + service["svc-output-bandwidth"] = bandwidth + + elif metric_type == "two-way-delay-maximum": + if layer_type == "l2": + qos_class.setdefault("frame-delay", {})["delay-bound"] = int(metric_value) + elif layer_type == "l3": + qos_class.setdefault("latency", {})["latency-boundary"] = int(metric_value) + + elif metric_type == "two-way-delay-variation-maximum": + if layer_type == "l2": + qos_class.setdefault("frame-jitter", {})["delay-bound"] = int(metric_value) + elif layer_type == "l3": + qos_class.setdefault("jitter", {})["latency-boundary"] = int(metric_value) + + elif metric_type == "two-way-packet-loss": + if layer_type == "l2": + qos_class.setdefault("frame-loss", {})["loss-bound"] = metric_value + diff --git a/src/realizer/restconf/service_types/builders/configure_match_criteria.py b/src/realizer/restconf/service_types/builders/configure_match_criteria.py new file mode 100644 index 0000000..44b0f1f --- /dev/null +++ b/src/realizer/restconf/service_types/builders/configure_match_criteria.py @@ -0,0 +1,35 @@ +import logging +from src.utils.safe_get import safe_get + + +def configure_match_criteria(network_access, sdp, layer_type): + """Configura los criterios de coincidencia en el acceso a la red.""" + MATCH_TYPE_MAPPING = { + "dscp": "dscp" + } + if layer_type == "l3": + MATCH_TYPE_MAPPING["source-ip-prefix"] = "ipv4-src-prefix" + MATCH_TYPE_MAPPING["destination-ip-prefix"] = "ipv4-dst-prefix" + + match_criteria = sdp.get("match_criteria") + if not match_criteria: + return + + match_type = safe_get(sdp, ["match_criteria", "match-type", 0, "type"]) + index = safe_get(sdp, ["match_criteria", "index"]) + value = safe_get(sdp, ["match_criteria", "match-type", 0, match_type, 0]) + + logging.debug(f"Configuring match criteria for SDP: {safe_get(sdp, ['sdp', 'id'])} with match type: {match_type} and value: {value}") + + if match_type not in MATCH_TYPE_MAPPING: + logging.warning(f"Unknown match type: {match_type}") + return + + rule = { + "id": f"match-{match_type}-{index}", + "match-flow": { + MATCH_TYPE_MAPPING[match_type]: value + } + } + + network_access["service"]["qos"]["qos-classification-policy"]["rule"].append(rule) \ No newline at end of file diff --git a/src/realizer/restconf/service_types/builders/configure_slos.py b/src/realizer/restconf/service_types/builders/configure_slos.py new file mode 100644 index 0000000..2a674f5 --- /dev/null +++ b/src/realizer/restconf/service_types/builders/configure_slos.py @@ -0,0 +1,26 @@ +import logging +from src.utils.safe_get import safe_get +from .apply_metric_constraint import apply_metric_constraint + +def configure_slos(network_access, ietf_intent, layer_type): + """Configura los SLOs (Service Level Objectives) en el acceso a la red.""" + service = network_access["service"] + qos_class = service["qos"]["qos-profile"]["classes"]["class"][0] + + logging.debug(f"Configuring SLOs with constraints: {safe_get(ietf_intent, ['template', 'slo-policy', 'metric-bound'])}") + + # Configurar constraints de métricas + metric_bounds = safe_get(ietf_intent, ["template", "slo-policy", "metric-bound"]) + if metric_bounds: + for constraint in metric_bounds: + apply_metric_constraint(service, qos_class, constraint, ietf_intent["id"], layer_type) + + # Configurar availability + availability = safe_get(ietf_intent, ["template", "slo-policy", "availability"]) + if availability: + qos_class.setdefault("bandwidth", {})["guaranteed-bw-percent"] = availability + + # Configurar MTU + mtu = safe_get(ietf_intent, ["template", "slo-policy", "mtu"]) + if mtu: + service["svc-mtu"] = mtu \ No newline at end of file diff --git a/src/realizer/restconf/service_types/builders/create_network_access.py b/src/realizer/restconf/service_types/builders/create_network_access.py new file mode 100644 index 0000000..6e1a790 --- /dev/null +++ b/src/realizer/restconf/service_types/builders/create_network_access.py @@ -0,0 +1,53 @@ +from src.utils.safe_get import safe_get +from .configure_match_criteria import configure_match_criteria +from .configure_slos import configure_slos + +def create_network_access(sdp, ietf_intent, connectivity_type, router_id, router_if, layer_type): + """Crea la configuración de acceso a la red del site.""" + if layer_type == "l2": + access_id = "network-access-id" + access_type = "type" + elif layer_type == "l3": + access_id = "site-network-access-id" + access_type = "site-network-access-type" + ip_connection = { + "ipv4": { + "address-allocation-type": "ietf-l3vpn-svc:static-address", + "addresses": { + "provider-address": safe_get(sdp, ["sdp", "attachment-circuits", "attachment-circuit", 0, "ac-ipv4-address"]), + "customer-address": "", + "prefix-length": safe_get(sdp, ["sdp", "attachment-circuits", "attachment-circuit", 0, "ac-ipv4-prefix-length"]) + } + } + } + + network_access = { + access_id: router_if, + access_type: f"ietf-{layer_type}-svc:{connectivity_type}", + "device-reference": router_id, + "vpn-attachment": { + "vpn-id": ietf_intent["id"], + "site-role": f"ietf-{layer_type}-svc:{sdp['type']}" + }, + "service": { + "qos": { + "qos-classification-policy": {"rule": []}, + "qos-profile": { + "classes": { + "class": [{ + "class-id": "qos-realtime", + "direction": f"ietf-{layer_type}-svc:both", + }] + } + } + } + } + } + if layer_type == "l3": + network_access["ip-connection"] = ip_connection + + # Configurar match criteria y SLOs + configure_match_criteria(network_access, sdp, layer_type) + configure_slos(network_access, ietf_intent, layer_type) + + return network_access diff --git a/src/realizer/restconf/service_types/builders/create_site_from_sdp.py b/src/realizer/restconf/service_types/builders/create_site_from_sdp.py new file mode 100644 index 0000000..c53b102 --- /dev/null +++ b/src/realizer/restconf/service_types/builders/create_site_from_sdp.py @@ -0,0 +1,43 @@ +import logging +from src.utils.safe_get import safe_get +from .create_network_access import create_network_access + +def create_site_from_sdp(sdp, ietf_intent, connectivity_type, layer_type): + """ + Crea la configuración de un site a partir de un SDP. + + Args: + sdp: Service Delivery Point + ietf_intent: Intent IETF completo + connectivity_type: Tipo de conectividad + + Returns: + Diccionario con la configuración del site + """ + logging.debug(f"Processing SDP: {sdp}") + + # Extraer información básica + location = safe_get(sdp, ["sdp", "node-id"]) + router_id = safe_get(sdp, ["sdp", "attachment-circuits", "attachment-circuit", 0, "ac-node-id"]) + router_if = safe_get(sdp, ["sdp", "attachment-circuits", "attachment-circuit", 0, "ac-tp-id"]) + + logging.debug(f"Configured site for SDP {safe_get(sdp, ['sdp', 'id'])} with location: {location}, router_id: {router_id}, router_if: {router_if}") + + # Crear estructura del site + site = { + "site-id": safe_get(sdp, ["sdp", "id"]), + "locations": { + "location": [{"location-id": location}] + }, + "devices": { + "device": [{ + "device-id": router_id, + "location": location + }] + }, + "site-network-accesses": { + "site-network-access": [create_network_access(sdp, ietf_intent, connectivity_type, router_id, router_if, layer_type)] + } + } + + return site diff --git a/src/realizer/restconf/service_types/builders/initialize_structure.py b/src/realizer/restconf/service_types/builders/initialize_structure.py new file mode 100644 index 0000000..cf7866d --- /dev/null +++ b/src/realizer/restconf/service_types/builders/initialize_structure.py @@ -0,0 +1,22 @@ +def initialize_structure(vpn_id, connectivity_type, layer_type): + """Inicializa la estructura base del servicio.""" + structure = { + f"ietf-{layer_type}vpn-svc:{layer_type}vpn-svc": { + "vpn-services": { + "vpn-service": [ + {"vpn-id": vpn_id} + ] + }, + "sites": { + "site": [] + } + } + } + if layer_type == "l2": + structure[f"ietf-{layer_type}vpn-svc:{layer_type}vpn-svc"]["vpn-services"]["vpn-service"] = { + "vpn-id": vpn_id, + "customer-name": "osm", + "vpn-svc-type": "vpws", # Lo dejamos en vpws porque es el unico que encaja + "svc-topo": connectivity_type, + } + return structure \ No newline at end of file diff --git a/src/realizer/restconf/service_types/l2vpn.py b/src/realizer/restconf/service_types/l2vpn.py new file mode 100644 index 0000000..34fa537 --- /dev/null +++ b/src/realizer/restconf/service_types/l2vpn.py @@ -0,0 +1,52 @@ +# 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. +# 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 file includes original contributions from Telefonica Innovación Digital S.L. + +import logging +from .builders.initialize_structure import initialize_structure +from .builders.create_site_from_sdp import create_site_from_sdp + + + +def l2vpn(ietf_intent): + """ + Crea un servicio L2VPN basado en el intent IETF proporcionado. + + Args: + ietf_intent: Diccionario con la configuración del intent IETF + response: Objeto de respuesta + + Returns: + Diccionario con la configuración del servicio L2VPN o None si no hay SDPs + """ + # Validación temprana + if not ietf_intent.get("sdps"): + logging.warning("SDPs not found in the intent. Skipping L2VPN realization.") + return None + + # Inicializar estructura L2VPN + connectivity_type = ietf_intent["connectivity_type"] + l2_service = initialize_structure(ietf_intent["id"], connectivity_type, layer_type="l2") + + + # Procesar cada SDP + for sdp in ietf_intent["sdps"]: + site = create_site_from_sdp(sdp, ietf_intent, connectivity_type, layer_type="l2") + l2_service["ietf-l2vpn-svc:l2vpn-svc"]["sites"]["site"].append(site) + + logging.debug(f"L2VPN service created: {l2_service}") + logging.info("L2VPN Intent realized") + + return l2_service \ No newline at end of file diff --git a/src/realizer/restconf/service_types/l3vpn.py b/src/realizer/restconf/service_types/l3vpn.py new file mode 100644 index 0000000..7ffd560 --- /dev/null +++ b/src/realizer/restconf/service_types/l3vpn.py @@ -0,0 +1,49 @@ +# 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. +# 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 file includes original contributions from Telefonica Innovación Digital S.L. + +import logging +from .builders.initialize_structure import initialize_structure +from .builders.create_site_from_sdp import create_site_from_sdp + +def l3vpn(ietf_intent): + """ + Crea un servicio L3VPN basado en el intent IETF proporcionado. + + Args: + ietf_intent: Diccionario con la configuración del intent IETF + response: Objeto de respuesta + + Returns: + Diccionario con la configuración del servicio L3VPN o None si no hay SDPs + """ + # Validación temprana + if not ietf_intent.get("sdps"): + logging.warning("SDPs not found in the intent. Skipping L3VPN realization.") + return None + + # Inicializar estructura L3VPN + connectivity_type = ietf_intent["connectivity_type"] + l3_service = initialize_structure(ietf_intent["id"], connectivity_type, layer_type="l3") + + # Procesar cada SDP + for sdp in ietf_intent["sdps"]: + site = create_site_from_sdp(sdp, ietf_intent, connectivity_type, layer_type="l3") + l3_service["ietf-l3vpn-svc:l3vpn-svc"]["sites"]["site"].append(site) + + logging.debug(f"L3VPN service created: {l3_service}") + logging.info("L3VPN Intent realized") + + return l3_service \ No newline at end of file diff --git a/src/realizer/select_way.py b/src/realizer/select_way.py index 6d3cc53..016f9ee 100644 --- a/src/realizer/select_way.py +++ b/src/realizer/select_way.py @@ -15,9 +15,10 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. import logging -from .ixia.main import ixia -from .tfs.main import tfs -from .e2e.main import e2e +from .ixia.main import ixia +from .tfs.main import tfs +from .e2e.main import e2e +from .restconf.main import restconf def select_way(controller=None, way=None, ietf_intent=None, response=None, rules = None): """ @@ -45,6 +46,8 @@ def select_way(controller=None, way=None, ietf_intent=None, response=None, rules realizing_request = ixia(ietf_intent) elif controller == "E2E": realizing_request = e2e(ietf_intent, way, response, rules) + elif controller == "RESTCONF": + realizing_request = restconf(ietf_intent, way, response) else: logging.warning(f"Unsupported controller: {controller}. Defaulting to TFS realization.") realizing_request = tfs(ietf_intent, way, response) diff --git a/src/realizer/send_controller.py b/src/realizer/send_controller.py index 334de49..ee757fa 100644 --- a/src/realizer/send_controller.py +++ b/src/realizer/send_controller.py @@ -19,6 +19,7 @@ from flask import current_app from .tfs.tfs_connect import tfs_connect from .ixia.ixia_connect import ixia_connect from .e2e.e2e_connect import e2e_connect +from .restconf.restconf_connect import restconf_connect def send_controller(controller_type, requests): """ @@ -61,5 +62,7 @@ def send_controller(controller_type, requests): elif controller_type == "E2E": response = e2e_connect(requests, current_app.config["TFS_E2E"]) logging.info("Requests sent to Teraflow E2E") - + elif controller_type == "restconf": + response = restconf_connect(requests, current_app.config["RESTCONF_IP"]) + logging.info("Requests sent to restconf controller") return response \ No newline at end of file diff --git a/src/utils/slice_manager.py b/src/utils/slice_manager.py new file mode 100644 index 0000000..17e01bb --- /dev/null +++ b/src/utils/slice_manager.py @@ -0,0 +1,65 @@ +# 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. +# 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 file is an original contribution from Telefonica Innovación Digital S.L. + +class SliceManager: + """ + Manages available PBR slices and their DSCP assignments. + Slices are 1-indexed. Slice with the highest index is always the default. + """ + TOTAL_SLICES = 3 + DEFAULT_SLICE = TOTAL_SLICES # SLICE-3 is always default + + def __init__(self): + # slot -> dscp (None means free) + self._slots: dict[int, int | None] = { + i: None for i in range(1, self.TOTAL_SLICES) # {1: None, 2: None} + } + + def assign_slot(self, dscp: int) -> int | None: + """ + Try to assign a free slot to a DSCP value. + Returns the assigned slot number, or None if no slots are available. + """ + # Check if DSCP already assigned + for slot, assigned_dscp in self._slots.items(): + if assigned_dscp == dscp: + return slot # idempotent + + # Find first free slot + for slot, assigned_dscp in self._slots.items(): + if assigned_dscp is None: + self._slots[slot] = dscp + return slot + + return None # No free slots + + def release_slot(self, dscp: int) -> bool: + """ + Release the slot assigned to a DSCP value. + Returns True if released, False if not found. + """ + for slot, assigned_dscp in self._slots.items(): + if assigned_dscp == dscp: + self._slots[slot] = None + return True + return False + + def get_active_assignments(self) -> dict[int, int]: + """Returns only the occupied slots {slot: dscp}.""" + return {slot: dscp for slot, dscp in self._slots.items() if dscp is not None} + + def is_full(self) -> bool: + return all(dscp is not None for dscp in self._slots.values()) \ No newline at end of file -- GitLab From 8df8e7148d98289e021cede346b33cedf3e90edb Mon Sep 17 00:00:00 2001 From: velazquez Date: Wed, 25 Feb 2026 12:50:16 +0100 Subject: [PATCH 2/4] Move some changes to feature 26 --- src/config/.env.example | 4 +- src/config/config.py | 1 - .../restconf/connectors/frr_connector.py | 89 ------------------- src/realizer/restconf/restconf_connect.py | 28 ------ src/utils/slice_manager.py | 65 -------------- 5 files changed, 1 insertion(+), 186 deletions(-) delete mode 100644 src/realizer/restconf/connectors/frr_connector.py delete mode 100644 src/utils/slice_manager.py diff --git a/src/config/.env.example b/src/config/.env.example index 03ec8f1..31b1f1d 100644 --- a/src/config/.env.example +++ b/src/config/.env.example @@ -19,7 +19,7 @@ # ------------------------- NSC_PORT=8086 # Options: CRITICAL, ERROR, WARNING, INFO, DEBUG, NOTSET -LOGGING_LEVEL=DEBUG +LOGGING_LEVEL=INFO DUMP_TEMPLATES=false # ------------------------- @@ -69,8 +69,6 @@ TFS_E2E_IP=127.0.0.1 RESTCONF_IP=127.0.0.1 # Options: TFS or IXIA SDN_CONTROLLER_TYPE=TFS -# Options: FRR, CISCO -DATAPLANE_SUPPORT=FRR # ------------------------- # WebUI diff --git a/src/config/config.py b/src/config/config.py index 145418d..4955a51 100644 --- a/src/config/config.py +++ b/src/config/config.py @@ -67,6 +67,5 @@ def create_config(app: Flask): # Restconf Controller app.config["RESTCONF_IP"] = os.getenv("RESTCONF_IP", "127.0.0.1") app.config["SDN_CONTROLLER_TYPE"] = os.getenv("SDN_CONTROLLER_TYPE", "TFS") - app.config["DATAPLANE_SUPPORT"] = os.getenv("DATAPLANE_SUPPORT", "FRR") return app diff --git a/src/realizer/restconf/connectors/frr_connector.py b/src/realizer/restconf/connectors/frr_connector.py deleted file mode 100644 index e3cb79f..0000000 --- a/src/realizer/restconf/connectors/frr_connector.py +++ /dev/null @@ -1,89 +0,0 @@ -# frr_connector.py - -import logging -from netmiko import ConnectHandler - -class frr_connector(): - """Class to interact with FRR devices via SSH using Netmiko.""" - - def __init__(self, address): - self.address = address - - def execute_commands(self, commands): - try: - device = { - 'device_type': 'linux', - 'host': self.address, - 'username': 'root', - 'password': 'root', - } - connection = ConnectHandler(**device) - output = connection.send_config_set(commands) - logging.debug(output) - connection.disconnect() - - except Exception as e: - logging.error(f"Failed to execute commands on {self.address}: {str(e)}") - raise - - def setup_slice(self, config: dict, assignments: dict[int, int]) -> list[str]: - """ - Build PBR commands dynamically based on active slot assignments. - - Args: - config: Device config (interfaces, addresses, etc.) - assignments: {slot_number: dscp_value} for currently active slices. - e.g. {1: 52, 2: 51} - Returns: - List of FRR/vtysh commands. - """ - commands = ["vtysh", "conf te"] - - # Nexthop groups - one per non-default slice + DEFAULT - for i, iface in enumerate(config['output_interfaces'], start=1): - commands += [ - f"nexthop-group SLICE-{i}", - f"nexthop {iface}", - "exit", - ] - - # PBR rules - one per active slot, ordered by seq - seq = 10 - for slot, dscp in sorted(assignments.items()): - commands += [ - f"pbr-map PBR-DSCP seq {seq}", - f"match mark {dscp}", - f"set nexthop-group SLICE-{slot}", - "exit", - ] - seq += 10 - - # Default catch-all rule always last - commands += [ - "pbr-map PBR-DSCP seq 100", - "match dst-ip 0.0.0.0/0", - "set nexthop-group DEFAULT", - "exit", - ] - - # Apply PBR policy to input interface - commands += [ - f"interface {config['input_interface']}", - f"ip address {config['input_address']}/24", - "pbr-policy PBR-DSCP", - "exit", - "end", - ] - - return commands - - def delete_all_slices(self) -> list[str]: - return [ - "vtysh", - "conf te", - "no pbr-map PBR-DSCP", - "no nexthop-group SLICE-1", - "no nexthop-group SLICE-2", - "no nexthop-group DEFAULT", - "end", - ] \ No newline at end of file diff --git a/src/realizer/restconf/restconf_connect.py b/src/realizer/restconf/restconf_connect.py index e4d75af..e1fe465 100644 --- a/src/realizer/restconf/restconf_connect.py +++ b/src/realizer/restconf/restconf_connect.py @@ -1,5 +1,4 @@ from src.utils.slice_manager import SliceManager -from .connectors.frr_connector import frr_connector from .connectors.tfs_connector import tfs_connector from src.utils.send_response import send_response from src.utils.safe_get import safe_get @@ -42,34 +41,7 @@ def restconf_connect(requests, restconf_ip): elif key == "ietf-l3vpn-svc:l3vpn-svc": path = NBI_L3_PATH - dscp = safe_get(intent, [ - "sites", 0, "site-network-accesses", [0], - "service", "qos", "qos-classification-policy", - "rule", 0, "match-flow", "dscp" - ]) - if dscp is not None and current_app.config["DATAPLANE_SUPPORT"] == "FRR": - if _slice_manager.is_full(): - return send_response( - False, code=429, - message=f"No available slices. Current assignments: {_slice_manager.get_active_assignments()}" - ) - - slot = _slice_manager.assign_slot(dscp) - if slot is None: - return send_response(False, code=429, message=f"Could not assign slot for DSCP {dscp}") - - logging.info(f"Assigned DSCP {dscp} to SLICE-{slot}") - - assignments = _slice_manager.get_active_assignments() - for device_config in FRR_DEVICES: - connector = frr_connector(device_config["management_address"]) - commands = connector.setup_slice(device_config, assignments) - try: - connector.execute_commands(commands) - except Exception as e: - return send_response(False, code=500, - message=f"FRR config failed on {device_config['management_address']}: {str(e)}") else: return send_response(False, code=400, message=f"Unsupported service type: {key}") diff --git a/src/utils/slice_manager.py b/src/utils/slice_manager.py deleted file mode 100644 index 17e01bb..0000000 --- a/src/utils/slice_manager.py +++ /dev/null @@ -1,65 +0,0 @@ -# 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. -# 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 file is an original contribution from Telefonica Innovación Digital S.L. - -class SliceManager: - """ - Manages available PBR slices and their DSCP assignments. - Slices are 1-indexed. Slice with the highest index is always the default. - """ - TOTAL_SLICES = 3 - DEFAULT_SLICE = TOTAL_SLICES # SLICE-3 is always default - - def __init__(self): - # slot -> dscp (None means free) - self._slots: dict[int, int | None] = { - i: None for i in range(1, self.TOTAL_SLICES) # {1: None, 2: None} - } - - def assign_slot(self, dscp: int) -> int | None: - """ - Try to assign a free slot to a DSCP value. - Returns the assigned slot number, or None if no slots are available. - """ - # Check if DSCP already assigned - for slot, assigned_dscp in self._slots.items(): - if assigned_dscp == dscp: - return slot # idempotent - - # Find first free slot - for slot, assigned_dscp in self._slots.items(): - if assigned_dscp is None: - self._slots[slot] = dscp - return slot - - return None # No free slots - - def release_slot(self, dscp: int) -> bool: - """ - Release the slot assigned to a DSCP value. - Returns True if released, False if not found. - """ - for slot, assigned_dscp in self._slots.items(): - if assigned_dscp == dscp: - self._slots[slot] = None - return True - return False - - def get_active_assignments(self) -> dict[int, int]: - """Returns only the occupied slots {slot: dscp}.""" - return {slot: dscp for slot, dscp in self._slots.items() if dscp is not None} - - def is_full(self) -> bool: - return all(dscp is not None for dscp in self._slots.values()) \ No newline at end of file -- GitLab From 067c1b3831870ecde7c99258fd0afccfb4540c68 Mon Sep 17 00:00:00 2001 From: velazquez Date: Wed, 25 Feb 2026 12:55:13 +0100 Subject: [PATCH 3/4] More changes for feature 26 --- src/realizer/restconf/restconf_connect.py | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/src/realizer/restconf/restconf_connect.py b/src/realizer/restconf/restconf_connect.py index e1fe465..6aa431d 100644 --- a/src/realizer/restconf/restconf_connect.py +++ b/src/realizer/restconf/restconf_connect.py @@ -1,4 +1,3 @@ -from src.utils.slice_manager import SliceManager from .connectors.tfs_connector import tfs_connector from src.utils.send_response import send_response from src.utils.safe_get import safe_get @@ -6,22 +5,6 @@ from flask import current_app from src.config.constants import NBI_L2_PATH, NBI_L3_PATH import logging -FRR_DEVICES = [ - { - "management_address": "10.60.125.20", - "input_address": "192.168.24.2", - "input_interface": "eth2", - "output_interfaces": ["192.168.23.3", "192.168.25.5", "192.168.12.1"], - }, - { - "management_address": "10.60.125.50", - "input_address": "192.168.56.5", - "input_interface": "eth4", - "output_interfaces": ["192.168.35.3", "192.168.25.2", "192.168.15.1"], - }, -] - -_slice_manager = SliceManager() def restconf_connect(requests, restconf_ip): """ Connect to controller and upload services. -- GitLab From e7de0d3e996d3ef3e3042ec0b8f4682d5d8e47bf Mon Sep 17 00:00:00 2001 From: velazquez Date: Wed, 25 Feb 2026 18:14:08 +0100 Subject: [PATCH 4/4] Adapt code to be comnpatible with previous tests --- src/database/sysrepo_store.py | 8 +- src/main.py | 5 +- src/mapper/main.py | 152 ++++++++++---------- src/realizer/main.py | 2 +- src/realizer/tfs/service_types/tfs_l2vpn.py | 1 + src/tests/test_mapper.py | 12 +- src/tests/test_nbi_processor.py | 4 +- src/tests/test_utils.py | 2 +- 8 files changed, 95 insertions(+), 91 deletions(-) diff --git a/src/database/sysrepo_store.py b/src/database/sysrepo_store.py index b6098d3..63c80ba 100644 --- a/src/database/sysrepo_store.py +++ b/src/database/sysrepo_store.py @@ -33,10 +33,9 @@ def create_data_store(intent: dict, xpath: str= ""): sess = conn.start_session() try: - logging.info(f"Creating data at {xpath}") _write_dict(sess, xpath, intent) sess.apply_changes() - logging.info(f"Created data at {xpath}") + logging.debug(f"Created data at {xpath}") return True except Exception as e: sess.discard_changes() @@ -129,7 +128,7 @@ def patch_data_store(intent: dict, xpath: str = ""): # PATCH: Merge con datos existentes _write_dict(sess, xpath, intent) sess.apply_changes() - logging.info(f"Merged data at {xpath}") + logging.debug(f"Merged data at {xpath}") return True except Exception as e: @@ -148,10 +147,9 @@ def delete_data_store(xpath: str = ""): sess = conn.start_session() try: - logging.info(f"Deleting data at {xpath}") sess.delete_item(xpath) sess.apply_changes() - logging.info(f"Deleted data at {xpath}") + logging.debug(f"Deleted data at {xpath}") return True except Exception as e: sess.discard_changes() diff --git a/src/main.py b/src/main.py index 3e533a7..55b3fca 100644 --- a/src/main.py +++ b/src/main.py @@ -95,9 +95,10 @@ class NSController: ietf_intents = nbi_processor(intent_json) for intent in ietf_intents: + logging.info(intent) # Mapper - services, rules = mapper(intent) - logging.info(f"Services: {services}") + services, rules = mapper(intent, controller_type=self.controller_type) + logging.debug(f"Services: {services}") # Build response self.response = build_response(intent, self.response, controller_type= self.controller_type) # Realizer diff --git a/src/mapper/main.py b/src/mapper/main.py index 4cac67d..1732419 100644 --- a/src/mapper/main.py +++ b/src/mapper/main.py @@ -24,7 +24,7 @@ from src.realizer.main import realizer from flask import current_app from src.database.sysrepo_store import get_data_store, create_data_store, delete_data_store, update_data_store, normalize_libyang_data -def mapper(ietf_intent): +def mapper(ietf_intent, controller_type="TFS"): """ Map an IETF network slice intent to the most suitable Network Resource Partition (NRP). @@ -42,6 +42,9 @@ def mapper(ietf_intent): Returns: dict or None: Optimal path if planner is enabled; otherwise, None. """ + optimal_path = None + services = [ietf_intent] + if current_app.config["NRP_ENABLED"]: # Retrieve NRP view nrp_view = realizer(None, True, "READ") @@ -77,87 +80,88 @@ def mapper(ietf_intent): if current_app.config["PLANNER_ENABLED"]: optimal_path = Planner().planner(ietf_intent, current_app.config["PLANNER_TYPE"]) logging.debug(f"Optimal path: {optimal_path}") - #return optimal_path - # Initialize available templates - templates = get_data_store("/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template") - normalized_templates = normalize_libyang_data(templates) - logging.debug(f"Normalized templates: {normalized_templates}") - available_templates = safe_get(normalized_templates, ["slo-sle-templates", "slo-sle-template"]) or [] - - # Add templates from intent - for template in safe_get(ietf_intent, ["ietf-network-slice-service:network-slice-services","slo-sle-templates", "slo-sle-template"]): - available_templates.append(template) - logging.debug(f"Available templates: {available_templates}") - - services = [] - # Process each slice service - for slice_service in safe_get(ietf_intent, ["ietf-network-slice-service:network-slice-services", "slice-service"]): - service_id = safe_get(slice_service, ["id"]) - logging.debug(f"Service ID: {service_id}") - - way = safe_get(slice_service, ['service-tags', 'tag-type', 0, 'tag-type-value', 0]) - logging.debug(f"Way: {way}") + if controller_type == "RESTCONF": + # Initialize available templates + templates = get_data_store("/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template") + normalized_templates = normalize_libyang_data(templates) + logging.debug(f"Normalized templates: {normalized_templates}") + available_templates = safe_get(normalized_templates, ["slo-sle-templates", "slo-sle-template"]) or [] - # Get service-level template - service_template = get_service_template(slice_service, available_templates) - logging.debug(f"Service Template: {service_template}") + # Add templates from intent + for template in safe_get(ietf_intent, ["ietf-network-slice-service:network-slice-services","slo-sle-templates", "slo-sle-template"]): + available_templates.append(template) + logging.debug(f"Available templates: {available_templates}") - # Process connection groups - for connection_group in safe_get(slice_service, ["connection-groups", "connection-group"]): - connection_group_id = safe_get(connection_group, ['id']) - group_id = f"{service_id}-{connection_group_id}" - logging.debug(f"Group ID: {group_id}") - - # Start with service-level template for this group - template = service_template # Reset template for each connection group - - # Override template if specified at group level - group_template = get_service_template(connection_group, available_templates) - if group_template is not None: - template = group_template - logging.debug(f"Group Template: {template}") + services = [] + + # Process each slice service + for slice_service in safe_get(ietf_intent, ["ietf-network-slice-service:network-slice-services", "slice-service"]): + service_id = safe_get(slice_service, ["id"]) + logging.debug(f"Service ID: {service_id}") + + way = safe_get(slice_service, ['service-tags', 'tag-type', 0, 'tag-type-value', 0]) + logging.debug(f"Way: {way}") - connectivity_type = safe_get(connection_group, ['connectivity-type']) - logging.debug(f"Connectivity Type: {connectivity_type}") + # Get service-level template + service_template = get_service_template(slice_service, available_templates) + logging.debug(f"Service Template: {service_template}") - # Process connectivity constructs - for connectivity_construct in safe_get(connection_group, ["connectivity-construct"]): - connectivity_construct_id = safe_get(connectivity_construct, ['id']) - construct_id = f"{group_id}-{connectivity_construct_id}" - logging.debug(f"Construct ID: {construct_id}") + # Process connection groups + for connection_group in safe_get(slice_service, ["connection-groups", "connection-group"]): + connection_group_id = safe_get(connection_group, ['id']) + group_id = f"{service_id}-{connection_group_id}" + logging.debug(f"Group ID: {group_id}") + + # Start with service-level template for this group + template = service_template # Reset template for each connection group - # Start with group-level template for this construct - final_template = template # Reset template for each connectivity construct + # Override template if specified at group level + group_template = get_service_template(connection_group, available_templates) + if group_template is not None: + template = group_template + logging.debug(f"Group Template: {template}") - # Override template if specified at construct level - construct_template = get_service_template(connectivity_construct, available_templates) - if construct_template is not None: - final_template = construct_template - logging.debug(f"Final Template: {final_template}") + connectivity_type = safe_get(connection_group, ['connectivity-type']) + logging.debug(f"Connectivity Type: {connectivity_type}") - # Process SDPs based on connectivity type - sdps = process_connectivity( - connection_group_id, - connectivity_type, - connectivity_construct, - connectivity_construct_id, - slice_service - ) - logging.debug(f"SDPs: {sdps}") - if sdps: # Only append if SDPs were found - service = { - "id": construct_id, - "template": final_template, - "connectivity_type": connectivity_type, - "sdps": sdps, - "way": way - } - services.append(service) - logging.debug(f"Service added: {service}") + # Process connectivity constructs + for connectivity_construct in safe_get(connection_group, ["connectivity-construct"]): + connectivity_construct_id = safe_get(connectivity_construct, ['id']) + construct_id = f"{group_id}-{connectivity_construct_id}" + logging.debug(f"Construct ID: {construct_id}") + + # Start with group-level template for this construct + final_template = template # Reset template for each connectivity construct + + # Override template if specified at construct level + construct_template = get_service_template(connectivity_construct, available_templates) + if construct_template is not None: + final_template = construct_template + logging.debug(f"Final Template: {final_template}") - # Break only for point-to-point - if connectivity_type == "point-to-point": - break + # Process SDPs based on connectivity type + sdps = process_connectivity( + connection_group_id, + connectivity_type, + connectivity_construct, + connectivity_construct_id, + slice_service + ) + logging.debug(f"SDPs: {sdps}") + if sdps: # Only append if SDPs were found + service = { + "id": construct_id, + "template": final_template, + "connectivity_type": connectivity_type, + "sdps": sdps, + "way": way + } + services.append(service) + logging.debug(f"Service added: {service}") + + # Break only for point-to-point + if connectivity_type == "point-to-point": + break return services, optimal_path \ No newline at end of file diff --git a/src/realizer/main.py b/src/realizer/main.py index 204be88..2e22403 100644 --- a/src/realizer/main.py +++ b/src/realizer/main.py @@ -77,7 +77,7 @@ def realizer(service, need_nrp=False, order=None, nrp=None, controller_type=None return None way = selected_way else: - way = service["way"] + way = service.get("way", None) or safe_get(service, ['ietf-network-slice-service:network-slice-services', 'slice-service', 0, 'service-tags', 'tag-type', 0, 'tag-type-value', 0]) logging.info(f"Selected way: {way}") request = select_way(controller=controller_type, way=way, ietf_intent=service, response=response, rules = rules) return request diff --git a/src/realizer/tfs/service_types/tfs_l2vpn.py b/src/realizer/tfs/service_types/tfs_l2vpn.py index 6f3933c..a70fcf8 100644 --- a/src/realizer/tfs/service_types/tfs_l2vpn.py +++ b/src/realizer/tfs/service_types/tfs_l2vpn.py @@ -43,6 +43,7 @@ def tfs_l2vpn(ietf_intent, response): """ # Hardcoded router endpoints # TODO (should be dynamically determined) + logging.info(ietf_intent) origin_router_id = safe_get(ietf_intent, ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "sdps", "sdp", 0, "attachment-circuits", "attachment-circuit", 0, "sdp-peering", "peer-sap-id"]) if not origin_router_id: logging.warning("Origin router ID not found in the intent. Skipping L2VPN realization.") diff --git a/src/tests/test_mapper.py b/src/tests/test_mapper.py index 219923f..076caab 100644 --- a/src/tests/test_mapper.py +++ b/src/tests/test_mapper.py @@ -317,7 +317,7 @@ class TestMapper: result = mapper(sample_ietf_intent) - assert result is None + assert result == ([sample_ietf_intent], None) @patch('src.mapper.main.Planner') def test_mapper_with_planner_enabled(self, mock_planner_class, app_context, sample_ietf_intent): @@ -334,7 +334,7 @@ class TestMapper: result = mapper(sample_ietf_intent) - assert result == {"path": "node1->node2->node3"} + assert result == ([sample_ietf_intent], {"path": "node1->node2->node3"}) mock_planner_instance.planner.assert_called_once_with(sample_ietf_intent, "ENERGY") @patch('src.mapper.main.realizer') @@ -351,7 +351,7 @@ class TestMapper: # Verify realizer was called to READ NRP view assert mock_realizer.call_args_list[0] == call(None, True, "READ") - assert result is None + assert result == ([sample_ietf_intent], None) @patch('src.mapper.main.realizer') def test_mapper_with_nrp_enabled_no_viable_candidates(self, mock_realizer, app_context, sample_ietf_intent): @@ -380,7 +380,7 @@ class TestMapper: result = mapper(sample_ietf_intent) - assert result is None + assert result == ([sample_ietf_intent], None) @patch('src.mapper.main.realizer') def test_mapper_with_nrp_enabled_creates_new_nrp(self, mock_realizer, app_context, sample_ietf_intent): @@ -420,7 +420,7 @@ class TestMapper: result = mapper(sample_ietf_intent) # Planner should be called and return the result - assert result == {"path": "optimized_path"} + assert result == ([sample_ietf_intent],{"path": "optimized_path"}) @patch('src.mapper.main.realizer') def test_mapper_updates_best_nrp_with_slice(self, mock_realizer, app_context, sample_ietf_intent, sample_nrp_view): @@ -512,7 +512,7 @@ class TestMapperIntegration: result = mapper(sample_ietf_intent) - assert result == expected_path + assert result == ([sample_ietf_intent],expected_path) mock_planner_instance.planner.assert_called_once() def test_mapper_with_invalid_nrp_response(self, app_context, sample_ietf_intent): diff --git a/src/tests/test_nbi_processor.py b/src/tests/test_nbi_processor.py index 0a28bb8..0e0c8cd 100644 --- a/src/tests/test_nbi_processor.py +++ b/src/tests/test_nbi_processor.py @@ -93,8 +93,8 @@ def fake_template(): "description": "", "slo-sle-template": "", "sdps": {"sdp": [ - {"service-match-criteria": {"match-criterion": [{}]}, "attachment-circuits": {"attachment-circuit": [{"sdp-peering": {}}]}}, - {"service-match-criteria": {"match-criterion": [{}]}, "attachment-circuits": {"attachment-circuit": [{"sdp-peering": {}}]}} + {"service-match-criteria": {"match-criterion": [{"match-type":[{"type":""}]}]}, "attachment-circuits": {"attachment-circuit": [{"sdp-peering": {}}]}}, + {"service-match-criteria": {"match-criterion": [{"match-type":[{"type":""}]}]}, "attachment-circuits": {"attachment-circuit": [{"sdp-peering": {}}]}} ]}, "connection-groups": {"connection-group": [{}]}, } diff --git a/src/tests/test_utils.py b/src/tests/test_utils.py index 74d0947..f79526d 100644 --- a/src/tests/test_utils.py +++ b/src/tests/test_utils.py @@ -164,7 +164,7 @@ def test_build_response_ok(): assert slice_data["id"] == "slice-test-1" assert slice_data["source"] == "CU" assert slice_data["destination"] == "DU" - assert slice_data["vlan"] == "100" + assert slice_data["vlan"] == 100 # Validar constraints requirements = slice_data["requirements"] -- GitLab