diff --git a/src/config/.env.example b/src/config/.env.example index 3730fb786f1b523cd179652a96f821ce88eea36d..31b1f1d146b5af51298be9ea93a725531a9cf9c1 100644 --- a/src/config/.env.example +++ b/src/config/.env.example @@ -63,6 +63,13 @@ 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 + # ------------------------- # WebUI # ------------------------- diff --git a/src/config/config.py b/src/config/config.py index 04b72aae571b67b8e490a01267c4af5aac4e12d9..4955a5191e3266d1ef8f39d823cc1627b501fc65 100644 --- a/src/config/config.py +++ b/src/config/config.py @@ -64,4 +64,8 @@ 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") + return app diff --git a/src/database/sysrepo_store.py b/src/database/sysrepo_store.py index b6098d3a5d03e998d50c9b87b06c3c1940ee7c4f..63c80ba00e38dc553e19ecf7ca007aa861ded853 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 4ef517d17df077bbfdbc9992ac0a90c3820dfdf2..55b3fca8cf70bd92cd44b9d347777cd460688ed3 100644 --- a/src/main.py +++ b/src/main.py @@ -95,16 +95,19 @@ class NSController: ietf_intents = nbi_processor(intent_json) for intent in ietf_intents: + logging.info(intent) # Mapper - rules = mapper(intent) + 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 - 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 0000000000000000000000000000000000000000..caad0a38cd7ffd936f23a2aee55a5d68e7086fee --- /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 0000000000000000000000000000000000000000..a72e642ba6da564fd69867c7ae6e61bfbd5a7481 --- /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 0000000000000000000000000000000000000000..07c46ffc7f6417cd3425a8f1169b6a3ee533c265 --- /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 9e0a4c7aefbd535b692d30c24a925ffee74b9b9d..1732419c1aca3181b5ba0c35551a3c6c79608123 100644 --- a/src/mapper/main.py +++ b/src/mapper/main.py @@ -16,11 +16,15 @@ 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): +def mapper(ietf_intent, controller_type="TFS"): """ Map an IETF network slice intent to the most suitable Network Resource Partition (NRP). @@ -38,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") @@ -73,5 +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 - return None \ No newline at end of file + + 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 [] + + # 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 0000000000000000000000000000000000000000..e691d0640865f3f1ae92bada7093d38be7eb17f9 --- /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 eb6908af7e82484e09c518b837d53dba11847244..2e2240302cede734eee087b497fbf21ee1ba6907 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.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=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 0000000000000000000000000000000000000000..48120069234d6e04f722dfa48443a0ace2051b3c --- /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/tfs_connector.py b/src/realizer/restconf/connectors/tfs_connector.py new file mode 100644 index 0000000000000000000000000000000000000000..91c64f49e6c6291ddcd69978115ec6e3a6f98c9e --- /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 0000000000000000000000000000000000000000..cf415d35d3692a2fc0ad501fb12fbd3bd8980040 --- /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 0000000000000000000000000000000000000000..6aa431dbf6d7298c247e0febfd1d3e3819470574 --- /dev/null +++ b/src/realizer/restconf/restconf_connect.py @@ -0,0 +1,36 @@ +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 + +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 + + 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 0000000000000000000000000000000000000000..60354fafb782d0c39b3a086f2b0180239e73f7c8 --- /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 0000000000000000000000000000000000000000..44b0f1f0d663cf5cb2e0700237efefdadad1dc2f --- /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 0000000000000000000000000000000000000000..2a674f56dd3ebb078ddec703b51478d4101b89c8 --- /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 0000000000000000000000000000000000000000..6e1a790a336bb34e41b7a12d33f8462c9585ce35 --- /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 0000000000000000000000000000000000000000..c53b102e6a4be6c4d581683d7dbc9cecbfa8effb --- /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 0000000000000000000000000000000000000000..cf7866dbf5a2a9ae987aff033b8ead5c93a77f4b --- /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 0000000000000000000000000000000000000000..34fa537a8af0cd6be62d37c9b2bd35c6940e1d4a --- /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 0000000000000000000000000000000000000000..7ffd5604dee13af5e79f8be5500a69441aae7201 --- /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 6d3cc531b03ef75def47eaadd864ef51c503d900..016f9ee15e64dbf6bc05bd74a7e09165aa98ad21 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 334de49bdb472d898c968aa0125190a396cf733d..ee757fa2d71b382506930a5fac3a25907a11d7fc 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/realizer/tfs/service_types/tfs_l2vpn.py b/src/realizer/tfs/service_types/tfs_l2vpn.py index 6f3933c422c188054aeec25d640541348bde5fdd..a70fcf89c8b979e4274788a1db30a4d81983f83c 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 219923f0bf70e6920398e945a14205dd913baab9..076caabade7bf880be8d0f7cee58ee5fd272e8eb 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 0a28bb8a469ecce2bacd0e3d57255964cad4e7ca..0e0c8cd44e9b7e0148a586cd3d1be3c8c1c8cac8 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 74d094765ec06d081a8eb0cf62a2e6f3812829a0..f79526d396b17d18d61c9261a74099e14c6033f5 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"]