From 9ba66eb4236936fd9b37aac9e6129b32a6a57ffb Mon Sep 17 00:00:00 2001 From: velazquez Date: Wed, 19 Aug 2026 11:27:58 +0200 Subject: [PATCH 1/7] - Add change scheduler planner and flags for IP and Port - Add reconifg slice route in swagger namespace - Add reconfig_slice in api main.py - Add reconfig_slice in main.py - Add reconfig action in Realizer - Improve tests to cover new features - Correct bugs --- .gitignore | 1 + src/api/main.py | 2511 +++++++++-------- src/config/config.py | 2 + src/main.py | 74 +- src/mapper/main.py | 2 + src/mapper/process_connnectivity.py | 4 +- src/nbi_processor/detect_format.py | 2 +- .../change_scheduler.py | 318 +++ src/planner/planner.py | 30 +- src/realizer/main.py | 30 +- src/tests/test_planner.py | 786 ++++-- swagger/restconf_namespace.py | 37 +- 12 files changed, 2237 insertions(+), 1560 deletions(-) create mode 100644 src/planner/change_scheduler_planner/change_scheduler.py diff --git a/.gitignore b/.gitignore index 7f02922..2987aa2 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ service.db telemetry_client.db alert.db .python-version +.agents/ diff --git a/src/api/main.py b/src/api/main.py index d922369..e045333 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -1,1246 +1,1265 @@ -# Copyright 2022-2026 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. - -from src.utils.send_response import send_response -import logging, json, asyncio -from flask import current_app -from src.database.db import get_data, delete_data, get_all_data, delete_all_data -from src.database.service_db import delete_by_slice_id, get_data_by_slice_id -from src.database.telemetry_client_db import create_client, get_client, get_all_clients, delete_client, delete_all_clients, upsert_subscription, get_subscription, get_client_subscriptions, delete_subscription, delete_all_subscriptions -import src.database.alert_db as alert_db -from src.realizer.tfs.helpers.tfs_connector import tfs_connector -from src.utils.safe_get import safe_get -from src.database.sysrepo_store import get_data_store, create_data_store, delete_data_store, update_data_store, normalize_libyang_data -from typing import Dict, Tuple -from src.realizer.restconf.connectors.tfs_connector import tfs_connector as tfs_restconf_connector -from src.realizer.tfs.service_types.tfs_l2vpn import tfs_l2vpn_delete - - - -class Api: - - def __init__(self, slice_service): - self.slice_service = slice_service - - def add_flow(self, intent): - """ - Create a new transport network slice. - - Args: - intent (dict): Network slice intent in 3GPP or IETF format - - Returns: - Result of the Network Slice Controller (NSC) operation - - API Endpoint: - POST /slice - - Raises: - RuntimeError: If there is no content to process - Exception: For unexpected errors - """ - try: - result = self.slice_service.nsc(intent) - if not result: - return send_response(False, code=404, message="No intents found") - if isinstance(result, tuple): - return result - logging.info(f"Slice created successfully") - return send_response( - True, - code=201, - data=result - ) - except RuntimeError as e: - # Handle case where there is no content to process - return send_response(False, code=200, message=str(e)) - except Exception as e: - # Handle unexpected errors - return send_response(False, code=500, message=str(e)) - - def receive_alert(self, alert_data): - """ - Receive and process an alert. - - Args: - alert_data (dict): The alert payload - - Returns: - Result of the operation - """ - try: - logging.info(f"Alert received: {alert_data}") - # Extract uuid if exists - context = alert_data.get("tapi-notification:notification-context", []) - alert_id = None - service_id = None - subscription_id = None - if context and isinstance(context, list): - notification = context[0].get("tapi-notification:notification", {}) - alert_id = notification.get("uuid") - subscription_id = alert_id - additional_info = notification.get("additional-info", {}) - service_id = additional_info.get("service-id") - - if not alert_id: - return send_response(False, code=400, message="UUID not found in alert data") - - # Save alert to DB - alert_db.save_alert(alert_id, alert_data) - - # Process intent modification based on alert - slice_id = None - slice_info = None - - logging.info(f"Looking up intent for subscription_id: {subscription_id}, service_id: {service_id}") - - if subscription_id: - try: - import src.database.db as db - mapped_slice_id = db.get_slice_id_by_subscription(subscription_id) - if mapped_slice_id: - slice_id = mapped_slice_id - logging.info(f"Found slice_id {slice_id} mapped to subscription_id {subscription_id}") - except Exception as e: - logging.info(f"Subscription mapping lookup failed: {e}") - - if not slice_id and service_id: - # 1. Try to find slice_id from service_db - try: - import src.database.service_db as service_db - service_info = service_db.get_data(service_id) - slice_id = service_info.get("slice_id") - logging.info(f"Found slice_id {slice_id} in service_db for service_id {service_id}") - except Exception as e: - logging.info(f"service_db lookup failed: {e}") - slice_id = service_id - - if slice_id: - # 2. Try to get slice data from db - try: - import src.database.db as db - slice_info = db.get_data(slice_id) - logging.info(f"Found slice_info in db by slice_id {slice_id}") - except Exception as e: - logging.info(f"db lookup by slice_id {slice_id} failed: {e}") - - # Fallback: if not found by ID, look up any existing slice in the DB - if not slice_info: - try: - import src.database.db as db - slices = db.get_all_data() - logging.info(f"Slices in db: {[s.get('slice_id') for s in slices]}") - for s in slices: - if s.get("slice_id") == slice_id: - slice_info = s - break - if not slice_info and slices: - # Default to the first/only slice if there's any - slice_info = slices[0] - logging.info(f"Defaulted to first slice from db: {slice_info.get('slice_id')}") - except Exception as e: - logging.info(f"db get_all_data lookup failed: {e}") - - # Fallback: read from /home/llmserver/tfs-nsc/intent.json if DB is empty - if not slice_info: - import os - import json - fallback_path = "/home/llmserver/tfs-nsc/intent.json" - if os.path.exists(fallback_path): - try: - with open(fallback_path, "r") as f: - intent_data = json.load(f) - slice_info = {"slice_id": slice_id or "slice", "intent": intent_data} - logging.info("Loaded fallback intent from intent.json") - except Exception as e: - logging.error(f"Failed to read fallback intent.json: {e}") - - if slice_info: - intent = slice_info.get("intent") - curr_slice_id = slice_info.get("slice_id") - logging.info(f"Processing intent for slice {curr_slice_id}") - - if intent: - nss = intent.get("ietf-network-slice-service:network-slice-services", {}) - slice_services = nss.get("slice-service", []) - modified = False - - for service in slice_services: - # Get all SDP IDs in this slice service - sdp_list = service.get("sdps", {}).get("sdp", []) - sdp_ids = [sdp.get("id") for sdp in sdp_list if sdp.get("id")] - - connection_groups = service.get("connection-groups", {}).get("connection-group", []) - for cg in connection_groups: - connectivity_constructs = cg.get("connectivity-construct", []) - for cc in connectivity_constructs: - p2mp_sender = cc.get("p2mp-sender-sdp") - p2mp_receivers = cc.get("p2mp-receiver-sdp", []) - - logging.info(f"sdp_ids: {sdp_ids}, p2mp_sender: {p2mp_sender}, p2mp_receivers: {p2mp_receivers}") - - # Find alternative receiver endpoints - other_endpoints = [ - sdp_id for sdp_id in sdp_ids - if sdp_id != p2mp_sender and sdp_id not in p2mp_receivers - ] - - old_receiver = None - new_receiver = None - - if other_endpoints and p2mp_receivers: - if len(p2mp_receivers) >= 2: - old_receiver = p2mp_receivers[1] - new_receiver = other_endpoints[0] - cc["p2mp-receiver-sdp"] = [p2mp_receivers[0], new_receiver] - else: - old_receiver = p2mp_receivers[0] - new_receiver = other_endpoints[0] - cc["p2mp-receiver-sdp"] = [new_receiver] - modified = True - - # Log ORIGEN and DESTINO - logging.info(f"ORIGEN: {p2mp_sender} DESTINO: {new_receiver}") - print(f"ORIGEN: {p2mp_sender} DESTINO: {new_receiver}", flush=True) - else: - logging.warning("No alternative receiver endpoints found to swap.") - - if p2mp_receivers: - old_service_id = f"{p2mp_sender}_to_{','.join(p2mp_receivers)}" - else: - old_service_id = None - - if modified: - # Re-apply the modified intent via slice_service.nsc - try: - self.slice_service.nsc(intent, curr_slice_id, old_service_id=old_service_id) - logging.info(f"Slice {curr_slice_id} updated successfully following alert.") - except Exception as e: - logging.error(f"Failed to update slice configuration: {e}") - else: - logging.warning("No slice intent found to process alert.") - - return send_response( - True, - code=201, - message="Alert processed and saved successfully", - data=alert_data - ) - except Exception as e: - return send_response(False, code=500, message=str(e)) - - def get_alerts(self, alert_id=None): - """ - Retrieve alert(s). - """ - try: - if alert_id: - try: - data = alert_db.get_alert(alert_id) - return data, 200 - except ValueError as e: - return send_response(False, code=404, message=str(e)) - else: - data = alert_db.get_all_alerts() - return data, 200 - except Exception as e: - return send_response(False, code=500, message=str(e)) - - def modify_alert(self, alert_id, alert_data): - """ - Modify/update an alert. - """ - try: - try: - alert_db.update_alert(alert_id, alert_data) - return send_response( - True, - code=200, - message="Alert updated successfully", - data=alert_data - ) - except ValueError as e: - return send_response(False, code=404, message=str(e)) - except Exception as e: - return send_response(False, code=500, message=str(e)) - - def delete_alerts(self, alert_id=None): - """ - Delete alert(s). - """ - try: - if alert_id: - try: - alert_db.delete_alert(alert_id) - return {}, 204 - except ValueError as e: - return send_response(False, code=404, message=str(e)) - else: - alert_db.delete_all_alerts() - return {}, 204 - except Exception as e: - return send_response(False, code=500, message=str(e)) - - def get_flows(self,slice_id=None): - """ - Retrieve transport network slice information. - - This method allows retrieving: - - All transport network slices - - A specific slice by its ID - - Args: - slice_id (str, optional): Unique identifier of a specific slice. - Defaults to None. - - Returns: - dict or list: - - If slice_id is provided: Returns the specific slice details - - If slice_id is None: Returns a list of all slices - - Returns an error response if no slices are found - - API Endpoint: - GET /slice/{id} - - Raises: - ValueError: If no transport network slices are found - Exception: For unexpected errors - """ - try: - # Read slice database from JSON file - content = get_all_data() - # If specific slice ID is provided, find and return matching slice - if slice_id: - for slice in content: - if slice["slice_id"] == slice_id: - return slice, 200 - raise ValueError("Transport network slices not found") - # If no slices exist, raise an error - if len(content) == 0: - raise ValueError("Transport network slices not found") - - # Return all slices if no specific ID is given - return [slice for slice in content if slice.get("controller") == self.slice_service.controller_type], 200 - - except ValueError as e: - # Handle case where no slices are found - return send_response(False, code=404, message=str(e)) - except Exception as e: - # Handle unexpected errors - return send_response(False, code=500, message=str(e)) - - def modify_flow(self,slice_id, intent): - """ - Modify an existing transport network slice. - - Args: - slice_id (str): Unique identifier of the slice to modify - intent (dict): New intent configuration for the slice - - Returns: - Result of the Network Slice Controller (NSC) operation - - API Endpoint: - PUT /slice/{id} - Raises: - Exception: For unexpected errors - """ - try: - result = self.slice_service.nsc(intent, slice_id) - if isinstance(result, tuple): - return result - if not result: - return send_response(False, code=404, message="Slice not found") - logging.info(f"Slice {slice_id} modified successfully") - return send_response( - True, - code=200, - message="Slice modified successfully", - data=result - ) - except ValueError as e: - # Handle case where no slices are found - return send_response(False, code=404, message=str(e)) - except Exception as e: - # Handle unexpected errors - return send_response(False, code=500, message=str(e)) - - def delete_flows(self, slice_id=None): - """ - Delete transport network slice(s). - - This method supports: - - Deleting a specific slice by ID - - Deleting all slices - - Optional cleanup of L2VPN configurations - - Args: - slice_id (str, optional): Unique identifier of slice to delete. - Defaults to None. - - Returns: - dict: {} indicating successful deletion or error details - - API Endpoint: - DELETE /slice/{id} - - Raises: - ValueError: If no slices are found to delete - Exception: For unexpected errors - - Notes: - - If controller_type is TFS, attempts to delete from Teraflow - - If need_l2vpn_support is True, performs additional L2VPN cleanup - """ - try: - # Delete specific slice if slice_id is provided - if slice_id: - slice = get_data(slice_id) - # Raise error if slice not found - if not slice or slice.get("controller") != self.slice_service.controller_type: - raise ValueError("Transport network slice not found") - # Delete in Teraflow - if not current_app.config["DUMMY_MODE"]: - if self.slice_service.controller_type == "TFS": - slice_type = safe_get(slice, ['intent', 'ietf-network-slice-service:network-slice-services', 'slice-service', 0, 'service-tags', 'tag-type', 0, 'tag-type-value', 0]) - if not slice_type: - slice_type = "L2" - logging.warning(f"Slice type not found in slice intent. Defaulting to L2") - tfs_connector().nbi_delete(current_app.config["TFS_IP"],slice_type, slice_id) - # Update slice database - delete_data(slice_id) - logging.info(f"Slice {slice_id} removed successfully") - return {}, 204 - - # Delete all slices - else: - # Optional: Delete in Teraflow if configured - if not current_app.config["DUMMY_MODE"]: - if self.slice_service.controller_type == "TFS": - content = get_all_data() - for slice in content: - if slice.get("controller") == self.slice_service.controller_type: - slice_type = safe_get(slice, ['intent', 'ietf-network-slice-service:network-slice-services', 'slice-service', 0, 'service-tags', 'tag-type', 0, 'tag-type-value', 0]) - if not slice_type: - slice_type = "L2" - logging.warning(f"Slice type not found in slice intent. Defaulting to L2") - tfs_connector().nbi_delete(current_app.config["TFS_IP"],slice_type, slice.get("slice_id")) - if current_app.config["TFS_L2VPN_SUPPORT"]: - tfs_l2vpn_delete() - - # Clear slice database - delete_all_data() - - logging.info("All slices removed successfully") - return {}, 204 - - except ValueError as e: - return send_response(False, code=404, message=str(e)) - except Exception as e: - return send_response(False, code=500, message=str(e)) - - # RESCONF calls - - ### GET - - def get_network_slice_services(self): - try: - data = get_data_store("/ietf-network-slice-service:network-slice-services") - if not data: - raise ValueError("Nothing found") - return data, 200 - except ValueError as e: - return send_response(False, code=404, message=str(e)) - except Exception as e: - return send_response(False, code=500, message=str(e)) - - def get_slo_sle_templates(self, template_id=None): - try: - if template_id: - xpath = f"/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template[id='{template_id}']" - data = get_data_store(xpath) - if not data: - raise ValueError("Template not found") - return data, 200 - - data = get_data_store("/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template") - if not data: - raise ValueError("No templates found") - - return data, 200 - - except ValueError as e: - # Handle case where no slices are found - return send_response(False, code=404, message=str(e)) - except Exception as e: - # Handle unexpected errors - return send_response(False, code=500, message=str(e)) - - def get_slice_services(self, slice_id=None): - try: - if slice_id: - data = get_data_store(f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']") - if not data: - raise ValueError("Slice not found") - return data, 200 - - data = get_data_store("/ietf-network-slice-service:network-slice-services/slice-service") - if not data: - raise ValueError("No slices found") - - return data, 200 - - except ValueError as e: - # Handle case where no slices are found - return send_response(False, code=404, message=str(e)) - except Exception as e: - # Handle unexpected errors - return send_response(False, code=500, message=str(e)) - - def get_slice_topology(self, slice_id): - """ - Retrieve Network Slice Topology for a given slice_id according to draft-ietf-teas-network-slice-topology-yang-04. - """ - try: - tfs_ip = current_app.config.get("RESTCONF_IP", "127.0.0.1") - connector = tfs_restconf_connector() - return connector.get_slice_topology(tfs_ip, slice_id) - except Exception as e: - logging.exception(f"Error retrieving slice topology for slice '{slice_id}': {e}") - return send_response(False, code=500, message=str(e)) - - def get_sdps(self, slice_id, sdp_id=None): - try: - if sdp_id: - data = get_data_store(f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']/sdps/sdp[id='{sdp_id}']") - if not data: - raise ValueError("SDP not found") - return data, 200 - - data = get_data_store(f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']/sdps") - if not data: - raise ValueError("No SDPs found") - - return data, 200 - - except ValueError as e: - # Handle case where no slices are found - return send_response(False, code=404, message=str(e)) - except Exception as e: - # Handle unexpected errors - return send_response(False, code=500, message=str(e)) - - ### POST - - def add_network_slice_service(self, intent): - try: - result = self.slice_service.nsc(intent) - if isinstance(result, tuple): - return result - if result: - try: - create_data_store(intent) - except Exception as ds_err: - logging.warning(f"Could not store intent in sysrepo datastore: {ds_err}") - logging.info(f"Network Slice created successfully") - return send_response( - True, - code=201, - message="Network Slice created successfully", - data=result - ) - except RuntimeError as e: - # Handle case where there is no content to process - return send_response(False, code=200, message=str(e)) - except Exception as e: - # Handle unexpected errors - return send_response(False, code=500, message=str(e)) - - def add_slo_sle_template(self, template): - try: - template_id = template.pop("id", None) - xpath = f"/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template[id='{template_id}']" - existing_template = get_data_store(xpath) - if existing_template: - return send_response(False, code=409, message="Template already exists") - create_data_store(template, xpath) - logging.info(f"Template created successfully") - return send_response( - True, - code=201, - message="Template created successfully" - ) - except Exception as e: - # Handle unexpected errors - return send_response(False, code=500, message=str(e)) - - def add_slice_service(self, intent): - try: - xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{intent.get('id')}']" - existing_slice = get_data_store(xpath) - if existing_slice: - return send_response(False, code=409, message="Slice already exists") - - if "slo-sle-template" in intent: - template_ref = intent.get("slo-sle-template") - xpath_template = f"/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template[id='{template_ref}']" - existing_template = get_data_store(xpath_template) - if not existing_template: - return send_response(False, code=404, message="Referenced SLO/SLE template not found") - full_intent = { - "ietf-network-slice-service:network-slice-services": { - "slo-sle-templates": { - "slo-sle-template": [existing_template["network-slice-services"]["slo-sle-templates"]["slo-sle-template"][template_ref]] - }, - "slice-service": [intent] - } - } - elif "service-slo-sle-policy" in intent: - full_intent = { - "ietf-network-slice-service:network-slice-services": { - "slo-sle-templates": { - "slo-sle-template": intent.get("service-slo-sle-policy") - }, - "slice-service": [intent] - } - } - else: - return send_response(False, code=400, message="No SLO/SLE template or policy provided in intent") - - full_intent = normalize_libyang_data(full_intent) - result = self.slice_service.nsc(full_intent) - if result: - intent.pop("id", None) - create_data_store(intent, xpath) - logging.info(f"Slice created successfully") - return send_response( - True, - code=201, - message="Slice created successfully", - data=result - ) - except RuntimeError as e: - # Handle case where there is no content to process - return send_response(False, code=200, message=str(e)) - except Exception as e: - # Handle unexpected errors - return send_response(False, code=500, message=str(e)) - - def add_sdp(self, slice_id, sdp): - try: - sdp_id = sdp.pop("id", None) - xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']/sdps/sdp[id='{sdp_id}']" - existing_sdp = get_data_store(xpath) - if existing_sdp: - return send_response(False, code=409, message="SDP already exists") - create_data_store(sdp, xpath) - logging.info(f"SDP created successfully") - return send_response( - True, - code=201, - message="SDP created successfully" - ) - except Exception as e: - # Handle unexpected errors - return send_response(False, code=500, message=str(e)) - - ### PUT - - def update_network_slice_service(self, intent): - """ - Modify (replace) all network-slice-services configuration - """ - try: - xpath = "/ietf-network-slice-service:network-slice-services" - - # Verify if there is something to modify - existing_data = get_data_store(xpath) - if not existing_data: - return send_response(False, code=404, message="Network slice services not found") - - # If not in DUMMY mode, process with TFS - result = self.slice_service.nsc(intent) - if not result: - return send_response(False, code=500, message="Failed to process slice in TFS") - - # Replace completely the resource - update_data_store(intent) - logging.info("Network slice services modified successfully") - - return send_response( - True, - code=200, - message="Network slice services updated successfully", - data=result - ) - - except ValueError as e: - return send_response(False, code=404, message=str(e)) - except Exception as e: - return send_response(False, code=500, message=str(e)) - - def update_slo_sle_template(self, template_id, template): - """ - Modify (replace) an specific SLO/SLE template - """ - try: - xpath = f"/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template[id='{template_id}']" - - # Verify the template exists - existing_template = get_data_store(xpath) - if not existing_template: - return send_response(False, code=404, message="Template not found") - - # Assure that the body ID matches the URL - if "id" in template and template["id"] != template_id: - return send_response(False, code=400, message="Template ID in body does not match URL") - - slices = get_data_store("/ietf-network-slice-service:network-slice-services/slice-service") - - for slice in slices["network-slice-services"]["slice-service"]: - if "slo-sle-template" in slice: - if slice.get("slo-sle-template") == template_id: - full_intent = { - "ietf-network-slice-service:network-slice-services": { - "slo-sle-templates": { - "slo-sle-template": [existing_template["network-slice-services"]["slo-sle-templates"]["slo-sle-template"][template_id]] - }, - "slice-service": [slice] - } - } - full_intent = normalize_libyang_data(full_intent) - result = self.slice_service.nsc(full_intent, slice.get("id")) - if not result: - return send_response(False, code=500, message="Slice not updated") - - # Remove the ID from the body if it exists (it's already in the predicate) - template_data = template.copy() - template_data.pop("id", None) - - # Replace the template - update_data_store(template_data, xpath) - logging.info(f"Template {template_id} modified successfully") - - return send_response( - True, - code=200, - message="Template updated successfully", - data=result - ) - - except ValueError as e: - return send_response(False, code=404, message=str(e)) - except Exception as e: - return send_response(False, code=500, message=str(e)) - - def update_slice_service(self, slice_id, intent): - """ - Modifica (reemplaza) un slice service específico - """ - try: - xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']" - - # Verify that the slice exists - existing_slice = get_data_store(xpath) - if not existing_slice: - return send_response(False, code=404, message="Slice not found") - - # Assure that the body ID matches the URL - if "id" in intent and intent["id"] != slice_id: - return send_response(False, code=400, message="Slice ID in body does not match URL") - - # Validate that the referenced SLO/SLE template exists - if "slo-sle-template" in intent: - template_ref = intent.get("slo-sle-template") - xpath_template = f"/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template[id='{template_ref}']" - existing_template = get_data_store(xpath_template) - if not existing_template: - return send_response(False, code=404, message="Referenced SLO/SLE template not found") - - # Build the full intent - full_intent = { - "ietf-network-slice-service:network-slice-services": { - "slo-sle-templates": { - "slo-sle-template": [existing_template["network-slice-services"]["slo-sle-templates"]["slo-sle-template"][template_ref]] - }, - "slice-service": [intent] - } - } - elif "service-slo-sle-policy" in intent: - full_intent = { - "ietf-network-slice-service:network-slice-services": { - "slo-sle-templates": { - "slo-sle-template": intent.get("service-slo-sle-policy") - }, - "slice-service": [intent] - } - } - else: - return send_response(False, code=400, message="No SLO/SLE template or policy provided in intent") - - full_intent = normalize_libyang_data(full_intent) - result = self.slice_service.nsc(full_intent) - if not result: - return send_response(False, code=500, message="Slice not updated") - - # Remove the ID from the body (it's already in the predicate) - intent_data = intent.copy() - intent_data.pop("id", None) - - # Replace the slice - update_data_store(intent_data, xpath) - logging.info(f"Slice {slice_id} modified successfully") - - return send_response( - True, - code=200, - message="Slice updated successfully", - data=result - ) - - except ValueError as e: - return send_response(False, code=404, message=str(e)) - except RuntimeError as e: - return send_response(False, code=200, message=str(e)) - except Exception as e: - return send_response(False, code=500, message=str(e)) - - def update_sdp(self, slice_id, sdp_id, sdp): - """ - Modify (replace) an specific SDP in the slice - """ - try: - # Verify the template exists - slice_xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']" - existing_slice = get_data_store(slice_xpath) - if not existing_slice: - return send_response(False, code=404, message="Slice not found") - - # Verify the SDP exists - sdp_xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']/sdps/sdp[id='{sdp_id}']" - existing_sdp = get_data_store(sdp_xpath) - if not existing_sdp: - return send_response(False, code=404, message="SDP not found") - - # Assure that the body ID matches the URL - if "id" in sdp and sdp["id"] != sdp_id: - return send_response(False, code=400, message="SDP ID in body does not match URL") - - # Remove the ID from the body (it's already in the predicate) - sdp_data = sdp.copy() - sdp_data.pop("id", None) - - # Replace the SDP - update_data_store(sdp_data, sdp_xpath) - logging.info(f"SDP {sdp_id} in slice {slice_id} modified successfully") - - return send_response( - True, - code=200, - message="SDP updated successfully" - ) - - except ValueError as e: - return send_response(False, code=404, message=str(e)) - except Exception as e: - return send_response(False, code=500, message=str(e)) - - ### DELETE - - def delete_network_slice_services(self): - try: - xpath = f"/ietf-network-slice-service:network-slice-services" - if not current_app.config["DUMMY_MODE"]: - content = get_data_store(xpath) - slice_services = safe_get(content, ["network-slice-services", "slice-service"]) - if not slice_services: - raise ValueError("Network slice services not found") - for slice in slice_services: - slice_type = list(slice["service-tags"]["tag-type"]["ietf-network-slice-service:service"]["tag-type-value"])[0] - if not slice_type: - slice_type = "L2" - logging.warning(f"Slice type not found in slice intent. Defaulting to L2") - logging.debug(f"Send slice to delete in TFS with slice_type {slice_type}") - services = get_data_by_slice_id(slice.get("id")) - for service in services: - id = service.get("service_id") - tfs_connector().nbi_delete(current_app.config["RESTCONF_IP"], slice_type, id) - delete_by_slice_id(slice.get("id")) - if current_app.config["TFS_L2VPN_SUPPORT"]: - self.slice_service.tfs_l2vpn_delete() - - delete_data_store(xpath) - logging.info("All slices removed successfully") - - return {}, 204 - - except ValueError as e: - return send_response(False, code=404, message=str(e)) - except Exception as e: - return send_response(False, code=500, message=str(e)) - - def delete_slo_sle_templates(self, template_id=None): - try: - # Delete specific template if template_id is provided - if template_id: - xpath = f"/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template[id='{template_id}']" - existing_template = get_data_store(xpath) - if not existing_template: - raise ValueError("Template not found") - delete_data_store(xpath) - logging.info(f"Template {template_id} removed successfully") - return {}, 204 - - # Delete all templates - else: - xpath = f"/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template" - delete_data_store(xpath) - logging.info("All templates removed successfully") - return {}, 204 - - except ValueError as e: - return send_response(False, code=404, message=str(e)) - except Exception as e: - return send_response(False, code=500, message=str(e)) - - def delete_slice_services(self, slice_id=None): - try: - # Delete specific slice if slice_id is provided - if slice_id: - xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']" - existing_slice = get_data_store(xpath) - if not existing_slice: - raise ValueError("Slice not found") - if not current_app.config["DUMMY_MODE"]: - slice_type = safe_get(existing_slice, ["network-slice-services", "slice-service", slice_id, "service-tags", "tag-type", "ietf-network-slice-service:service", "tag-type-value", 0]) or "L2" - logging.debug(f"Send slice to delete in TFS with slice_type {slice_type}") - services = get_data_by_slice_id(slice_id) - for service in services: - id = service.get("service_id") - tfs_connector().nbi_delete(current_app.config["RESTCONF_IP"], slice_type, id) - delete_by_slice_id(slice_id) - if current_app.config["TFS_L2VPN_SUPPORT"]: - tfs_l2vpn_delete() - - delete_data_store(xpath) - logging.info(f"Slice {slice_id} removed successfully") - return {}, 204 - - # Delete all slices - else: - xpath = f"/ietf-network-slice-service:network-slice-services/slice-service" - if not current_app.config["DUMMY_MODE"]: - content = get_data_store(xpath) - slice_services = safe_get(content, ["network-slice-services", "slice-service"]) - if not slice_services: - raise ValueError("Slice services not found") - for slice in slice_services: - slice_type = safe_get(slice, ["service-tags", "tag-type", "ietf-network-slice-service:service", "tag-type-value", 0]) or "L2" - logging.debug(f"Send slice to delete in TFS with slice_type {slice_type}") - services = get_data_by_slice_id(slice.get("id")) - for service in services: - id = service.get("service_id") - tfs_connector().nbi_delete(current_app.config["RESTCONF_IP"], slice_type, id) - delete_by_slice_id(slice.get("id")) - if current_app.config["TFS_L2VPN_SUPPORT"]: - tfs_l2vpn_delete() - delete_data_store(xpath) - logging.info("All slices removed successfully") - return {}, 204 - - except ValueError as e: - return send_response(False, code=404, message=str(e)) - except Exception as e: - return send_response(False, code=500, message=str(e)) - - def delete_sdps(self, slice_id, sdp_id=None): - try: - # Delete specific SDP if sdp_id is provided - if sdp_id: - xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']" - existing_slice = get_data_store(xpath) - if not existing_slice: - raise ValueError("Slice not found") - xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']/sdps/sdp[id='{sdp_id}']" - existing_sdp = get_data_store(xpath) - if not existing_sdp: - raise ValueError("SDP not found") - delete_data_store(xpath) - logging.info(f"SDP {sdp_id} removed successfully") - return {}, 204 - - # Delete all SDPs - else: - xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']" - existing_slice = get_data_store(xpath) - if not existing_slice: - raise ValueError("Slice not found") - xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']/sdps" - delete_data_store(xpath) - logging.info("All SDPs removed successfully") - return {}, 204 - - except ValueError as e: - return send_response(False, code=404, message=str(e)) - except Exception as e: - return send_response(False, code=500, message=str(e)) - - # --- CLIENTS --- - - def get_clients(self, client_id=None): - try: - if client_id: - return get_client(client_id), 200 - clients = get_all_clients() - if not clients: - raise ValueError("No clients found") - return clients, 200 - - except ValueError as e: - return send_response(False, code=404, message=str(e)) - except Exception as e: - return send_response(False, code=500, message=str(e)) - - def add_client(self, client_id): - try: - create_client(client_id) - logging.info(f"Client '{client_id}' created successfully") - return send_response( - True, - code=201, - message=f"Client '{client_id}' created successfully", - data={"client_id": client_id} - ) - - except ValueError as e: - return send_response(False, code=409, message=str(e)) - except Exception as e: - return send_response(False, code=500, message=str(e)) - - def delete_clients(self, client_id=None): - try: - if client_id: - delete_client(client_id) - logging.info(f"Client '{client_id}' removed successfully") - else: - delete_all_clients() - logging.info("All clients removed successfully") - return {}, 204 - except ValueError as e: - return send_response(False, code=404, message=str(e)) - except Exception as e: - return send_response(False, code=500, message=str(e)) - - # --- SUBSCRIPTIONS --- - - def get_subscriptions(self, client_id, slice_id = None): - try: - if slice_id is not None: - try: - subscription = get_subscription(client_id, slice_id) - except ValueError: - subscription = None - if not subscription: - raise ValueError(f"Client '{client_id}' has no subscription for slice '{slice_id}'") - - telemetry = self.get_telemetry(slice_id) - - return { - **subscription, - "telemetry": telemetry - }, 200 - - subscriptions = get_client_subscriptions(client_id) - - result = [] - - for sub in subscriptions: - slice_id = sub["slice_id"] - - telemetry = self.get_telemetry(slice_id) - - result.append({ - **sub, - "telemetry": telemetry - }) - - return { - "client_id": client_id, - "subscriptions": result - }, 200 - - except ValueError as e: - return send_response(False, code=404, message=str(e)) - except Exception as e: - return send_response(False, code=500, message=str(e)) - - def add_subscription(self, client_id, slice_id, frequency): - try: - try: - subscription = get_subscription(client_id, slice_id) - except ValueError: - subscription = None - if subscription: - raise ValueError(f"Client '{client_id}' already has a subscription for slice '{slice_id}'") - if not frequency: - raise KeyError("Field 'frequency' is required") - - upsert_subscription(client_id, slice_id, frequency) - logging.info(f"Subscription for slice '{slice_id}' and client '{client_id}' created successfully") - return send_response( - True, - code=201, - message="Subscription successfully created", - data={ - "sliceId": slice_id, - "frequency": frequency - } - ) - except KeyError as e: - return send_response(False, code=400, message=str(e)) - except ValueError as e: - return send_response(False, code=404, message=str(e)) - except Exception as e: - return send_response(False, code=500, message=str(e)) - - def update_subscription(self, client_id, slice_id, frequency): - try: - try: - subscription = get_subscription(client_id, slice_id) - except ValueError: - subscription = None - if not subscription: - raise ValueError(f"Client '{client_id}' has no subscription for slice '{slice_id}'") - if not frequency: - raise KeyError("Field 'frequency' is required") - - upsert_subscription(client_id, slice_id, frequency) - logging.info(f"Subscription for slice '{slice_id}' and client '{client_id}' modified successfully") - return send_response( - True, - code=201, - message="Subscription successfully modified", - data={ - "sliceId": slice_id, - "frequency": frequency - } - ) - except KeyError as e: - return send_response(False, code=400, message=str(e)) - except ValueError as e: - return send_response(False, code=404, message=str(e)) - except Exception as e: - return send_response(False, code=500, message=str(e)) - - def delete_subscriptions(self, client_id, slice_id = None): - try: - subscriptions = get_client_subscriptions(client_id) - if slice_id: - if slice_id not in subscriptions: - raise ValueError(f"Client '{client_id}' has no subscription for slice '{slice_id}'") - delete_subscription(client_id, slice_id) - logging.info(f"Subscription for slice '{slice_id}' and client '{client_id}' removed successfully") - return {}, 204 - delete_all_subscriptions(client_id) - logging.info(f"All subscriptions for client '{client_id}' removed successfully") - return {}, 204 - - except ValueError as e: - return send_response(False, code=404, message=str(e)) - except Exception as e: - return send_response(False, code=500, message=str(e)) - - # --- TELEMETRY --- - - def get_telemetry(self, slice_id = None): - logging.debug(f"Getting telemetry for slice_id: {slice_id}") - try: - if slice_id is not None: - xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']" - existing_slice = get_data_store(xpath) - if not existing_slice: - raise ValueError(f"There is no slice with id '{slice_id}' registered") - template_id = safe_get(existing_slice, ["network-slice-services", "slice-service", slice_id, "slo-sle-template"]) - slo_sle_template = self.get_slo_sle_templates(template_id)[0] - slo_sle_template = safe_get(slo_sle_template, ["network-slice-services", "slo-sle-templates", "slo-sle-template"]) - slo_sle_template = next(iter(slo_sle_template), None) - if not slo_sle_template: - raise ValueError(f"SLO/SLE template '{template_id}' not found for slice '{slice_id}'") - metrics = self.slice_service.monitoring(slice_id, slo_sle_template) - return metrics, 200 - - telemetry_data = {} - slices_data = self.get_slice_services()[0] - slice_service_list = slices_data["network-slice-services"]["slice-service"] - if isinstance(slice_service_list, dict): - slice_service_list = list(slice_service_list.values()) - - for slice in slice_service_list: - selected_template_id = slice.get("slo-sle-template") - slo_sle_template = self.get_slo_sle_templates(selected_template_id)[0] - slo_sle_template = safe_get(slo_sle_template, ["network-slice-services", "slo-sle-templates", "slo-sle-template"]) - slo_sle_template = next(iter(slo_sle_template), None) - if not slo_sle_template: - raise ValueError(f"SLO/SLE template '{selected_template_id}' not found for slice '{slice['id']}'") - slice_id = slice["id"] - telemetry_data[slice_id] = self.slice_service.monitoring(slice_id, slo_sle_template) - return telemetry_data, 200 - - except ValueError as e: - return send_response(False, code=404, message=str(e)) - except Exception as e: - return send_response(False, code=500, message=str(e)) - - def sync_stream(self, async_gen_func, *args, **kwargs): - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - agen = async_gen_func(*args, **kwargs) - - try: - while True: yield loop.run_until_complete(agen.__anext__()) - except StopAsyncIteration: pass - finally: loop.close() - - async def stream_client_subscriptions(self, client_id): - while True: - try: - data, code = self.get_subscriptions(client_id) - if code == 200: - yield f"data: {json.dumps(data)}\n\n" - subs = data.get("subscriptions", []) - freq = max([s["frequency"] for s in subs]) if subs else 5 - await asyncio.sleep(freq) - else: - yield f"event: error\ndata: {json.dumps(data)}\n\n" - break - except Exception as e: - yield f"event: error\ndata: {json.dumps({'error': str(e)})}\n\n" - break - - async def stream_slice_subscription(self, client_id, slice_id): - while True: - try: - data, code = self.get_subscriptions(client_id, slice_id) - if code == 200: - yield f"data: {json.dumps(data)}\n\n" - freq = data.get("frequency", 5) - await asyncio.sleep(freq) - else: - yield f"event: error\ndata: {json.dumps(data)}\n\n" - break - except Exception as e: - yield f"event: error\ndata: {json.dumps({'error': str(e)})}\n\n" - break \ No newline at end of file +# Copyright 2022-2026 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. + +from src.utils.send_response import send_response +import logging, json, asyncio +from flask import current_app +from src.database.db import get_data, delete_data, get_all_data, delete_all_data +from src.database.service_db import delete_by_slice_id, get_data_by_slice_id +from src.database.telemetry_client_db import create_client, get_client, get_all_clients, delete_client, delete_all_clients, upsert_subscription, get_subscription, get_client_subscriptions, delete_subscription, delete_all_subscriptions +import src.database.alert_db as alert_db +from src.realizer.tfs.helpers.tfs_connector import tfs_connector +from src.utils.safe_get import safe_get +from src.database.sysrepo_store import get_data_store, create_data_store, delete_data_store, update_data_store, normalize_libyang_data +from typing import Dict, Tuple +from src.realizer.restconf.connectors.tfs_connector import tfs_connector as tfs_restconf_connector +from src.realizer.tfs.service_types.tfs_l2vpn import tfs_l2vpn_delete + + + +class Api: + + def __init__(self, slice_service): + self.slice_service = slice_service + + def add_flow(self, intent): + """ + Create a new transport network slice. + + Args: + intent (dict): Network slice intent in 3GPP or IETF format + + Returns: + Result of the Network Slice Controller (NSC) operation + + API Endpoint: + POST /slice + + Raises: + RuntimeError: If there is no content to process + Exception: For unexpected errors + """ + try: + result = self.slice_service.nsc(intent) + if not result: + return send_response(False, code=404, message="No intents found") + if isinstance(result, tuple): + return result + logging.info(f"Slice created successfully") + return send_response( + True, + code=201, + data=result + ) + except RuntimeError as e: + # Handle case where there is no content to process + return send_response(False, code=200, message=str(e)) + except Exception as e: + # Handle unexpected errors + return send_response(False, code=500, message=str(e)) + + def receive_alert(self, alert_data): + """ + Receive and process an alert. + + Args: + alert_data (dict): The alert payload + + Returns: + Result of the operation + """ + try: + logging.info(f"Alert received: {alert_data}") + # Extract uuid if exists + context = alert_data.get("tapi-notification:notification-context", []) + alert_id = None + service_id = None + subscription_id = None + if context and isinstance(context, list): + notification = context[0].get("tapi-notification:notification", {}) + alert_id = notification.get("uuid") + subscription_id = alert_id + additional_info = notification.get("additional-info", {}) + service_id = additional_info.get("service-id") + + if not alert_id: + return send_response(False, code=400, message="UUID not found in alert data") + + # Save alert to DB + alert_db.save_alert(alert_id, alert_data) + + # Process intent modification based on alert + slice_id = None + slice_info = None + + logging.info(f"Looking up intent for subscription_id: {subscription_id}, service_id: {service_id}") + + if subscription_id: + try: + import src.database.db as db + mapped_slice_id = db.get_slice_id_by_subscription(subscription_id) + if mapped_slice_id: + slice_id = mapped_slice_id + logging.info(f"Found slice_id {slice_id} mapped to subscription_id {subscription_id}") + except Exception as e: + logging.info(f"Subscription mapping lookup failed: {e}") + + if not slice_id and service_id: + # 1. Try to find slice_id from service_db + try: + import src.database.service_db as service_db + service_info = service_db.get_data(service_id) + slice_id = service_info.get("slice_id") + logging.info(f"Found slice_id {slice_id} in service_db for service_id {service_id}") + except Exception as e: + logging.info(f"service_db lookup failed: {e}") + slice_id = service_id + + if slice_id: + # 2. Try to get slice data from db + try: + import src.database.db as db + slice_info = db.get_data(slice_id) + logging.info(f"Found slice_info in db by slice_id {slice_id}") + except Exception as e: + logging.info(f"db lookup by slice_id {slice_id} failed: {e}") + + # Fallback: if not found by ID, look up any existing slice in the DB + if not slice_info: + try: + import src.database.db as db + slices = db.get_all_data() + logging.info(f"Slices in db: {[s.get('slice_id') for s in slices]}") + for s in slices: + if s.get("slice_id") == slice_id: + slice_info = s + break + if not slice_info and slices: + # Default to the first/only slice if there's any + slice_info = slices[0] + logging.info(f"Defaulted to first slice from db: {slice_info.get('slice_id')}") + except Exception as e: + logging.info(f"db get_all_data lookup failed: {e}") + + # Fallback: read from /home/llmserver/tfs-nsc/intent.json if DB is empty + if not slice_info: + import os + import json + fallback_path = "/home/llmserver/tfs-nsc/intent.json" + if os.path.exists(fallback_path): + try: + with open(fallback_path, "r") as f: + intent_data = json.load(f) + slice_info = {"slice_id": slice_id or "slice", "intent": intent_data} + logging.info("Loaded fallback intent from intent.json") + except Exception as e: + logging.error(f"Failed to read fallback intent.json: {e}") + + if slice_info: + intent = slice_info.get("intent") + curr_slice_id = slice_info.get("slice_id") + logging.info(f"Processing intent for slice {curr_slice_id}") + + if intent: + nss = intent.get("ietf-network-slice-service:network-slice-services", {}) + slice_services = nss.get("slice-service", []) + modified = False + + for service in slice_services: + # Get all SDP IDs in this slice service + sdp_list = service.get("sdps", {}).get("sdp", []) + sdp_ids = [sdp.get("id") for sdp in sdp_list if sdp.get("id")] + + connection_groups = service.get("connection-groups", {}).get("connection-group", []) + for cg in connection_groups: + connectivity_constructs = cg.get("connectivity-construct", []) + for cc in connectivity_constructs: + p2mp_sender = cc.get("p2mp-sender-sdp") + p2mp_receivers = cc.get("p2mp-receiver-sdp", []) + + logging.info(f"sdp_ids: {sdp_ids}, p2mp_sender: {p2mp_sender}, p2mp_receivers: {p2mp_receivers}") + + # Find alternative receiver endpoints + other_endpoints = [ + sdp_id for sdp_id in sdp_ids + if sdp_id != p2mp_sender and sdp_id not in p2mp_receivers + ] + + old_receiver = None + new_receiver = None + + if other_endpoints and p2mp_receivers: + if len(p2mp_receivers) >= 2: + old_receiver = p2mp_receivers[1] + new_receiver = other_endpoints[0] + cc["p2mp-receiver-sdp"] = [p2mp_receivers[0], new_receiver] + else: + old_receiver = p2mp_receivers[0] + new_receiver = other_endpoints[0] + cc["p2mp-receiver-sdp"] = [new_receiver] + modified = True + + # Log ORIGEN and DESTINO + logging.info(f"ORIGEN: {p2mp_sender} DESTINO: {new_receiver}") + print(f"ORIGEN: {p2mp_sender} DESTINO: {new_receiver}", flush=True) + else: + logging.warning("No alternative receiver endpoints found to swap.") + + if p2mp_receivers: + old_service_id = f"{p2mp_sender}_to_{','.join(p2mp_receivers)}" + else: + old_service_id = None + + if modified: + # Re-apply the modified intent via slice_service.nsc + try: + self.slice_service.nsc(intent, curr_slice_id, old_service_id=old_service_id) + logging.info(f"Slice {curr_slice_id} updated successfully following alert.") + except Exception as e: + logging.error(f"Failed to update slice configuration: {e}") + else: + logging.warning("No slice intent found to process alert.") + + return send_response( + True, + code=201, + message="Alert processed and saved successfully", + data=alert_data + ) + except Exception as e: + return send_response(False, code=500, message=str(e)) + + def get_alerts(self, alert_id=None): + """ + Retrieve alert(s). + """ + try: + if alert_id: + try: + data = alert_db.get_alert(alert_id) + return data, 200 + except ValueError as e: + return send_response(False, code=404, message=str(e)) + else: + data = alert_db.get_all_alerts() + return data, 200 + except Exception as e: + return send_response(False, code=500, message=str(e)) + + def modify_alert(self, alert_id, alert_data): + """ + Modify/update an alert. + """ + try: + try: + alert_db.update_alert(alert_id, alert_data) + return send_response( + True, + code=200, + message="Alert updated successfully", + data=alert_data + ) + except ValueError as e: + return send_response(False, code=404, message=str(e)) + except Exception as e: + return send_response(False, code=500, message=str(e)) + + def delete_alerts(self, alert_id=None): + """ + Delete alert(s). + """ + try: + if alert_id: + try: + alert_db.delete_alert(alert_id) + return {}, 204 + except ValueError as e: + return send_response(False, code=404, message=str(e)) + else: + alert_db.delete_all_alerts() + return {}, 204 + except Exception as e: + return send_response(False, code=500, message=str(e)) + + def get_flows(self,slice_id=None): + """ + Retrieve transport network slice information. + + This method allows retrieving: + - All transport network slices + - A specific slice by its ID + + Args: + slice_id (str, optional): Unique identifier of a specific slice. + Defaults to None. + + Returns: + dict or list: + - If slice_id is provided: Returns the specific slice details + - If slice_id is None: Returns a list of all slices + - Returns an error response if no slices are found + + API Endpoint: + GET /slice/{id} + + Raises: + ValueError: If no transport network slices are found + Exception: For unexpected errors + """ + try: + # Read slice database from JSON file + content = get_all_data() + # If specific slice ID is provided, find and return matching slice + if slice_id: + for slice in content: + if slice["slice_id"] == slice_id: + return slice, 200 + raise ValueError("Transport network slices not found") + # If no slices exist, raise an error + if len(content) == 0: + raise ValueError("Transport network slices not found") + + # Return all slices if no specific ID is given + return [slice for slice in content if slice.get("controller") == self.slice_service.controller_type], 200 + + except ValueError as e: + # Handle case where no slices are found + return send_response(False, code=404, message=str(e)) + except Exception as e: + # Handle unexpected errors + return send_response(False, code=500, message=str(e)) + + def modify_flow(self,slice_id, intent): + """ + Modify an existing transport network slice. + + Args: + slice_id (str): Unique identifier of the slice to modify + intent (dict): New intent configuration for the slice + + Returns: + Result of the Network Slice Controller (NSC) operation + + API Endpoint: + PUT /slice/{id} + Raises: + Exception: For unexpected errors + """ + try: + result = self.slice_service.nsc(intent, slice_id) + if isinstance(result, tuple): + return result + if not result: + return send_response(False, code=404, message="Slice not found") + logging.info(f"Slice {slice_id} modified successfully") + return send_response( + True, + code=200, + message="Slice modified successfully", + data=result + ) + except ValueError as e: + # Handle case where no slices are found + return send_response(False, code=404, message=str(e)) + except Exception as e: + # Handle unexpected errors + return send_response(False, code=500, message=str(e)) + + def delete_flows(self, slice_id=None): + """ + Delete transport network slice(s). + + This method supports: + - Deleting a specific slice by ID + - Deleting all slices + - Optional cleanup of L2VPN configurations + + Args: + slice_id (str, optional): Unique identifier of slice to delete. + Defaults to None. + + Returns: + dict: {} indicating successful deletion or error details + + API Endpoint: + DELETE /slice/{id} + + Raises: + ValueError: If no slices are found to delete + Exception: For unexpected errors + + Notes: + - If controller_type is TFS, attempts to delete from Teraflow + - If need_l2vpn_support is True, performs additional L2VPN cleanup + """ + try: + # Delete specific slice if slice_id is provided + if slice_id: + slice = get_data(slice_id) + # Raise error if slice not found + if not slice or slice.get("controller") != self.slice_service.controller_type: + raise ValueError("Transport network slice not found") + # Delete in Teraflow + if not current_app.config["DUMMY_MODE"]: + if self.slice_service.controller_type == "TFS": + slice_type = safe_get(slice, ['intent', 'ietf-network-slice-service:network-slice-services', 'slice-service', 0, 'service-tags', 'tag-type', 0, 'tag-type-value', 0]) + if not slice_type: + slice_type = "L2" + logging.warning(f"Slice type not found in slice intent. Defaulting to L2") + tfs_connector().nbi_delete(current_app.config["TFS_IP"],slice_type, slice_id) + # Update slice database + delete_data(slice_id) + logging.info(f"Slice {slice_id} removed successfully") + return {}, 204 + + # Delete all slices + else: + # Optional: Delete in Teraflow if configured + if not current_app.config["DUMMY_MODE"]: + if self.slice_service.controller_type == "TFS": + content = get_all_data() + for slice in content: + if slice.get("controller") == self.slice_service.controller_type: + slice_type = safe_get(slice, ['intent', 'ietf-network-slice-service:network-slice-services', 'slice-service', 0, 'service-tags', 'tag-type', 0, 'tag-type-value', 0]) + if not slice_type: + slice_type = "L2" + logging.warning(f"Slice type not found in slice intent. Defaulting to L2") + tfs_connector().nbi_delete(current_app.config["TFS_IP"],slice_type, slice.get("slice_id")) + if current_app.config["TFS_L2VPN_SUPPORT"]: + tfs_l2vpn_delete() + + # Clear slice database + delete_all_data() + + logging.info("All slices removed successfully") + return {}, 204 + + except ValueError as e: + return send_response(False, code=404, message=str(e)) + except Exception as e: + return send_response(False, code=500, message=str(e)) + + # RESCONF calls + + ### GET + + def get_network_slice_services(self): + try: + data = get_data_store("/ietf-network-slice-service:network-slice-services") + if not data: + raise ValueError("Nothing found") + return data, 200 + except ValueError as e: + return send_response(False, code=404, message=str(e)) + except Exception as e: + return send_response(False, code=500, message=str(e)) + + def get_slo_sle_templates(self, template_id=None): + try: + if template_id: + xpath = f"/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template[id='{template_id}']" + data = get_data_store(xpath) + if not data: + raise ValueError("Template not found") + return data, 200 + + data = get_data_store("/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template") + if not data: + raise ValueError("No templates found") + + return data, 200 + + except ValueError as e: + # Handle case where no slices are found + return send_response(False, code=404, message=str(e)) + except Exception as e: + # Handle unexpected errors + return send_response(False, code=500, message=str(e)) + + def get_slice_services(self, slice_id=None): + try: + if slice_id: + data = get_data_store(f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']") + if not data: + raise ValueError("Slice not found") + return data, 200 + + data = get_data_store("/ietf-network-slice-service:network-slice-services/slice-service") + if not data: + raise ValueError("No slices found") + + return data, 200 + + except ValueError as e: + # Handle case where no slices are found + return send_response(False, code=404, message=str(e)) + except Exception as e: + # Handle unexpected errors + return send_response(False, code=500, message=str(e)) + + def get_slice_topology(self, slice_id): + """ + Retrieve Network Slice Topology for a given slice_id according to draft-ietf-teas-network-slice-topology-yang-04. + """ + try: + tfs_ip = current_app.config.get("RESTCONF_IP", "127.0.0.1") + connector = tfs_restconf_connector() + return connector.get_slice_topology(tfs_ip, slice_id) + except Exception as e: + logging.exception(f"Error retrieving slice topology for slice '{slice_id}': {e}") + return send_response(False, code=500, message=str(e)) + + def get_sdps(self, slice_id, sdp_id=None): + try: + if sdp_id: + data = get_data_store(f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']/sdps/sdp[id='{sdp_id}']") + if not data: + raise ValueError("SDP not found") + return data, 200 + + data = get_data_store(f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']/sdps") + if not data: + raise ValueError("No SDPs found") + + return data, 200 + + except ValueError as e: + # Handle case where no slices are found + return send_response(False, code=404, message=str(e)) + except Exception as e: + # Handle unexpected errors + return send_response(False, code=500, message=str(e)) + + ### POST + + def add_network_slice_service(self, intent): + try: + result = self.slice_service.nsc(intent) + if isinstance(result, tuple): + return result + if result: + try: + create_data_store(intent) + except Exception as ds_err: + logging.warning(f"Could not store intent in sysrepo datastore: {ds_err}") + logging.info(f"Network Slice created successfully") + return send_response( + True, + code=201, + message="Network Slice created successfully", + data=result + ) + except RuntimeError as e: + # Handle case where there is no content to process + return send_response(False, code=200, message=str(e)) + except Exception as e: + # Handle unexpected errors + return send_response(False, code=500, message=str(e)) + + def add_slo_sle_template(self, template): + try: + template_id = template.pop("id", None) + xpath = f"/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template[id='{template_id}']" + existing_template = get_data_store(xpath) + if existing_template: + return send_response(False, code=409, message="Template already exists") + create_data_store(template, xpath) + logging.info(f"Template created successfully") + return send_response( + True, + code=201, + message="Template created successfully" + ) + except Exception as e: + # Handle unexpected errors + return send_response(False, code=500, message=str(e)) + + def add_slice_service(self, intent): + try: + xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{intent.get('id')}']" + existing_slice = get_data_store(xpath) + if existing_slice: + return send_response(False, code=409, message="Slice already exists") + + if "slo-sle-template" in intent: + template_ref = intent.get("slo-sle-template") + xpath_template = f"/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template[id='{template_ref}']" + existing_template = get_data_store(xpath_template) + if not existing_template: + return send_response(False, code=404, message="Referenced SLO/SLE template not found") + full_intent = { + "ietf-network-slice-service:network-slice-services": { + "slo-sle-templates": { + "slo-sle-template": [existing_template["network-slice-services"]["slo-sle-templates"]["slo-sle-template"][template_ref]] + }, + "slice-service": [intent] + } + } + elif "service-slo-sle-policy" in intent: + full_intent = { + "ietf-network-slice-service:network-slice-services": { + "slo-sle-templates": { + "slo-sle-template": intent.get("service-slo-sle-policy") + }, + "slice-service": [intent] + } + } + else: + return send_response(False, code=400, message="No SLO/SLE template or policy provided in intent") + + full_intent = normalize_libyang_data(full_intent) + result = self.slice_service.nsc(full_intent) + if result: + intent.pop("id", None) + create_data_store(intent, xpath) + logging.info(f"Slice created successfully") + return send_response( + True, + code=201, + message="Slice created successfully", + data=result + ) + except RuntimeError as e: + # Handle case where there is no content to process + return send_response(False, code=200, message=str(e)) + except Exception as e: + # Handle unexpected errors + return send_response(False, code=500, message=str(e)) + + def add_sdp(self, slice_id, sdp): + try: + sdp_id = sdp.pop("id", None) + xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']/sdps/sdp[id='{sdp_id}']" + existing_sdp = get_data_store(xpath) + if existing_sdp: + return send_response(False, code=409, message="SDP already exists") + create_data_store(sdp, xpath) + logging.info(f"SDP created successfully") + return send_response( + True, + code=201, + message="SDP created successfully" + ) + except Exception as e: + # Handle unexpected errors + return send_response(False, code=500, message=str(e)) + + ### PUT + + def update_network_slice_service(self, intent): + """ + Modify (replace) all network-slice-services configuration + """ + try: + xpath = "/ietf-network-slice-service:network-slice-services" + + # Verify if there is something to modify + existing_data = get_data_store(xpath) + if not existing_data: + return send_response(False, code=404, message="Network slice services not found") + + # If not in DUMMY mode, process with TFS + result = self.slice_service.nsc(intent) + if not result: + return send_response(False, code=500, message="Failed to process slice in TFS") + + # Replace completely the resource + update_data_store(intent) + logging.info("Network slice services modified successfully") + + return send_response( + True, + code=200, + message="Network slice services updated successfully", + data=result + ) + + except ValueError as e: + return send_response(False, code=404, message=str(e)) + except Exception as e: + return send_response(False, code=500, message=str(e)) + + def update_slo_sle_template(self, template_id, template): + """ + Modify (replace) an specific SLO/SLE template + """ + try: + xpath = f"/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template[id='{template_id}']" + + # Verify the template exists + existing_template = get_data_store(xpath) + if not existing_template: + return send_response(False, code=404, message="Template not found") + + # Assure that the body ID matches the URL + if "id" in template and template["id"] != template_id: + return send_response(False, code=400, message="Template ID in body does not match URL") + + slices = get_data_store("/ietf-network-slice-service:network-slice-services/slice-service") + + for slice in slices["network-slice-services"]["slice-service"]: + if "slo-sle-template" in slice: + if slice.get("slo-sle-template") == template_id: + full_intent = { + "ietf-network-slice-service:network-slice-services": { + "slo-sle-templates": { + "slo-sle-template": [existing_template["network-slice-services"]["slo-sle-templates"]["slo-sle-template"][template_id]] + }, + "slice-service": [slice] + } + } + full_intent = normalize_libyang_data(full_intent) + result = self.slice_service.nsc(full_intent, slice.get("id")) + if not result: + return send_response(False, code=500, message="Slice not updated") + + # Remove the ID from the body if it exists (it's already in the predicate) + template_data = template.copy() + template_data.pop("id", None) + + # Replace the template + update_data_store(template_data, xpath) + logging.info(f"Template {template_id} modified successfully") + + return send_response( + True, + code=200, + message="Template updated successfully", + data=result + ) + + except ValueError as e: + return send_response(False, code=404, message=str(e)) + except Exception as e: + return send_response(False, code=500, message=str(e)) + + def update_slice_service(self, slice_id, intent): + """ + Modifica (reemplaza) un slice service específico + """ + try: + xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']" + + # Verify that the slice exists + existing_slice = get_data_store(xpath) + if not existing_slice: + return send_response(False, code=404, message="Slice not found") + + # Assure that the body ID matches the URL + if "id" in intent and intent["id"] != slice_id: + return send_response(False, code=400, message="Slice ID in body does not match URL") + + # Validate that the referenced SLO/SLE template exists + if "slo-sle-template" in intent: + template_ref = intent.get("slo-sle-template") + xpath_template = f"/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template[id='{template_ref}']" + existing_template = get_data_store(xpath_template) + if not existing_template: + return send_response(False, code=404, message="Referenced SLO/SLE template not found") + + # Build the full intent + full_intent = { + "ietf-network-slice-service:network-slice-services": { + "slo-sle-templates": { + "slo-sle-template": [existing_template["network-slice-services"]["slo-sle-templates"]["slo-sle-template"][template_ref]] + }, + "slice-service": [intent] + } + } + elif "service-slo-sle-policy" in intent: + full_intent = { + "ietf-network-slice-service:network-slice-services": { + "slo-sle-templates": { + "slo-sle-template": intent.get("service-slo-sle-policy") + }, + "slice-service": [intent] + } + } + else: + return send_response(False, code=400, message="No SLO/SLE template or policy provided in intent") + + full_intent = normalize_libyang_data(full_intent) + result = self.slice_service.nsc(full_intent) + if not result: + return send_response(False, code=500, message="Slice not updated") + + # Remove the ID from the body (it's already in the predicate) + intent_data = intent.copy() + intent_data.pop("id", None) + + # Replace the slice + update_data_store(intent_data, xpath) + logging.info(f"Slice {slice_id} modified successfully") + + return send_response( + True, + code=200, + message="Slice updated successfully", + data=result + ) + + except ValueError as e: + return send_response(False, code=404, message=str(e)) + except RuntimeError as e: + return send_response(False, code=200, message=str(e)) + except Exception as e: + return send_response(False, code=500, message=str(e)) + + def update_sdp(self, slice_id, sdp_id, sdp): + """ + Modify (replace) an specific SDP in the slice + """ + try: + # Verify the template exists + slice_xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']" + existing_slice = get_data_store(slice_xpath) + if not existing_slice: + return send_response(False, code=404, message="Slice not found") + + # Verify the SDP exists + sdp_xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']/sdps/sdp[id='{sdp_id}']" + existing_sdp = get_data_store(sdp_xpath) + if not existing_sdp: + return send_response(False, code=404, message="SDP not found") + + # Assure that the body ID matches the URL + if "id" in sdp and sdp["id"] != sdp_id: + return send_response(False, code=400, message="SDP ID in body does not match URL") + + # Remove the ID from the body (it's already in the predicate) + sdp_data = sdp.copy() + sdp_data.pop("id", None) + + # Replace the SDP + update_data_store(sdp_data, sdp_xpath) + logging.info(f"SDP {sdp_id} in slice {slice_id} modified successfully") + + return send_response( + True, + code=200, + message="SDP updated successfully" + ) + + except ValueError as e: + return send_response(False, code=404, message=str(e)) + except Exception as e: + return send_response(False, code=500, message=str(e)) + + ### DELETE + + def delete_network_slice_services(self): + try: + xpath = f"/ietf-network-slice-service:network-slice-services" + if not current_app.config["DUMMY_MODE"]: + content = get_data_store(xpath) + slice_services = safe_get(content, ["network-slice-services", "slice-service"]) + if not slice_services: + raise ValueError("Network slice services not found") + for slice in slice_services: + slice_type = list(slice["service-tags"]["tag-type"]["ietf-network-slice-service:service"]["tag-type-value"])[0] + if not slice_type: + slice_type = "L2" + logging.warning(f"Slice type not found in slice intent. Defaulting to L2") + logging.debug(f"Send slice to delete in TFS with slice_type {slice_type}") + services = get_data_by_slice_id(slice.get("id")) + for service in services: + id = service.get("service_id") + tfs_connector().nbi_delete(current_app.config["RESTCONF_IP"], slice_type, id) + delete_by_slice_id(slice.get("id")) + if current_app.config["TFS_L2VPN_SUPPORT"]: + self.slice_service.tfs_l2vpn_delete() + + delete_data_store(xpath) + logging.info("All slices removed successfully") + + return {}, 204 + + except ValueError as e: + return send_response(False, code=404, message=str(e)) + except Exception as e: + return send_response(False, code=500, message=str(e)) + + def delete_slo_sle_templates(self, template_id=None): + try: + # Delete specific template if template_id is provided + if template_id: + xpath = f"/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template[id='{template_id}']" + existing_template = get_data_store(xpath) + if not existing_template: + raise ValueError("Template not found") + delete_data_store(xpath) + logging.info(f"Template {template_id} removed successfully") + return {}, 204 + + # Delete all templates + else: + xpath = f"/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template" + delete_data_store(xpath) + logging.info("All templates removed successfully") + return {}, 204 + + except ValueError as e: + return send_response(False, code=404, message=str(e)) + except Exception as e: + return send_response(False, code=500, message=str(e)) + + def delete_slice_services(self, slice_id=None): + try: + # Delete specific slice if slice_id is provided + if slice_id: + xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']" + existing_slice = get_data_store(xpath) + if not existing_slice: + raise ValueError("Slice not found") + if not current_app.config["DUMMY_MODE"]: + slice_type = safe_get(existing_slice, ["network-slice-services", "slice-service", slice_id, "service-tags", "tag-type", "ietf-network-slice-service:service", "tag-type-value", 0]) or "L2" + logging.debug(f"Send slice to delete in TFS with slice_type {slice_type}") + services = get_data_by_slice_id(slice_id) + for service in services: + id = service.get("service_id") + tfs_connector().nbi_delete(current_app.config["RESTCONF_IP"], slice_type, id) + delete_by_slice_id(slice_id) + if current_app.config["TFS_L2VPN_SUPPORT"]: + tfs_l2vpn_delete() + + delete_data_store(xpath) + logging.info(f"Slice {slice_id} removed successfully") + return {}, 204 + + # Delete all slices + else: + xpath = f"/ietf-network-slice-service:network-slice-services/slice-service" + if not current_app.config["DUMMY_MODE"]: + content = get_data_store(xpath) + slice_services = safe_get(content, ["network-slice-services", "slice-service"]) + if not slice_services: + raise ValueError("Slice services not found") + for slice in slice_services: + slice_type = safe_get(slice, ["service-tags", "tag-type", "ietf-network-slice-service:service", "tag-type-value", 0]) or "L2" + logging.debug(f"Send slice to delete in TFS with slice_type {slice_type}") + services = get_data_by_slice_id(slice.get("id")) + for service in services: + id = service.get("service_id") + tfs_connector().nbi_delete(current_app.config["RESTCONF_IP"], slice_type, id) + delete_by_slice_id(slice.get("id")) + if current_app.config["TFS_L2VPN_SUPPORT"]: + tfs_l2vpn_delete() + delete_data_store(xpath) + logging.info("All slices removed successfully") + return {}, 204 + + except ValueError as e: + return send_response(False, code=404, message=str(e)) + except Exception as e: + return send_response(False, code=500, message=str(e)) + + def delete_sdps(self, slice_id, sdp_id=None): + try: + # Delete specific SDP if sdp_id is provided + if sdp_id: + xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']" + existing_slice = get_data_store(xpath) + if not existing_slice: + raise ValueError("Slice not found") + xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']/sdps/sdp[id='{sdp_id}']" + existing_sdp = get_data_store(xpath) + if not existing_sdp: + raise ValueError("SDP not found") + delete_data_store(xpath) + logging.info(f"SDP {sdp_id} removed successfully") + return {}, 204 + + # Delete all SDPs + else: + xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']" + existing_slice = get_data_store(xpath) + if not existing_slice: + raise ValueError("Slice not found") + xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']/sdps" + delete_data_store(xpath) + logging.info("All SDPs removed successfully") + return {}, 204 + + except ValueError as e: + return send_response(False, code=404, message=str(e)) + except Exception as e: + return send_response(False, code=500, message=str(e)) + + # --- CLIENTS --- + + def get_clients(self, client_id=None): + try: + if client_id: + return get_client(client_id), 200 + clients = get_all_clients() + if not clients: + raise ValueError("No clients found") + return clients, 200 + + except ValueError as e: + return send_response(False, code=404, message=str(e)) + except Exception as e: + return send_response(False, code=500, message=str(e)) + + def add_client(self, client_id): + try: + create_client(client_id) + logging.info(f"Client '{client_id}' created successfully") + return send_response( + True, + code=201, + message=f"Client '{client_id}' created successfully", + data={"client_id": client_id} + ) + + except ValueError as e: + return send_response(False, code=409, message=str(e)) + except Exception as e: + return send_response(False, code=500, message=str(e)) + + def delete_clients(self, client_id=None): + try: + if client_id: + delete_client(client_id) + logging.info(f"Client '{client_id}' removed successfully") + else: + delete_all_clients() + logging.info("All clients removed successfully") + return {}, 204 + except ValueError as e: + return send_response(False, code=404, message=str(e)) + except Exception as e: + return send_response(False, code=500, message=str(e)) + + # --- SUBSCRIPTIONS --- + + def get_subscriptions(self, client_id, slice_id = None): + try: + if slice_id is not None: + try: + subscription = get_subscription(client_id, slice_id) + except ValueError: + subscription = None + if not subscription: + raise ValueError(f"Client '{client_id}' has no subscription for slice '{slice_id}'") + + telemetry = self.get_telemetry(slice_id) + + return { + **subscription, + "telemetry": telemetry + }, 200 + + subscriptions = get_client_subscriptions(client_id) + + result = [] + + for sub in subscriptions: + slice_id = sub["slice_id"] + + telemetry = self.get_telemetry(slice_id) + + result.append({ + **sub, + "telemetry": telemetry + }) + + return { + "client_id": client_id, + "subscriptions": result + }, 200 + + except ValueError as e: + return send_response(False, code=404, message=str(e)) + except Exception as e: + return send_response(False, code=500, message=str(e)) + + def add_subscription(self, client_id, slice_id, frequency): + try: + try: + subscription = get_subscription(client_id, slice_id) + except ValueError: + subscription = None + if subscription: + raise ValueError(f"Client '{client_id}' already has a subscription for slice '{slice_id}'") + if not frequency: + raise KeyError("Field 'frequency' is required") + + upsert_subscription(client_id, slice_id, frequency) + logging.info(f"Subscription for slice '{slice_id}' and client '{client_id}' created successfully") + return send_response( + True, + code=201, + message="Subscription successfully created", + data={ + "sliceId": slice_id, + "frequency": frequency + } + ) + except KeyError as e: + return send_response(False, code=400, message=str(e)) + except ValueError as e: + return send_response(False, code=404, message=str(e)) + except Exception as e: + return send_response(False, code=500, message=str(e)) + + def update_subscription(self, client_id, slice_id, frequency): + try: + try: + subscription = get_subscription(client_id, slice_id) + except ValueError: + subscription = None + if not subscription: + raise ValueError(f"Client '{client_id}' has no subscription for slice '{slice_id}'") + if not frequency: + raise KeyError("Field 'frequency' is required") + + upsert_subscription(client_id, slice_id, frequency) + logging.info(f"Subscription for slice '{slice_id}' and client '{client_id}' modified successfully") + return send_response( + True, + code=201, + message="Subscription successfully modified", + data={ + "sliceId": slice_id, + "frequency": frequency + } + ) + except KeyError as e: + return send_response(False, code=400, message=str(e)) + except ValueError as e: + return send_response(False, code=404, message=str(e)) + except Exception as e: + return send_response(False, code=500, message=str(e)) + + def delete_subscriptions(self, client_id, slice_id = None): + try: + subscriptions = get_client_subscriptions(client_id) + if slice_id: + if slice_id not in subscriptions: + raise ValueError(f"Client '{client_id}' has no subscription for slice '{slice_id}'") + delete_subscription(client_id, slice_id) + logging.info(f"Subscription for slice '{slice_id}' and client '{client_id}' removed successfully") + return {}, 204 + delete_all_subscriptions(client_id) + logging.info(f"All subscriptions for client '{client_id}' removed successfully") + return {}, 204 + + except ValueError as e: + return send_response(False, code=404, message=str(e)) + except Exception as e: + return send_response(False, code=500, message=str(e)) + + # --- TELEMETRY --- + + def get_telemetry(self, slice_id = None): + logging.debug(f"Getting telemetry for slice_id: {slice_id}") + try: + if slice_id is not None: + xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']" + existing_slice = get_data_store(xpath) + if not existing_slice: + raise ValueError(f"There is no slice with id '{slice_id}' registered") + template_id = safe_get(existing_slice, ["network-slice-services", "slice-service", slice_id, "slo-sle-template"]) + slo_sle_template = self.get_slo_sle_templates(template_id)[0] + slo_sle_template = safe_get(slo_sle_template, ["network-slice-services", "slo-sle-templates", "slo-sle-template"]) + slo_sle_template = next(iter(slo_sle_template), None) + if not slo_sle_template: + raise ValueError(f"SLO/SLE template '{template_id}' not found for slice '{slice_id}'") + metrics = self.slice_service.monitoring(slice_id, slo_sle_template) + return metrics, 200 + + telemetry_data = {} + slices_data = self.get_slice_services()[0] + slice_service_list = slices_data["network-slice-services"]["slice-service"] + if isinstance(slice_service_list, dict): + slice_service_list = list(slice_service_list.values()) + + for slice in slice_service_list: + selected_template_id = slice.get("slo-sle-template") + slo_sle_template = self.get_slo_sle_templates(selected_template_id)[0] + slo_sle_template = safe_get(slo_sle_template, ["network-slice-services", "slo-sle-templates", "slo-sle-template"]) + slo_sle_template = next(iter(slo_sle_template), None) + if not slo_sle_template: + raise ValueError(f"SLO/SLE template '{selected_template_id}' not found for slice '{slice['id']}'") + slice_id = slice["id"] + telemetry_data[slice_id] = self.slice_service.monitoring(slice_id, slo_sle_template) + return telemetry_data, 200 + + except ValueError as e: + return send_response(False, code=404, message=str(e)) + except Exception as e: + return send_response(False, code=500, message=str(e)) + + def sync_stream(self, async_gen_func, *args, **kwargs): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + agen = async_gen_func(*args, **kwargs) + + try: + while True: yield loop.run_until_complete(agen.__anext__()) + except StopAsyncIteration: pass + finally: loop.close() + + async def stream_client_subscriptions(self, client_id): + while True: + try: + data, code = self.get_subscriptions(client_id) + if code == 200: + yield f"data: {json.dumps(data)}\n\n" + subs = data.get("subscriptions", []) + freq = max([s["frequency"] for s in subs]) if subs else 5 + await asyncio.sleep(freq) + else: + yield f"event: error\ndata: {json.dumps(data)}\n\n" + break + except Exception as e: + yield f"event: error\ndata: {json.dumps({'error': str(e)})}\n\n" + break + + async def stream_slice_subscription(self, client_id, slice_id): + while True: + try: + data, code = self.get_subscriptions(client_id, slice_id) + if code == 200: + yield f"data: {json.dumps(data)}\n\n" + freq = data.get("frequency", 5) + await asyncio.sleep(freq) + else: + yield f"event: error\ndata: {json.dumps(data)}\n\n" + break + except Exception as e: + yield f"event: error\ndata: {json.dumps({'error': str(e)})}\n\n" + break + + def reconfig_slice(self, slice_id): + """ + Trigger slice reconfiguration via Change Scheduler Planner. + + Args: + slice_id (str): The identifier of the slice to reconfigure. + + Returns: + Tuple[dict, int]: Response payload and HTTP status code. + """ + try: + result = self.slice_service.reconfig_slice(slice_id) + return send_response(True, code=200, data=result) + except ValueError as e: + return send_response(False, code=404, message=str(e)) + except Exception as e: + return send_response(False, code=500, message=str(e)) + \ No newline at end of file diff --git a/src/config/config.py b/src/config/config.py index 338f7b7..89aea6b 100644 --- a/src/config/config.py +++ b/src/config/config.py @@ -48,6 +48,8 @@ def create_config(app: Flask): app.config["E2E_OPTICAL_IP"] = os.getenv("E2E_OPTICAL_IP", "127.0.0.1") app.config["SUBSCRIBE_ALERTS"] = os.getenv("SUBSCRIBE_ALERTS", "false").lower() == "true" app.config["SUBSCRIBE_ALERTS_URL"] = os.getenv("SUBSCRIBE_ALERTS_URL", "") + app.config["CHANGE_SCHEDULER_IP"] = os.getenv("CHANGE_SCHEDULER_IP", "127.0.0.1") + app.config["CHANGE_SCHEDULER_PORT"] = int(os.getenv("CHANGE_SCHEDULER_PORT", "8090")) # Realizer app.config["DUMMY_MODE"] = os.getenv("DUMMY_MODE", "true").lower() == "true" diff --git a/src/main.py b/src/main.py index 3bf626a..9e83a22 100644 --- a/src/main.py +++ b/src/main.py @@ -24,6 +24,7 @@ from src.database.service_db import delete_data from src.mapper.main import mapper from src.realizer.main import realizer from src.realizer.send_controller import send_controller +from src.database.sysrepo_store import update_data_store, get_data_store class NSController: """ @@ -227,4 +228,75 @@ class NSController: # Request the mapper to aggregate the metrics and store them in the database metrics = mapper(payload, action="MONITOR") - return metrics \ No newline at end of file + # Check if SLO/SLE compliance indicates non-compliance + slo_compliance = metrics.get("slo_sle_compliance", {}) if isinstance(metrics, dict) else {} + is_compliant = slo_compliance.get("is_compliant", True) + if not is_compliant: + logging.warning(f"SLO/SLE compliance violation (is_compliant=False) detected for slice '{slice_id}'. Triggering automatic reconfig_slice...") + try: + reconfig_res = self.reconfig_slice(slice_id) + if isinstance(metrics, dict): + metrics["reconfig_result"] = reconfig_res + except Exception as e: + logging.error(f"Automatic reconfig_slice failed for slice '{slice_id}': {e}") + if isinstance(metrics, dict): + metrics["reconfig_error"] = str(e) + + return metrics + + def reconfig_slice(self, slice_id): + """ + Reconfigure a network slice by computing the optimal path, comparing it with + the active service path, and sending scheduled topology changes to Change Scheduler. + Once Change Scheduler confirms NDT viability, perform slice PUT modification. + + Args: + slice_id (str): Identifier of the network slice to reconfigure. + + Returns: + dict: Result from Planner using CHANGE_SCHEDULER strategy and slice modification. + """ + reconfig_data = realizer({"slice_id": slice_id}, action="RECONFIG", controller_type=self.controller_type) + + from src.planner.planner import Planner + planner = Planner() + cs_result = planner.planner(reconfig_data, type="CHANGE_SCHEDULER") + + # Evaluate Change Scheduler response viability + is_viable = False + if isinstance(cs_result, dict): + cs_response = cs_result.get("response", {}) + if cs_result.get("success", True) or cs_result.get("status_code") in (200, 201, 202): + is_viable = True + if isinstance(cs_response, dict): + if cs_response.get("status") in ("VIABLE", "SCHEDULED") or cs_response.get("viable") is True: + is_viable = True + + if is_viable: + logging.info(f"Change Scheduler confirmed viability for slice '{slice_id}'. Performing slice PUT modification...") + try: + xpath = f"/ietf-network-slice-service:network-slice-services" + intent = get_data_store(xpath) + if not intent: + raise ValueError("Network slice services not found") + if intent: + if isinstance(intent, dict) and "network-slice-services" in intent: + intent["ietf-network-slice-service:network-slice-services"] = intent.pop("network-slice-services") + logging.debug(f"Intent found for slice '{slice_id}': {intent}") + mod_result = self.nsc(intent, slice_id=slice_id) + + if isinstance(cs_result, dict): + cs_result["slice_modification"] = mod_result + logging.info(f"Slice '{slice_id}' successfully modified via PUT following Change Scheduler viability confirmation.") + update_data_store(intent) + else: + logging.warning(f"No stored intent found for slice '{slice_id}' during reconfiguration PUT modification.") + except Exception as e: + logging.error(f"Error during slice '{slice_id}' PUT modification: {e}") + if isinstance(cs_result, dict): + cs_result["slice_modification_error"] = str(e) + + return cs_result + + + diff --git a/src/mapper/main.py b/src/mapper/main.py index 30577e2..64a7f2c 100644 --- a/src/mapper/main.py +++ b/src/mapper/main.py @@ -133,6 +133,8 @@ def mapper(payload, controller_type="TFS", action="CREATE"): logging.debug(f"Group Template: {template}") connectivity_type = safe_get(connection_group, ['connectivity-type']) + if isinstance(connectivity_type, str) and ":" in connectivity_type: + connectivity_type = connectivity_type.split(":")[-1] logging.debug(f"Connectivity Type: {connectivity_type}") # Process connectivity constructs diff --git a/src/mapper/process_connnectivity.py b/src/mapper/process_connnectivity.py index 8dc1209..b424843 100644 --- a/src/mapper/process_connnectivity.py +++ b/src/mapper/process_connnectivity.py @@ -30,7 +30,7 @@ def process_connectivity(connection_group_id, connectivity_type, connectivity_co List of tuples: (sdp_info, direction) """ sdps = [] - if connectivity_type == "ietf-vpn-common:any-to-any": + if connectivity_type == "any-to-any": a2a_list = safe_get(connectivity_construct, ["a2a-sdp"]) or [] for sdp in a2a_list: sdp, match_criteria = extract_sdp_info(sdp, slice_service, connection_group_id, connectivity_construct_id) @@ -41,7 +41,7 @@ def process_connectivity(connection_group_id, connectivity_type, connectivity_co } sdps.append(sdp) - elif connectivity_type == "ietf-vpn-common:hub-spoke": + elif connectivity_type == "hub-spoke": # Process sender sender_sdp = safe_get(connectivity_construct, ["p2mp-sender-sdp"]) if sender_sdp: diff --git a/src/nbi_processor/detect_format.py b/src/nbi_processor/detect_format.py index 1bb834f..03ab9a0 100644 --- a/src/nbi_processor/detect_format.py +++ b/src/nbi_processor/detect_format.py @@ -31,7 +31,7 @@ def detect_format(json_data): - None if no recognizable format is detected """ # Check for IETF-specific key - if "ietf-network-slice-service:network-slice-services" in json_data: + if ("ietf-network-slice-service:network-slice-services" in json_data or "network-slice-services" in json_data): return "IETF" # Check for 3GPP-specific keys if any(key in json_data for key in ["NetworkSlice1", "TopSliceSubnet1", "CNSliceSubnet1", "RANSliceSubnet1"]): diff --git a/src/planner/change_scheduler_planner/change_scheduler.py b/src/planner/change_scheduler_planner/change_scheduler.py new file mode 100644 index 0000000..7eb08e4 --- /dev/null +++ b/src/planner/change_scheduler_planner/change_scheduler.py @@ -0,0 +1,318 @@ +# Copyright 2022-2026 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 +import requests +from datetime import datetime, timezone +from flask import current_app +from src.database.service_db import get_data_by_slice_id +from src.planner.shortest_path import get_shortest_path, normalize_node_id +from src.realizer.restconf.connectors.tfs_connector import tfs_connector +from src.utils.safe_get import safe_get + + +def _find_link_info(u: str, v: str, underlay_links: list) -> tuple[str, str]: + """ + Find source node and link ID in the physical topology matching node pair (u, v). + """ + norm_u = normalize_node_id(u) + norm_v = normalize_node_id(v) + + for link in underlay_links: + if not isinstance(link, dict): + continue + src_n = normalize_node_id(str(safe_get(link, ["source", "source-node"]) or "")) + dst_n = normalize_node_id(str(safe_get(link, ["destination", "dest-node"]) or "")) + if (src_n == norm_u and dst_n == norm_v) or (src_n == norm_v and dst_n == norm_u): + source_node = str(safe_get(link, ["source", "source-node"]) or u) + source_link_id = link.get("source-link-id") or link.get("link-id") or f"{u}-{v}" + return source_node, source_link_id + + return u, f"{u}-{v}" + + +def change_scheduler_planner( + slice_id: str, + current_path: list = None, + network: dict = None, + ip: str = None, + port: int = None, + change_scheduler_url: str = None +): + """ + Change Scheduler Planner: + 1. Receive or retrieve current service path and physical topology. + 2. Compute optimal path via get_shortest_path. + 3. Calculate differences in nodes and links. + 4. Construct IETF TVR topology schedule request body. + 5. Send POST request to Change Scheduler at /change-scheduler/request. + + Args: + slice_id (str): Identifier of the network slice. + current_path (list, optional): Active service path provided by realizer. + network (dict, optional): Network topology provided by realizer. + ip (str, optional): Change Scheduler IP address. + port (int, optional): Change Scheduler port. + change_scheduler_url (str, optional): Target URL for Change Scheduler service. + + Returns: + tuple[dict, int]: Result payload and HTTP status code. + """ + if not change_scheduler_url: + cs_ip = ip + cs_port = port + if not cs_ip or not cs_port: + try: + cs_ip = cs_ip or current_app.config.get("CHANGE_SCHEDULER_IP", "127.0.0.1") + cs_port = cs_port or current_app.config.get("CHANGE_SCHEDULER_PORT", 8090) + except RuntimeError: + cs_ip = cs_ip or "127.0.0.1" + cs_port = cs_port or 8090 + change_scheduler_url = f"http://{cs_ip}:{cs_port}/change-scheduler/request" + + # Fetch service path and topology if not provided directly by realizer + if not current_path or not network: + tfs_ip = "127.0.0.1" + try: + tfs_ip = current_app.config.get("RESTCONF_IP", "127.0.0.1") + except RuntimeError: + pass + + services = get_data_by_slice_id(slice_id) + if not services: + raise ValueError(f"No services found for slice '{slice_id}'.") + + service_id = services[0].get("service_id") + if not service_id: + raise ValueError(f"No valid service_id found for slice '{slice_id}'.") + + conn = tfs_connector() + + if not current_path: + path, path_code = conn.get_service_path(tfs_ip, service_id) + if path_code != 200 or not path or len(path) < 2: + logging.error(f"Failed to retrieve valid current service path for service '{service_id}'") + raise Exception(f"Could not retrieve service path for service '{service_id}'.") + current_path = path + + if not network: + net, topo_code = conn.get_network_topology(tfs_ip, slice_id) + if topo_code != 200 or not net: + logging.error(f"Failed to retrieve network topology for slice '{slice_id}'") + raise Exception(f"Could not retrieve network topology for slice '{slice_id}'.") + network = net + + if not current_path or len(current_path) < 2: + raise Exception(f"Invalid current service path for slice '{slice_id}'.") + + # Calculate optimal path + src_node = current_path[0] + dst_node = current_path[-1] + + optimal_path, opt_code = get_shortest_path(network, src_node, dst_node, directed_graph=False) + if opt_code != 200 or not optimal_path: + logging.error(f"Failed to calculate shortest path between '{src_node}' and '{dst_node}'") + raise Exception(f"Could not compute shortest path from '{src_node}' to '{dst_node}'.") + + + + # 4. Compare current_path vs optimal_path + current_nodes = set(normalize_node_id(n) for n in current_path) + optimal_nodes = set(normalize_node_id(n) for n in optimal_path) + + nodes_to_off = current_nodes - optimal_nodes + nodes_to_on = optimal_nodes - current_nodes + + current_links = [(current_path[i], current_path[i + 1]) for i in range(len(current_path) - 1)] + optimal_links = [(optimal_path[i], optimal_path[i + 1]) for i in range(len(optimal_path) - 1)] + + def canonical_link(u, v): + return tuple(sorted([normalize_node_id(u), normalize_node_id(v)])) + + current_canon = set(canonical_link(u, v) for u, v in current_links) + optimal_canon = set(canonical_link(u, v) for u, v in optimal_links) + + links_to_off = [link for link in current_links if canonical_link(*link) not in optimal_canon] + links_to_on = [link for link in optimal_links if canonical_link(*link) not in current_canon] + + underlay_links = network.get("ietf-network-topology:link", []) if isinstance(network, dict) else [] + + now_utc = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + # 5. Build TVR schedule payload + links_schedule = [] + schedule_counter = 1 + + for u, v in links_to_off: + source_node, source_link_id = _find_link_info(u, v, underlay_links) + links_schedule.append({ + "source-node": source_node, + "source-link-id": source_link_id, + "available": { + "default-link-available": "false", + "schedules": [ + { + "schedule-id": schedule_counter, + "schedule-type": { + "recurrence": { + "recurrence-first": { + "utc-start-time": now_utc, + "duration": 0 + }, + "recurrence-bound": { + "count": 1 + }, + "frequency": 1 + } + }, + "attr-value": { + "link-available": "false" + } + } + ], + "time-zone-identifier": "UTC" + } + }) + schedule_counter += 1 + + for u, v in links_to_on: + source_node, source_link_id = _find_link_info(u, v, underlay_links) + links_schedule.append({ + "source-node": source_node, + "source-link-id": source_link_id, + "available": { + "default-link-available": "true", + "schedules": [ + { + "schedule-id": schedule_counter, + "schedule-type": { + "recurrence": { + "recurrence-first": { + "utc-start-time": now_utc, + "duration": 0 + }, + "recurrence-bound": { + "count": 1 + }, + "frequency": 1 + } + }, + "attr-value": { + "link-available": "true" + } + } + ], + "time-zone-identifier": "UTC" + } + }) + schedule_counter += 1 + + nodes_schedule = [] + for node_name in sorted(nodes_to_off): + nodes_schedule.append({ + "node-id": node_name, + "available": { + "default-node-available": "false", + "schedules": [ + { + "schedule-id": schedule_counter, + "schedule-type": { + "recurrence": { + "recurrence-first": { + "utc-start-time": now_utc, + "duration": 0 + }, + "recurrence-bound": { + "count": 1 + }, + "frequency": 1 + } + }, + "attr-value": { + "node-available": "false" + } + } + ], + "time-zone-identifier": "UTC" + } + }) + schedule_counter += 1 + + for node_name in sorted(nodes_to_on): + nodes_schedule.append({ + "node-id": node_name, + "available": { + "default-node-available": "true", + "schedules": [ + { + "schedule-id": schedule_counter, + "schedule-type": { + "recurrence": { + "recurrence-first": { + "utc-start-time": now_utc, + "duration": 0 + }, + "recurrence-bound": { + "count": 1 + }, + "frequency": 1 + } + }, + "attr-value": { + "node-available": "true" + } + } + ], + "time-zone-identifier": "UTC" + } + }) + schedule_counter += 1 + + tvr_topology_schedule = { + "links": links_schedule + } + if nodes_schedule: + tvr_topology_schedule["nodes"] = nodes_schedule + + payload = { + "ietf-tvr-topology:topology-schedule": tvr_topology_schedule + } + + # 6. Send request to Change Scheduler + logging.info(f"Sending Change Scheduler request to '{change_scheduler_url}': {payload}") + headers = {"Content-Type": "application/json"} + try: + resp = requests.post(change_scheduler_url, json=payload, headers=headers, timeout=30) + status_code = resp.status_code + try: + resp_data = resp.json() + except Exception: + resp_data = resp.text + + if status_code not in (200, 201, 202): + raise Exception(f"Change Scheduler request failed with status {status_code}: {resp_data}") + + return { + "slice_id": slice_id, + "new_path": optimal_path, + "request_payload": payload, + "response": resp_data + } + except Exception as e: + logging.error(f"Error sending request to Change Scheduler at '{change_scheduler_url}': {e}") + raise Exception(f"Failed to connect to Change Scheduler: {e}") + + diff --git a/src/planner/planner.py b/src/planner/planner.py index b738910..876e27f 100644 --- a/src/planner/planner.py +++ b/src/planner/planner.py @@ -18,6 +18,7 @@ import logging from src.planner.energy_planner.energy import energy_planner from src.planner.hrat_planner.hrat import hrat_planner from src.planner.e2e_optical_planner.e2e_optical import e2e_optical_planner +from src.planner.change_scheduler_planner.change_scheduler import change_scheduler_planner from flask import current_app @@ -36,7 +37,7 @@ class Planner: Args: intent (dict): Network slice intent - type (str): Planner type (ENERGY, HRAT, TFS_OPTICAL) + type (str): Planner type (ENERGY, HRAT, TFS_OPTICAL, CHANGE_SCHEDULER) is_update (bool): Whether this is an update/modification request Returns: @@ -52,5 +53,32 @@ class Planner: elif type == "E2E_OPTICAL": action = "update" if is_update else "create" return e2e_optical_planner(intent, current_app.config["E2E_OPTICAL_IP"], action = action) + elif type == "CHANGE_SCHEDULER": + slice_id = None + current_path = None + network = None + if isinstance(intent, str): + slice_id = intent + elif isinstance(intent, dict): + slice_id = intent.get("slice_id") + current_path = intent.get("service_path") + network = intent.get("network_topology") + kwargs = {} + try: + if "CHANGE_SCHEDULER_IP" in current_app.config: + kwargs["ip"] = current_app.config["CHANGE_SCHEDULER_IP"] + if "CHANGE_SCHEDULER_PORT" in current_app.config: + kwargs["port"] = current_app.config["CHANGE_SCHEDULER_PORT"] + except RuntimeError: + pass + return change_scheduler_planner( + slice_id, + current_path=current_path, + network=network, + **kwargs + ) + + + # Return None if planner type is unsupported else : return None diff --git a/src/realizer/main.py b/src/realizer/main.py index 582cb81..f80ba3e 100644 --- a/src/realizer/main.py +++ b/src/realizer/main.py @@ -100,4 +100,32 @@ def realizer(payload, need_nrp=False, order=None, nrp=None, controller_type=None logging.debug(f"Retrieved service path for slice '{slice_id}' (service '{service_id}'): {path}") get_metrics(path, slice_id, controller_type) else: - raise Exception("Error: Service path not retrieved") \ No newline at end of file + raise Exception("Error: Service path not retrieved") + elif action == "RECONFIG": + logging.debug("Realizer action: RECONFIG") + slice_id = payload.get("slice_id", None) if isinstance(payload, dict) else payload + service_data = get_data_by_slice_id(slice_id) + logging.debug(f"DEBUG: Slice data found for slice '{slice_id}': {service_data}") + if not service_data: + raise ValueError(f"No services found for slice '{slice_id}'") + service_id = service_data[0]["service_id"] + logging.debug(f"DEBUG: Service ID for slice '{slice_id}': {service_id}") + tfs_ip = current_app.config.get("RESTCONF_IP", "127.0.0.1") + logging.debug(f"DEBUG: TFS IP: {tfs_ip}") + conn = tfs_connector() + + path, path_code = conn.get_service_path(tfs_ip, service_id) + logging.debug(f"DEBUG: Path: {path}") + if path_code != 200 or not path: + raise Exception(f"Could not retrieve service path for service '{service_id}'") + + network, topo_code = conn.get_network_topology(tfs_ip, slice_id) + logging.debug(f"DEBUG: Network topology: {network}") + if topo_code != 200 or not network: + raise Exception(f"Could not retrieve network topology for slice '{slice_id}'") + + return { + "slice_id": slice_id, + "service_path": path, + "network_topology": network + } diff --git a/src/tests/test_planner.py b/src/tests/test_planner.py index 32837f5..ecc0fba 100644 --- a/src/tests/test_planner.py +++ b/src/tests/test_planner.py @@ -1,307 +1,479 @@ -# Copyright 2022-2026 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 pytest -from unittest.mock import patch, MagicMock -import requests - -from src.planner.planner import Planner -from src.planner.shortest_path import normalize_node_id, get_shortest_path -from src.planner.energy_planner.energy import energy_planner, retrieve_energy, retrieve_topology -from src.planner.hrat_planner.hrat import hrat_planner -from src.planner.e2e_optical_planner.e2e_optical import e2e_optical_planner - - -# ============================================================================= -# 1. Tests for main Planner Dispatcher (src/planner/planner.py) -# ============================================================================= - -def test_planner_dispatch_energy(flask_app, sample_ietf_intent): - """Test Planner.planner dispatches to energy_planner when type is ENERGY.""" - planner = Planner() - with patch("src.planner.planner.energy_planner") as mock_energy: - mock_energy.return_value = ["A", "B"] - with flask_app.app_context(): - res = planner.planner(sample_ietf_intent, type="ENERGY") - assert res == ["A", "B"] - mock_energy.assert_called_once_with(sample_ietf_intent) - - -def test_planner_dispatch_hrat(flask_app, sample_ietf_intent): - """Test Planner.planner dispatches to hrat_planner when type is HRAT.""" - planner = Planner() - with patch("src.planner.planner.hrat_planner") as mock_hrat: - mock_hrat.return_value = {"viability": True} - with flask_app.app_context(): - res = planner.planner(sample_ietf_intent, type="HRAT") - assert res == {"viability": True} - mock_hrat.assert_called_once_with(sample_ietf_intent, "10.0.0.1") - - -def test_planner_dispatch_e2e_optical(flask_app, sample_ietf_intent): - """Test Planner.planner dispatches to e2e_optical_planner when type is E2E_OPTICAL.""" - planner = Planner() - with patch("src.planner.planner.e2e_optical_planner") as mock_opt: - mock_opt.return_value = {"path": ["A", "B"]} - with flask_app.app_context(): - res = planner.planner(sample_ietf_intent, type="E2E_OPTICAL", is_update=False) - assert res == {"path": ["A", "B"]} - - res_update = planner.planner(sample_ietf_intent, type="E2E_OPTICAL", is_update=True) - assert res_update == {"path": ["A", "B"]} - assert mock_opt.call_count == 2 - - -def test_planner_dispatch_invalid(flask_app, sample_ietf_intent): - """Test Planner.planner returns None for unknown strategy types.""" - planner = Planner() - with flask_app.app_context(): - res = planner.planner(sample_ietf_intent, type="INVALID_STRATEGY") - assert res is None - - -# ============================================================================= -# 2. Tests for Shortest Path Algorithm (src/planner/shortest_path.py) -# ============================================================================= - -def test_normalize_node_id(): - """Test normalization of node URN identifiers.""" - assert normalize_node_id("urn:tfs:node:A") == "A" - assert normalize_node_id("B") == "B" - assert normalize_node_id(123) == 123 - - -def test_get_shortest_path_success(): - """Test successful shortest path computation on undirected graph.""" - network = { - "node": [ - {"node-id": "urn:tfs:node:A"}, - {"node-id": "urn:tfs:node:B"}, - {"node-id": "urn:tfs:node:C"}, - ], - "ietf-network-topology:link": [ - { - "source": {"source-node": "urn:tfs:node:A"}, - "destination": {"dest-node": "urn:tfs:node:B"} - }, - { - "source": {"source-node": "urn:tfs:node:B"}, - "destination": {"dest-node": "urn:tfs:node:C"} - } - ] - } - path, code = get_shortest_path(network, "urn:tfs:node:A", "urn:tfs:node:C", directed_graph=False) - assert code == 200 - assert path == ["A", "B", "C"] - - -def test_get_shortest_path_missing_source(): - """Test error handling when source node is absent.""" - network = { - "node": [{"node-id": "B"}], - "ietf-network-topology:link": [] - } - res, code = get_shortest_path(network, "A", "B") - assert code == 404 - assert res == {"message": "Source node 'A' not found"} - - -def test_get_shortest_path_missing_destination(): - """Test error handling when destination node is absent.""" - network = { - "node": [{"node-id": "A"}], - "ietf-network-topology:link": [] - } - res, code = get_shortest_path(network, "A", "B") - assert code == 404 - assert res == {"message": "Destination node 'B' not found"} - - -def test_get_shortest_path_no_path(): - """Test error handling when destination is disconnected from source.""" - network = { - "node": [{"node-id": "A"}, {"node-id": "B"}], - "ietf-network-topology:link": [] - } - res, code = get_shortest_path(network, "A", "B") - assert code == 404 - assert res == {"message": "No path found"} - - -def test_get_shortest_path_directed(): - """Test shortest path on a directed graph.""" - network = { - "node": [{"node-id": "A"}, {"node-id": "B"}], - "ietf-network-topology:link": [ - { - "source": {"source-node": "A"}, - "destination": {"dest-node": "B"} - } - ] - } - # Path A -> B should succeed - path, code = get_shortest_path(network, "A", "B", directed_graph=True) - assert code == 200 - assert path == ["A", "B"] - - # Path B -> A should fail on directed graph - res, code = get_shortest_path(network, "B", "A", directed_graph=True) - assert code == 404 - assert res == {"message": "No path found"} - - -# ============================================================================= -# 3. Tests for Energy Planner (src/planner/energy_planner/energy.py) -# ============================================================================= - -def test_retrieve_energy_and_topology(flask_app): - """Test metric and topology dataset loading functions.""" - energy = retrieve_energy() - with flask_app.app_context(): - topology = retrieve_topology() - assert isinstance(topology, dict) - assert isinstance(energy, list) - - -def test_energy_planner_invalid_nodes(flask_app): - """Test energy planner returns None when source/dest nodes are outside allowed set.""" - intent = { - "ietf-network-slice-service:network-slice-services": { - "slice-service": [{ - "sdps": { - "sdp": [ - {"node-id": "NODE_X"}, - {"node-id": "NODE_Y"} - ] - } - }] - } - } - with flask_app.app_context(): - res = energy_planner(intent) - assert res is None - - -def test_energy_planner_internal(flask_app, sample_ietf_intent): - """Test internal Dijkstra-based energy planner execution.""" - flask_app.config["PCE_EXTERNAL"] = False - with flask_app.app_context(): - path = energy_planner(sample_ietf_intent) - assert path is not None - assert isinstance(path, list) - assert path[0] == "A" - assert path[-1] == "B" - - -def test_energy_planner_pce_external(flask_app, sample_ietf_intent): - """Test external PCE energy planner path computation.""" - flask_app.config["PCE_EXTERNAL"] = True - with flask_app.app_context(): - path = energy_planner(sample_ietf_intent) - assert path is not None - assert isinstance(path, list) - - -# ============================================================================= -# 4. Tests for HRAT Planner (src/planner/hrat_planner/hrat.py) -# ============================================================================= - -@patch("requests.post") -def test_hrat_planner_create_success(mock_post): - """Test HRAT create action success path.""" - mock_resp = MagicMock() - mock_resp.ok = True - mock_resp.json.return_value = {"network-slice-uuid": "test-uuid", "viability": True} - mock_post.return_value = mock_resp - - res = hrat_planner(data={"test": "payload"}, ip="10.0.0.1", action="create") - assert res == {"network-slice-uuid": "test-uuid", "viability": True} - mock_post.assert_called_once() - - -@patch("requests.delete") -def test_hrat_planner_delete_success(mock_delete): - """Test HRAT delete action success path.""" - mock_resp = MagicMock() - mock_resp.ok = True - mock_resp.json.return_value = {"network-slice-uuid": "slice-1", "status": "deleted"} - mock_delete.return_value = mock_resp - - res = hrat_planner(data="slice-1", ip="10.0.0.1", action="delete") - assert res == {"network-slice-uuid": "slice-1", "status": "deleted"} - mock_delete.assert_called_once() - - -def test_hrat_planner_invalid_action(): - """Test HRAT planner fallback on invalid action.""" - res = hrat_planner(data={}, ip="10.0.0.1", action="invalid_action") - assert "network-slice-uuid" in res - assert res["viability"] is True - - -@patch("requests.post") -def test_hrat_planner_http_error(mock_post): - """Test HRAT planner handles HTTP failure by returning fallback data.""" - mock_post.side_effect = requests.exceptions.RequestException("Connection refused") - res = hrat_planner(data={}, ip="10.0.0.1", action="create") - assert "network-slice-uuid" in res - assert res["viability"] is True - - -# ============================================================================= -# 5. Tests for E2E Optical Planner (src/planner/e2e_optical_planner/e2e_optical.py) -# ============================================================================= - -@patch("requests.post") -def test_e2e_optical_planner_create_success(mock_post): - """Test E2E Optical planner path creation success.""" - mock_resp = MagicMock() - mock_resp.status_code = 200 - mock_resp.json.return_value = {"path_id": "opt-1", "nodes": ["A", "B"]} - mock_post.return_value = mock_resp - - res = e2e_optical_planner(intent={"test": 1}, ip="10.0.0.1", action="create") - assert res == {"path_id": "opt-1", "nodes": ["A", "B"]} - assert "e2e_path_computation" in mock_post.call_args[0][0] - - -@patch("requests.post") -def test_e2e_optical_planner_update(mock_post): - """Test E2E Optical planner path recomputation update action.""" - mock_resp = MagicMock() - mock_resp.status_code = 201 - mock_resp.json.return_value = {"path_id": "opt-1", "updated": True} - mock_post.return_value = mock_resp - - res = e2e_optical_planner(intent={"test": 1}, ip="10.0.0.1", action="update") - assert res == {"path_id": "opt-1", "updated": True} - assert "recompute_optical_path" in mock_post.call_args[0][0] - - -@patch("requests.post") -def test_e2e_optical_planner_failure(mock_post): - """Test E2E Optical planner returns None on request failure or exception.""" - mock_resp = MagicMock() - mock_resp.status_code = 500 - mock_resp.text = "Internal Server Error" - mock_post.return_value = mock_resp - - res = e2e_optical_planner(intent={}, ip="10.0.0.1", action="create") - assert res is None - - mock_post.side_effect = requests.exceptions.Timeout("Timed out") - res_timeout = e2e_optical_planner(intent={}, ip="10.0.0.1", action="create") - assert res_timeout is None +# Copyright 2022-2026 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 pytest +from unittest.mock import patch, MagicMock +import requests + +from src.planner.planner import Planner +from src.planner.shortest_path import normalize_node_id, get_shortest_path +from src.planner.energy_planner.energy import energy_planner, retrieve_energy, retrieve_topology +from src.planner.hrat_planner.hrat import hrat_planner +from src.planner.e2e_optical_planner.e2e_optical import e2e_optical_planner +from src.planner.change_scheduler_planner.change_scheduler import change_scheduler_planner, _find_link_info +from src.main import NSController +from src.api.main import Api + + + +# ============================================================================= +# 1. Tests for main Planner Dispatcher (src/planner/planner.py) +# ============================================================================= + +def test_planner_dispatch_energy(flask_app, sample_ietf_intent): + """Test Planner.planner dispatches to energy_planner when type is ENERGY.""" + planner = Planner() + with patch("src.planner.planner.energy_planner") as mock_energy: + mock_energy.return_value = ["A", "B"] + with flask_app.app_context(): + res = planner.planner(sample_ietf_intent, type="ENERGY") + assert res == ["A", "B"] + mock_energy.assert_called_once_with(sample_ietf_intent) + + +def test_planner_dispatch_hrat(flask_app, sample_ietf_intent): + """Test Planner.planner dispatches to hrat_planner when type is HRAT.""" + planner = Planner() + with patch("src.planner.planner.hrat_planner") as mock_hrat: + mock_hrat.return_value = {"viability": True} + with flask_app.app_context(): + res = planner.planner(sample_ietf_intent, type="HRAT") + assert res == {"viability": True} + mock_hrat.assert_called_once_with(sample_ietf_intent, "10.0.0.1") + + +def test_planner_dispatch_e2e_optical(flask_app, sample_ietf_intent): + """Test Planner.planner dispatches to e2e_optical_planner when type is E2E_OPTICAL.""" + planner = Planner() + with patch("src.planner.planner.e2e_optical_planner") as mock_opt: + mock_opt.return_value = {"path": ["A", "B"]} + with flask_app.app_context(): + res = planner.planner(sample_ietf_intent, type="E2E_OPTICAL", is_update=False) + assert res == {"path": ["A", "B"]} + + res_update = planner.planner(sample_ietf_intent, type="E2E_OPTICAL", is_update=True) + assert res_update == {"path": ["A", "B"]} + assert mock_opt.call_count == 2 + + +def test_planner_dispatch_invalid(flask_app, sample_ietf_intent): + """Test Planner.planner returns None for unknown strategy types.""" + planner = Planner() + with flask_app.app_context(): + res = planner.planner(sample_ietf_intent, type="INVALID_STRATEGY") + assert res is None + + +# ============================================================================= +# 2. Tests for Shortest Path Algorithm (src/planner/shortest_path.py) +# ============================================================================= + +def test_normalize_node_id(): + """Test normalization of node URN identifiers.""" + assert normalize_node_id("urn:tfs:node:A") == "A" + assert normalize_node_id("B") == "B" + assert normalize_node_id(123) == 123 + + +def test_get_shortest_path_success(): + """Test successful shortest path computation on undirected graph.""" + network = { + "node": [ + {"node-id": "urn:tfs:node:A"}, + {"node-id": "urn:tfs:node:B"}, + {"node-id": "urn:tfs:node:C"}, + ], + "ietf-network-topology:link": [ + { + "source": {"source-node": "urn:tfs:node:A"}, + "destination": {"dest-node": "urn:tfs:node:B"} + }, + { + "source": {"source-node": "urn:tfs:node:B"}, + "destination": {"dest-node": "urn:tfs:node:C"} + } + ] + } + path, code = get_shortest_path(network, "urn:tfs:node:A", "urn:tfs:node:C", directed_graph=False) + assert code == 200 + assert path == ["A", "B", "C"] + + +def test_get_shortest_path_missing_source(): + """Test error handling when source node is absent.""" + network = { + "node": [{"node-id": "B"}], + "ietf-network-topology:link": [] + } + res, code = get_shortest_path(network, "A", "B") + assert code == 404 + assert res == {"message": "Source node 'A' not found"} + + +def test_get_shortest_path_missing_destination(): + """Test error handling when destination node is absent.""" + network = { + "node": [{"node-id": "A"}], + "ietf-network-topology:link": [] + } + res, code = get_shortest_path(network, "A", "B") + assert code == 404 + assert res == {"message": "Destination node 'B' not found"} + + +def test_get_shortest_path_no_path(): + """Test error handling when destination is disconnected from source.""" + network = { + "node": [{"node-id": "A"}, {"node-id": "B"}], + "ietf-network-topology:link": [] + } + res, code = get_shortest_path(network, "A", "B") + assert code == 404 + assert res == {"message": "No path found"} + + +def test_get_shortest_path_directed(): + """Test shortest path on a directed graph.""" + network = { + "node": [{"node-id": "A"}, {"node-id": "B"}], + "ietf-network-topology:link": [ + { + "source": {"source-node": "A"}, + "destination": {"dest-node": "B"} + } + ] + } + # Path A -> B should succeed + path, code = get_shortest_path(network, "A", "B", directed_graph=True) + assert code == 200 + assert path == ["A", "B"] + + # Path B -> A should fail on directed graph + res, code = get_shortest_path(network, "B", "A", directed_graph=True) + assert code == 404 + assert res == {"message": "No path found"} + + +# ============================================================================= +# 3. Tests for Energy Planner (src/planner/energy_planner/energy.py) +# ============================================================================= + +def test_retrieve_energy_and_topology(flask_app): + """Test metric and topology dataset loading functions.""" + energy = retrieve_energy() + with flask_app.app_context(): + topology = retrieve_topology() + assert isinstance(topology, dict) + assert isinstance(energy, list) + + +def test_energy_planner_invalid_nodes(flask_app): + """Test energy planner returns None when source/dest nodes are outside allowed set.""" + intent = { + "ietf-network-slice-service:network-slice-services": { + "slice-service": [{ + "sdps": { + "sdp": [ + {"node-id": "NODE_X"}, + {"node-id": "NODE_Y"} + ] + } + }] + } + } + with flask_app.app_context(): + res = energy_planner(intent) + assert res is None + + +def test_energy_planner_internal(flask_app, sample_ietf_intent): + """Test internal Dijkstra-based energy planner execution.""" + flask_app.config["PCE_EXTERNAL"] = False + with flask_app.app_context(): + path = energy_planner(sample_ietf_intent) + assert path is not None + assert isinstance(path, list) + assert path[0] == "A" + assert path[-1] == "B" + + +def test_energy_planner_pce_external(flask_app, sample_ietf_intent): + """Test external PCE energy planner path computation.""" + flask_app.config["PCE_EXTERNAL"] = True + with flask_app.app_context(): + path = energy_planner(sample_ietf_intent) + assert path is not None + assert isinstance(path, list) + + +# ============================================================================= +# 4. Tests for HRAT Planner (src/planner/hrat_planner/hrat.py) +# ============================================================================= + +@patch("requests.post") +def test_hrat_planner_create_success(mock_post): + """Test HRAT create action success path.""" + mock_resp = MagicMock() + mock_resp.ok = True + mock_resp.json.return_value = {"network-slice-uuid": "test-uuid", "viability": True} + mock_post.return_value = mock_resp + + res = hrat_planner(data={"test": "payload"}, ip="10.0.0.1", action="create") + assert res == {"network-slice-uuid": "test-uuid", "viability": True} + mock_post.assert_called_once() + + +@patch("requests.delete") +def test_hrat_planner_delete_success(mock_delete): + """Test HRAT delete action success path.""" + mock_resp = MagicMock() + mock_resp.ok = True + mock_resp.json.return_value = {"network-slice-uuid": "slice-1", "status": "deleted"} + mock_delete.return_value = mock_resp + + res = hrat_planner(data="slice-1", ip="10.0.0.1", action="delete") + assert res == {"network-slice-uuid": "slice-1", "status": "deleted"} + mock_delete.assert_called_once() + + +def test_hrat_planner_invalid_action(): + """Test HRAT planner fallback on invalid action.""" + res = hrat_planner(data={}, ip="10.0.0.1", action="invalid_action") + assert "network-slice-uuid" in res + assert res["viability"] is True + + +@patch("requests.post") +def test_hrat_planner_http_error(mock_post): + """Test HRAT planner handles HTTP failure by returning fallback data.""" + mock_post.side_effect = requests.exceptions.RequestException("Connection refused") + res = hrat_planner(data={}, ip="10.0.0.1", action="create") + assert "network-slice-uuid" in res + assert res["viability"] is True + + +# ============================================================================= +# 5. Tests for E2E Optical Planner (src/planner/e2e_optical_planner/e2e_optical.py) +# ============================================================================= + +@patch("requests.post") +def test_e2e_optical_planner_create_success(mock_post): + """Test E2E Optical planner path creation success.""" + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = {"path_id": "opt-1", "nodes": ["A", "B"]} + mock_post.return_value = mock_resp + + res = e2e_optical_planner(intent={"test": 1}, ip="10.0.0.1", action="create") + assert res == {"path_id": "opt-1", "nodes": ["A", "B"]} + assert "e2e_path_computation" in mock_post.call_args[0][0] + + +@patch("requests.post") +def test_e2e_optical_planner_update(mock_post): + """Test E2E Optical planner path recomputation update action.""" + mock_resp = MagicMock() + mock_resp.status_code = 201 + mock_resp.json.return_value = {"path_id": "opt-1", "updated": True} + mock_post.return_value = mock_resp + + res = e2e_optical_planner(intent={"test": 1}, ip="10.0.0.1", action="update") + assert res == {"path_id": "opt-1", "updated": True} + assert "recompute_optical_path" in mock_post.call_args[0][0] + + +@patch("requests.post") +def test_e2e_optical_planner_failure(mock_post): + """Test E2E Optical planner returns None on request failure or exception.""" + mock_resp = MagicMock() + mock_resp.status_code = 500 + mock_resp.text = "Internal Server Error" + mock_post.return_value = mock_resp + + res = e2e_optical_planner(intent={}, ip="10.0.0.1", action="create") + assert res is None + + mock_post.side_effect = requests.exceptions.Timeout("Timed out") + res_timeout = e2e_optical_planner(intent={}, ip="10.0.0.1", action="create") + assert res_timeout is None + + +# ============================================================================= +# 6. Tests for Change Scheduler Planner +# ============================================================================= + +@pytest.fixture +def change_scheduler_sample_network(): + return { + "node": [ + {"node-id": "urn:tfs:node:xrv11", "ietf-l3-unicast-topology:l3-node-attributes": {"name": "xrv11"}}, + {"node-id": "urn:tfs:node:xrv12", "ietf-l3-unicast-topology:l3-node-attributes": {"name": "xrv12"}}, + {"node-id": "urn:tfs:node:xrv13", "ietf-l3-unicast-topology:l3-node-attributes": {"name": "xrv13"}}, + {"node-id": "urn:tfs:node:xrv14", "ietf-l3-unicast-topology:l3-node-attributes": {"name": "xrv14"}}, + {"node-id": "urn:tfs:node:xrv15", "ietf-l3-unicast-topology:l3-node-attributes": {"name": "xrv15"}}, + ], + "ietf-network-topology:link": [ + { + "link-id": "xrv11-Gi0/0/0/1-xrv12-Gi0/0/0/1", + "source": {"source-node": "urn:tfs:node:xrv11"}, + "destination": {"dest-node": "urn:tfs:node:xrv12"} + }, + { + "link-id": "xrv12-Gi0/0/0/1-xrv13-Gi0/0/0/1", + "source": {"source-node": "urn:tfs:node:xrv12"}, + "destination": {"dest-node": "urn:tfs:node:xrv13"} + }, + { + "link-id": "xrv13-Gi0/0/0/1-xrv14-Gi0/0/0/1", + "source": {"source-node": "urn:tfs:node:xrv13"}, + "destination": {"dest-node": "urn:tfs:node:xrv14"} + }, + { + "link-id": "xrv11-Gi0/0/0/1-xrv15-Gi0/0/0/1", + "source": {"source-node": "urn:tfs:node:xrv11"}, + "destination": {"dest-node": "urn:tfs:node:xrv15"} + }, + { + "link-id": "xrv15-Gi0/0/0/1-xrv14-Gi0/0/0/1", + "source": {"source-node": "urn:tfs:node:xrv15"}, + "destination": {"dest-node": "urn:tfs:node:xrv14"} + }, + ] + } + + +def test_find_link_info(change_scheduler_sample_network): + underlay_links = change_scheduler_sample_network["ietf-network-topology:link"] + src_node, link_id = _find_link_info("xrv13", "xrv14", underlay_links) + assert src_node == "urn:tfs:node:xrv13" + assert link_id == "xrv13-Gi0/0/0/1-xrv14-Gi0/0/0/1" + + src_node_unk, link_id_unk = _find_link_info("A", "B", underlay_links) + assert src_node_unk == "A" + assert link_id_unk == "A-B" + + +def test_change_scheduler_planner_success(flask_app, change_scheduler_sample_network): + with patch("src.planner.change_scheduler_planner.change_scheduler.get_data_by_slice_id") as mock_db, \ + patch("src.planner.change_scheduler_planner.change_scheduler.tfs_connector") as mock_conn_cls, \ + patch("src.planner.change_scheduler_planner.change_scheduler.get_shortest_path") as mock_sp, \ + patch("requests.post") as mock_post: + + mock_db.return_value = [{"service_id": "svc-100", "slice_id": "slice-1"}] + + mock_conn = MagicMock() + mock_conn.get_service_path.return_value = (["xrv11", "xrv12", "xrv13", "xrv14"], 200) + mock_conn.get_network_topology.return_value = (change_scheduler_sample_network, 200) + mock_conn_cls.return_value = mock_conn + + mock_sp.return_value = (["xrv11", "xrv15", "xrv14"], 200) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.headers = {"content-type": "application/json"} + mock_response.json.return_value = {"status": "SCHEDULED"} + mock_post.return_value = mock_response + res = change_scheduler_planner("slice-1", change_scheduler_url="http://127.0.0.1:8090/change-scheduler/request") + + assert res["slice_id"] == "slice-1" + assert res["new_path"] == ["xrv11", "xrv15", "xrv14"] + + payload = res["request_payload"] + assert "ietf-tvr-topology:topology-schedule" in payload + schedule = payload["ietf-tvr-topology:topology-schedule"] + + assert "links" in schedule + assert "nodes" in schedule + + link_availabilities = [link["available"]["default-link-available"] for link in schedule["links"]] + assert "false" in link_availabilities + assert "true" in link_availabilities + + node_availabilities = {node["node-id"]: node["available"]["default-node-available"] for node in schedule["nodes"]} + assert node_availabilities.get("xrv12") == "false" + assert node_availabilities.get("xrv13") == "false" + assert node_availabilities.get("xrv15") == "true" + + mock_post.assert_called_once() + call_url = mock_post.call_args[0][0] + assert call_url == "http://127.0.0.1:8090/change-scheduler/request" + + +def test_change_scheduler_planner_no_services(): + with patch("src.planner.change_scheduler_planner.change_scheduler.get_data_by_slice_id") as mock_db: + mock_db.side_effect = ValueError("No services found") + + with pytest.raises(ValueError) as exc: + change_scheduler_planner("nonexistent-slice") + assert "No services found" in str(exc.value) + + +def test_change_scheduler_planner_service_path_failure(flask_app): + with patch("src.planner.change_scheduler_planner.change_scheduler.get_data_by_slice_id") as mock_db, \ + patch("src.planner.change_scheduler_planner.change_scheduler.tfs_connector") as mock_conn_cls: + + mock_db.return_value = [{"service_id": "svc-100", "slice_id": "slice-1"}] + mock_conn = MagicMock() + mock_conn.get_service_path.return_value = ([], 404) + mock_conn_cls.return_value = mock_conn + + with pytest.raises(Exception) as exc: + change_scheduler_planner("slice-1") + assert "Could not retrieve service path" in str(exc.value) + + +def test_reconfig_slice_main(flask_app): + with patch("src.main.realizer") as mock_realizer, \ + patch("src.planner.planner.change_scheduler_planner") as mock_planner: + mock_realizer.return_value = { + "slice_id": "slice-123", + "service_path": ["xrv11", "xrv12"], + "network_topology": {} + } + mock_planner.return_value = {"slice_id": "slice-123", "new_path": ["xrv11"]} + + nsc = NSController(controller_type="TFS") + res = nsc.reconfig_slice("slice-123") + + assert res["slice_id"] == "slice-123" + mock_realizer.assert_called_once_with({"slice_id": "slice-123"}, action="RECONFIG", controller_type="TFS") + mock_planner.assert_called_once() + + +def test_planner_class_change_scheduler(): + with patch("src.planner.planner.change_scheduler_planner") as mock_planner: + mock_planner.return_value = {"slice_id": "slice-999", "new_path": ["A", "B"]} + + p = Planner() + res = p.planner("slice-999", type="CHANGE_SCHEDULER") + + assert res == {"slice_id": "slice-999", "new_path": ["A", "B"]} + mock_planner.assert_called_once_with("slice-999", current_path=None, network=None) + + +def test_api_reconfig_slice(flask_app): + with patch("src.main.NSController.reconfig_slice") as mock_nsc: + mock_nsc.return_value = {"slice_id": "slice-1", "new_path": ["xrv11", "xrv15"]} + + nsc = NSController(controller_type="TFS") + api = Api(nsc) + res, code = api.reconfig_slice("slice-1") + + assert code == 200 + assert res["success"] is True + assert res["data"] == {"slice_id": "slice-1", "new_path": ["xrv11", "xrv15"]} + + diff --git a/swagger/restconf_namespace.py b/swagger/restconf_namespace.py index 423972b..df361e5 100644 --- a/swagger/restconf_namespace.py +++ b/swagger/restconf_namespace.py @@ -402,4 +402,39 @@ class Telemetry(Resource): def get(self, slice_id): controller = NSController(controller_type="RESTCONF") logging.info(f"Retrieving latest telemetry for slice '{slice_id}'") - return current_app.ensure_sync(Api(controller).get_telemetry)(slice_id=slice_id) \ No newline at end of file + return current_app.ensure_sync(Api(controller).get_telemetry)(slice_id=slice_id) + + +# Models for Slice Reconfiguration +slice_reconfig_data_model = restconf_ns.model( + "RestconfSliceReconfigData", + { + "slice_id": fields.String(description="ID of the reconfigured network slice", example="slice-1"), + "new_path": fields.List(fields.String, description="New optimal path for traffic after reconfiguration", example=["xrv11", "xrv15", "xrv14"]), + "request_payload": fields.Raw(description="Topology schedule request payload sent to Change Scheduler"), + "response": fields.Raw(description="Response returned by Change Scheduler service") + } +) + +slice_reconfig_response_model = restconf_ns.model( + "RestconfSliceReconfigResponse", + { + "success": fields.Boolean(description="Indicates whether the reconfiguration request succeeded", example=True), + "data": fields.Nested(slice_reconfig_data_model, description="Reconfiguration response details"), + "error": fields.String(description="Error message if any", example=None) + } +) + + +@restconf_ns.route("/operations/ietf-network-slice-service:network-slice-services/slice-service=/reconfigure") +@restconf_ns.doc(params={"slice_service_id": "The ID of the slice to reconfigure"}) +class RestconfSliceReconfig(Resource): + @restconf_ns.doc(summary="Reconfigure a specific transport network slice", description="Computes shortest path, compares with current service path, and schedules topology changes with Change Scheduler using RESTCONF controller.") + @restconf_ns.response(200, "Slice reconfigured successfully", slice_reconfig_response_model) + @restconf_ns.response(404, "Transport network slice or service not found.") + @restconf_ns.response(500, "Internal server error") + def post(self, slice_service_id): + """Reconfigure a slice using Change Scheduler Planner (RESTCONF controller)""" + controller = NSController(controller_type="RESTCONF") + data, code = Api(controller).reconfig_slice(slice_service_id) + return data, code -- GitLab From 26bbcd21cd6e9dceee2806bc1cb140e47851c340 Mon Sep 17 00:00:00 2001 From: velazquez Date: Wed, 19 Aug 2026 12:51:57 +0200 Subject: [PATCH 2/7] Code refactoring --- .gitignore | 1 + src/api/main.py | 397 +++++----- src/config/config.py | 39 +- src/config/constants.py | 28 +- src/database/alert_db.py | 131 ++-- src/database/db.py | 284 ++++--- src/database/service_db.py | 239 +++--- src/database/store_data.py | 87 ++- src/database/sysrepo_store.py | 129 ++-- src/database/telemetry_client_db.py | 550 ++++++------- src/main.py | 353 +++++---- src/mapper/aggregate_monitoring.py | 143 ++-- src/mapper/extract_sdp_info.py | 1 + src/mapper/get_service_template.py | 2 + src/mapper/main.py | 388 +++++----- src/mapper/process_connnectivity.py | 253 +++--- src/mapper/slo_viability.py | 155 ++-- src/nbi_processor/detect_format.py | 91 ++- src/nbi_processor/main.py | 111 ++- src/nbi_processor/translator.py | 232 +++--- .../change_scheduler.py | 4 +- .../e2e_optical_planner/e2e_optical.py | 5 +- src/planner/energy_planner/energy.py | 726 ++++++++---------- src/planner/hrat_planner/hrat.py | 5 +- src/planner/planner.py | 129 ++-- src/planner/shortest_path.py | 204 ++--- src/realizer/e2e/e2e_connect.py | 6 +- src/realizer/e2e/main.py | 2 + .../e2e/service_types/del_l3ipowdm_slice.py | 14 +- .../e2e/service_types/l3ipowdm_slice.py | 5 +- src/realizer/get_metrics.py | 7 +- src/realizer/ixia/helpers/NEII_V4.py | 6 +- .../ixia/helpers/automatizacion_ne2v4.py | 3 +- src/realizer/ixia/ixia_connect.py | 1 + src/realizer/ixia/main.py | 3 +- src/realizer/main.py | 268 ++++--- src/realizer/nrp_handler.py | 6 +- .../restconf/connectors/cisco_connector.py | 6 +- .../restconf/connectors/frr_connector.py | 6 +- .../restconf/connectors/tfs_connector.py | 25 +- src/realizer/restconf/main.py | 2 + src/realizer/restconf/restconf_connect.py | 16 +- .../builders/configure_match_criteria.py | 1 + .../service_types/builders/configure_slos.py | 3 + .../builders/create_site_from_sdp.py | 5 +- src/realizer/restconf/service_types/l2vpn.py | 4 +- src/realizer/restconf/service_types/l3vpn.py | 4 +- src/realizer/select_way.py | 111 +-- src/realizer/send_controller.py | 89 +-- src/realizer/tfs/helpers/cisco_connector.py | 4 +- src/realizer/tfs/helpers/tfs_connector.py | 9 +- src/realizer/tfs/main.py | 2 + src/realizer/tfs/service_types/tfs_l2vpn.py | 15 +- src/realizer/tfs/service_types/tfs_l3vpn.py | 14 +- src/realizer/tfs/tfs_connect.py | 5 +- src/tests/conftest.py | 13 +- src/tests/test_api.py | 12 +- src/tests/test_database.py | 61 +- src/tests/test_e2e.py | 10 +- src/tests/test_initialization.py | 8 +- src/tests/test_mapper.py | 5 +- src/tests/test_namespaces.py | 4 +- src/tests/test_nbi_processor.py | 7 +- src/tests/test_planner.py | 26 +- src/tests/test_realizer.py | 34 +- src/tests/test_utils.py | 9 +- src/tests/test_webui.py | 3 +- src/utils/build_response.py | 277 ++++--- src/utils/dump_templates.py | 114 ++- src/utils/load_template.py | 92 ++- src/utils/safe_get.py | 28 +- src/utils/send_response.py | 120 +-- src/utils/slice_manager.py | 135 ++-- src/webui/gui.py | 230 +++--- 74 files changed, 3384 insertions(+), 3133 deletions(-) diff --git a/.gitignore b/.gitignore index 2987aa2..4e1845e 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ telemetry_client.db alert.db .python-version .agents/ +.coverage diff --git a/src/api/main.py b/src/api/main.py index e045333..c8f7ce6 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -14,217 +14,213 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. -from src.utils.send_response import send_response -import logging, json, asyncio +import asyncio +import json +import logging +from pathlib import Path +from typing import Any + from flask import current_app -from src.database.db import get_data, delete_data, get_all_data, delete_all_data -from src.database.service_db import delete_by_slice_id, get_data_by_slice_id -from src.database.telemetry_client_db import create_client, get_client, get_all_clients, delete_client, delete_all_clients, upsert_subscription, get_subscription, get_client_subscriptions, delete_subscription, delete_all_subscriptions -import src.database.alert_db as alert_db + +from src.database import alert_db +from src.database.db import ( + delete_all_data, + delete_data, + get_all_data, + get_data, + get_slice_id_by_subscription, +) +from src.database.service_db import ( + delete_by_slice_id, + get_data_by_slice_id, +) +from src.database.service_db import ( + get_data as get_service_db_data, +) +from src.database.sysrepo_store import ( + create_data_store, + delete_data_store, + get_data_store, + normalize_libyang_data, + update_data_store, +) +from src.database.telemetry_client_db import ( + create_client, + delete_all_clients, + delete_all_subscriptions, + delete_client, + delete_subscription, + get_all_clients, + get_client, + get_client_subscriptions, + get_subscription, + upsert_subscription, +) +from src.realizer.restconf.connectors.tfs_connector import ( + tfs_connector as tfs_restconf_connector, +) from src.realizer.tfs.helpers.tfs_connector import tfs_connector -from src.utils.safe_get import safe_get -from src.database.sysrepo_store import get_data_store, create_data_store, delete_data_store, update_data_store, normalize_libyang_data -from typing import Dict, Tuple -from src.realizer.restconf.connectors.tfs_connector import tfs_connector as tfs_restconf_connector from src.realizer.tfs.service_types.tfs_l2vpn import tfs_l2vpn_delete +from src.utils.safe_get import safe_get +from src.utils.send_response import send_response +def _parse_alert_notification(alert_data: dict[str, Any]) -> tuple[str | None, str | None, str | None]: + """Extract alert_id, subscription_id, and service_id from TAPI alert payload.""" + context = alert_data.get("tapi-notification:notification-context", []) + if context and isinstance(context, list): + notification = context[0].get("tapi-notification:notification", {}) + alert_id = notification.get("uuid") + subscription_id = alert_id + additional_info = notification.get("additional-info", {}) + service_id = additional_info.get("service-id") + return alert_id, subscription_id, service_id + return None, None, None -class Api: - def __init__(self, slice_service): - self.slice_service = slice_service +def _find_slice_for_alert(subscription_id: str | None, service_id: str | None) -> dict[str, Any] | None: + """Resolve slice information for an alert by subscription, service_id, database, or fallback file.""" + slice_id = None + slice_info = None - def add_flow(self, intent): - """ - Create a new transport network slice. + if subscription_id: + try: + mapped_slice_id = get_slice_id_by_subscription(subscription_id) + if mapped_slice_id: + slice_id = mapped_slice_id + logging.info(f"Found slice_id {slice_id} mapped to subscription_id {subscription_id}") + except Exception as e: + logging.info(f"Subscription mapping lookup failed: {e}") - Args: - intent (dict): Network slice intent in 3GPP or IETF format + if not slice_id and service_id: + try: + service_info = get_service_db_data(service_id) + slice_id = service_info.get("slice_id") + logging.info(f"Found slice_id {slice_id} in service_db for service_id {service_id}") + except Exception as e: + logging.info(f"service_db lookup failed: {e}") + slice_id = service_id - Returns: - Result of the Network Slice Controller (NSC) operation + if slice_id: + try: + slice_info = get_data(slice_id) + logging.info(f"Found slice_info in db by slice_id {slice_id}") + except Exception as e: + logging.info(f"db lookup by slice_id {slice_id} failed: {e}") - API Endpoint: - POST /slice + if not slice_info: + try: + slices = get_all_data() + logging.info(f"Slices in db: {[s.get('slice_id') for s in slices]}") + for s in slices: + if s.get("slice_id") == slice_id: + slice_info = s + break + if not slice_info and slices: + slice_info = slices[0] + logging.info(f"Defaulted to first slice from db: {slice_info.get('slice_id')}") + except Exception as e: + logging.info(f"db get_all_data lookup failed: {e}") - Raises: - RuntimeError: If there is no content to process - Exception: For unexpected errors - """ + if not slice_info: + fallback_path = Path("/home/llmserver/tfs-nsc/intent.json") + if fallback_path.exists(): + try: + with fallback_path.open("r", encoding="utf-8") as f: + intent_data = json.load(f) + slice_info = {"slice_id": slice_id or "slice", "intent": intent_data} + logging.info("Loaded fallback intent from intent.json") + except Exception as e: + logging.error(f"Failed to read fallback intent.json: {e}") + + return slice_info + + +def _swap_p2mp_endpoints(intent: dict[str, Any]) -> tuple[bool, str | None]: + """Modify P2MP receiver endpoints to alternate paths following an alert.""" + nss = intent.get("ietf-network-slice-service:network-slice-services", {}) + slice_services = nss.get("slice-service", []) + modified = False + old_service_id = None + + for service in slice_services: + sdp_list = service.get("sdps", {}).get("sdp", []) + sdp_ids = [sdp.get("id") for sdp in sdp_list if sdp.get("id")] + + connection_groups = service.get("connection-groups", {}).get("connection-group", []) + for cg in connection_groups: + connectivity_constructs = cg.get("connectivity-construct", []) + for cc in connectivity_constructs: + p2mp_sender = cc.get("p2mp-sender-sdp") + p2mp_receivers = cc.get("p2mp-receiver-sdp", []) + + logging.info( + f"sdp_ids: {sdp_ids}, p2mp_sender: {p2mp_sender}, p2mp_receivers: {p2mp_receivers}" + ) + + other_endpoints = [ + sdp_id for sdp_id in sdp_ids if sdp_id != p2mp_sender and sdp_id not in p2mp_receivers + ] + + if other_endpoints and p2mp_receivers: + new_receiver = other_endpoints[0] + if len(p2mp_receivers) >= 2: + cc["p2mp-receiver-sdp"] = [p2mp_receivers[0], new_receiver] + else: + cc["p2mp-receiver-sdp"] = [new_receiver] + modified = True + logging.info(f"ORIGEN: {p2mp_sender} DESTINO: {new_receiver}") + else: + logging.warning("No alternative receiver endpoints found to swap.") + + if p2mp_receivers: + old_service_id = f"{p2mp_sender}_to_{','.join(p2mp_receivers)}" + + return modified, old_service_id + + +class Api: + """Network Slice Controller REST API service handler.""" + + def __init__(self, slice_service: Any) -> None: + self.slice_service = slice_service + + def add_flow(self, intent: dict[str, Any]) -> tuple[dict[str, Any], int]: + """Create a new transport network slice.""" try: result = self.slice_service.nsc(intent) if not result: return send_response(False, code=404, message="No intents found") if isinstance(result, tuple): return result - logging.info(f"Slice created successfully") - return send_response( - True, - code=201, - data=result - ) + logging.info("Slice created successfully") + return send_response(True, code=201, data=result) except RuntimeError as e: - # Handle case where there is no content to process return send_response(False, code=200, message=str(e)) except Exception as e: - # Handle unexpected errors return send_response(False, code=500, message=str(e)) - - def receive_alert(self, alert_data): - """ - Receive and process an alert. - Args: - alert_data (dict): The alert payload - - Returns: - Result of the operation - """ + def receive_alert(self, alert_data: dict[str, Any]) -> tuple[dict[str, Any], int]: + """Receive and process an incoming TAPI network alert.""" try: logging.info(f"Alert received: {alert_data}") - # Extract uuid if exists - context = alert_data.get("tapi-notification:notification-context", []) - alert_id = None - service_id = None - subscription_id = None - if context and isinstance(context, list): - notification = context[0].get("tapi-notification:notification", {}) - alert_id = notification.get("uuid") - subscription_id = alert_id - additional_info = notification.get("additional-info", {}) - service_id = additional_info.get("service-id") - + alert_id, subscription_id, service_id = _parse_alert_notification(alert_data) + if not alert_id: return send_response(False, code=400, message="UUID not found in alert data") - - # Save alert to DB - alert_db.save_alert(alert_id, alert_data) - - # Process intent modification based on alert - slice_id = None - slice_info = None + alert_db.save_alert(alert_id, alert_data) logging.info(f"Looking up intent for subscription_id: {subscription_id}, service_id: {service_id}") - if subscription_id: - try: - import src.database.db as db - mapped_slice_id = db.get_slice_id_by_subscription(subscription_id) - if mapped_slice_id: - slice_id = mapped_slice_id - logging.info(f"Found slice_id {slice_id} mapped to subscription_id {subscription_id}") - except Exception as e: - logging.info(f"Subscription mapping lookup failed: {e}") - - if not slice_id and service_id: - # 1. Try to find slice_id from service_db - try: - import src.database.service_db as service_db - service_info = service_db.get_data(service_id) - slice_id = service_info.get("slice_id") - logging.info(f"Found slice_id {slice_id} in service_db for service_id {service_id}") - except Exception as e: - logging.info(f"service_db lookup failed: {e}") - slice_id = service_id - - if slice_id: - # 2. Try to get slice data from db - try: - import src.database.db as db - slice_info = db.get_data(slice_id) - logging.info(f"Found slice_info in db by slice_id {slice_id}") - except Exception as e: - logging.info(f"db lookup by slice_id {slice_id} failed: {e}") - - # Fallback: if not found by ID, look up any existing slice in the DB - if not slice_info: - try: - import src.database.db as db - slices = db.get_all_data() - logging.info(f"Slices in db: {[s.get('slice_id') for s in slices]}") - for s in slices: - if s.get("slice_id") == slice_id: - slice_info = s - break - if not slice_info and slices: - # Default to the first/only slice if there's any - slice_info = slices[0] - logging.info(f"Defaulted to first slice from db: {slice_info.get('slice_id')}") - except Exception as e: - logging.info(f"db get_all_data lookup failed: {e}") - - # Fallback: read from /home/llmserver/tfs-nsc/intent.json if DB is empty - if not slice_info: - import os - import json - fallback_path = "/home/llmserver/tfs-nsc/intent.json" - if os.path.exists(fallback_path): - try: - with open(fallback_path, "r") as f: - intent_data = json.load(f) - slice_info = {"slice_id": slice_id or "slice", "intent": intent_data} - logging.info("Loaded fallback intent from intent.json") - except Exception as e: - logging.error(f"Failed to read fallback intent.json: {e}") - + slice_info = _find_slice_for_alert(subscription_id, service_id) if slice_info: intent = slice_info.get("intent") curr_slice_id = slice_info.get("slice_id") logging.info(f"Processing intent for slice {curr_slice_id}") - + if intent: - nss = intent.get("ietf-network-slice-service:network-slice-services", {}) - slice_services = nss.get("slice-service", []) - modified = False - - for service in slice_services: - # Get all SDP IDs in this slice service - sdp_list = service.get("sdps", {}).get("sdp", []) - sdp_ids = [sdp.get("id") for sdp in sdp_list if sdp.get("id")] - - connection_groups = service.get("connection-groups", {}).get("connection-group", []) - for cg in connection_groups: - connectivity_constructs = cg.get("connectivity-construct", []) - for cc in connectivity_constructs: - p2mp_sender = cc.get("p2mp-sender-sdp") - p2mp_receivers = cc.get("p2mp-receiver-sdp", []) - - logging.info(f"sdp_ids: {sdp_ids}, p2mp_sender: {p2mp_sender}, p2mp_receivers: {p2mp_receivers}") - - # Find alternative receiver endpoints - other_endpoints = [ - sdp_id for sdp_id in sdp_ids - if sdp_id != p2mp_sender and sdp_id not in p2mp_receivers - ] - - old_receiver = None - new_receiver = None - - if other_endpoints and p2mp_receivers: - if len(p2mp_receivers) >= 2: - old_receiver = p2mp_receivers[1] - new_receiver = other_endpoints[0] - cc["p2mp-receiver-sdp"] = [p2mp_receivers[0], new_receiver] - else: - old_receiver = p2mp_receivers[0] - new_receiver = other_endpoints[0] - cc["p2mp-receiver-sdp"] = [new_receiver] - modified = True - - # Log ORIGEN and DESTINO - logging.info(f"ORIGEN: {p2mp_sender} DESTINO: {new_receiver}") - print(f"ORIGEN: {p2mp_sender} DESTINO: {new_receiver}", flush=True) - else: - logging.warning("No alternative receiver endpoints found to swap.") - - if p2mp_receivers: - old_service_id = f"{p2mp_sender}_to_{','.join(p2mp_receivers)}" - else: - old_service_id = None - + modified, old_service_id = _swap_p2mp_endpoints(intent) if modified: - # Re-apply the modified intent via slice_service.nsc try: self.slice_service.nsc(intent, curr_slice_id, old_service_id=old_service_id) logging.info(f"Slice {curr_slice_id} updated successfully following alert.") @@ -237,11 +233,12 @@ class Api: True, code=201, message="Alert processed and saved successfully", - data=alert_data + data=alert_data, ) except Exception as e: return send_response(False, code=500, message=str(e)) + def get_alerts(self, alert_id=None): """ Retrieve alert(s). @@ -418,7 +415,7 @@ class Api: slice_type = safe_get(slice, ['intent', 'ietf-network-slice-service:network-slice-services', 'slice-service', 0, 'service-tags', 'tag-type', 0, 'tag-type-value', 0]) if not slice_type: slice_type = "L2" - logging.warning(f"Slice type not found in slice intent. Defaulting to L2") + logging.warning("Slice type not found in slice intent. Defaulting to L2") tfs_connector().nbi_delete(current_app.config["TFS_IP"],slice_type, slice_id) # Update slice database delete_data(slice_id) @@ -436,7 +433,7 @@ class Api: slice_type = safe_get(slice, ['intent', 'ietf-network-slice-service:network-slice-services', 'slice-service', 0, 'service-tags', 'tag-type', 0, 'tag-type-value', 0]) if not slice_type: slice_type = "L2" - logging.warning(f"Slice type not found in slice intent. Defaulting to L2") + logging.warning("Slice type not found in slice intent. Defaulting to L2") tfs_connector().nbi_delete(current_app.config["TFS_IP"],slice_type, slice.get("slice_id")) if current_app.config["TFS_L2VPN_SUPPORT"]: tfs_l2vpn_delete() @@ -555,7 +552,7 @@ class Api: create_data_store(intent) except Exception as ds_err: logging.warning(f"Could not store intent in sysrepo datastore: {ds_err}") - logging.info(f"Network Slice created successfully") + logging.info("Network Slice created successfully") return send_response( True, code=201, @@ -577,7 +574,7 @@ class Api: if existing_template: return send_response(False, code=409, message="Template already exists") create_data_store(template, xpath) - logging.info(f"Template created successfully") + logging.info("Template created successfully") return send_response( True, code=201, @@ -625,7 +622,7 @@ class Api: if result: intent.pop("id", None) create_data_store(intent, xpath) - logging.info(f"Slice created successfully") + logging.info("Slice created successfully") return send_response( True, code=201, @@ -647,7 +644,7 @@ class Api: if existing_sdp: return send_response(False, code=409, message="SDP already exists") create_data_store(sdp, xpath) - logging.info(f"SDP created successfully") + logging.info("SDP created successfully") return send_response( True, code=201, @@ -862,7 +859,7 @@ class Api: def delete_network_slice_services(self): try: - xpath = f"/ietf-network-slice-service:network-slice-services" + xpath = "/ietf-network-slice-service:network-slice-services" if not current_app.config["DUMMY_MODE"]: content = get_data_store(xpath) slice_services = safe_get(content, ["network-slice-services", "slice-service"]) @@ -872,13 +869,14 @@ class Api: slice_type = list(slice["service-tags"]["tag-type"]["ietf-network-slice-service:service"]["tag-type-value"])[0] if not slice_type: slice_type = "L2" - logging.warning(f"Slice type not found in slice intent. Defaulting to L2") + logging.warning("Slice type not found in slice intent. Defaulting to L2") logging.debug(f"Send slice to delete in TFS with slice_type {slice_type}") services = get_data_by_slice_id(slice.get("id")) - for service in services: - id = service.get("service_id") - tfs_connector().nbi_delete(current_app.config["RESTCONF_IP"], slice_type, id) - delete_by_slice_id(slice.get("id")) + if services: + for service in services: + id = service.get("service_id") + tfs_connector().nbi_delete(current_app.config["RESTCONF_IP"], slice_type, id) + delete_by_slice_id(slice.get("id")) if current_app.config["TFS_L2VPN_SUPPORT"]: self.slice_service.tfs_l2vpn_delete() @@ -906,7 +904,7 @@ class Api: # Delete all templates else: - xpath = f"/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template" + xpath = "/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template" delete_data_store(xpath) logging.info("All templates removed successfully") return {}, 204 @@ -928,10 +926,11 @@ class Api: slice_type = safe_get(existing_slice, ["network-slice-services", "slice-service", slice_id, "service-tags", "tag-type", "ietf-network-slice-service:service", "tag-type-value", 0]) or "L2" logging.debug(f"Send slice to delete in TFS with slice_type {slice_type}") services = get_data_by_slice_id(slice_id) - for service in services: - id = service.get("service_id") - tfs_connector().nbi_delete(current_app.config["RESTCONF_IP"], slice_type, id) - delete_by_slice_id(slice_id) + if services: + for service in services: + id = service.get("service_id") + tfs_connector().nbi_delete(current_app.config["RESTCONF_IP"], slice_type, id) + delete_by_slice_id(slice_id) if current_app.config["TFS_L2VPN_SUPPORT"]: tfs_l2vpn_delete() @@ -941,7 +940,7 @@ class Api: # Delete all slices else: - xpath = f"/ietf-network-slice-service:network-slice-services/slice-service" + xpath = "/ietf-network-slice-service:network-slice-services/slice-service" if not current_app.config["DUMMY_MODE"]: content = get_data_store(xpath) slice_services = safe_get(content, ["network-slice-services", "slice-service"]) @@ -951,15 +950,17 @@ class Api: slice_type = safe_get(slice, ["service-tags", "tag-type", "ietf-network-slice-service:service", "tag-type-value", 0]) or "L2" logging.debug(f"Send slice to delete in TFS with slice_type {slice_type}") services = get_data_by_slice_id(slice.get("id")) - for service in services: - id = service.get("service_id") - tfs_connector().nbi_delete(current_app.config["RESTCONF_IP"], slice_type, id) - delete_by_slice_id(slice.get("id")) + if services: + for service in services: + id = service.get("service_id") + tfs_connector().nbi_delete(current_app.config["RESTCONF_IP"], slice_type, id) + delete_by_slice_id(slice.get("id")) if current_app.config["TFS_L2VPN_SUPPORT"]: tfs_l2vpn_delete() delete_data_store(xpath) logging.info("All slices removed successfully") return {}, 204 + except ValueError as e: return send_response(False, code=404, message=str(e)) diff --git a/src/config/config.py b/src/config/config.py index 89aea6b..37c6617 100644 --- a/src/config/config.py +++ b/src/config/config.py @@ -14,15 +14,17 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. +import logging import os +from typing import Final + from dotenv import load_dotenv from flask import Flask -import logging # Load .env file if present load_dotenv() -LOG_LEVELS = { +LOG_LEVELS: Final[dict[str, int]] = { "CRITICAL": logging.CRITICAL, "ERROR": logging.ERROR, "WARNING": logging.WARNING, @@ -31,33 +33,43 @@ LOG_LEVELS = { "NOTSET": logging.NOTSET, } -def create_config(app: Flask): - """Load flags into Flask app.config""" + +def _get_bool_env(key: str, default: bool = False) -> bool: + """Retrieve an environment variable parsed as boolean.""" + value = os.getenv(key) + if value is None: + return default + return value.strip().lower() in ("true", "1", "yes") + + +def create_config(app: Flask) -> Flask: + """Load configuration flags and environment variables into Flask app.config.""" # Default logging level - app.config["LOGGING_LEVEL"] = LOG_LEVELS.get(os.getenv("LOGGING_LEVEL", "INFO").upper(),logging.INFO) + log_level_name = os.getenv("LOGGING_LEVEL", "INFO").upper() + app.config["LOGGING_LEVEL"] = LOG_LEVELS.get(log_level_name, logging.INFO) # Dump templates - app.config["DUMP_TEMPLATES"] = os.getenv("DUMP_TEMPLATES", "false").lower() == "true" + app.config["DUMP_TEMPLATES"] = _get_bool_env("DUMP_TEMPLATES", default=False) # Mapper - app.config["NRP_ENABLED"] = os.getenv("NRP_ENABLED", "false").lower() == "true" - app.config["PLANNER_ENABLED"] = os.getenv("PLANNER_ENABLED", "false").lower() == "true" + app.config["NRP_ENABLED"] = _get_bool_env("NRP_ENABLED", default=False) + app.config["PLANNER_ENABLED"] = _get_bool_env("PLANNER_ENABLED", default=False) app.config["PLANNER_TYPE"] = os.getenv("PLANNER_TYPE", "ENERGY") - app.config["PCE_EXTERNAL"] = os.getenv("PCE_EXTERNAL", "false").lower() == "true" + app.config["PCE_EXTERNAL"] = _get_bool_env("PCE_EXTERNAL", default=False) app.config["HRAT_IP"] = os.getenv("HRAT_IP", "192.168.1.143") app.config["E2E_OPTICAL_IP"] = os.getenv("E2E_OPTICAL_IP", "127.0.0.1") - app.config["SUBSCRIBE_ALERTS"] = os.getenv("SUBSCRIBE_ALERTS", "false").lower() == "true" + app.config["SUBSCRIBE_ALERTS"] = _get_bool_env("SUBSCRIBE_ALERTS", default=False) app.config["SUBSCRIBE_ALERTS_URL"] = os.getenv("SUBSCRIBE_ALERTS_URL", "") app.config["CHANGE_SCHEDULER_IP"] = os.getenv("CHANGE_SCHEDULER_IP", "127.0.0.1") app.config["CHANGE_SCHEDULER_PORT"] = int(os.getenv("CHANGE_SCHEDULER_PORT", "8090")) # Realizer - app.config["DUMMY_MODE"] = os.getenv("DUMMY_MODE", "true").lower() == "true" + app.config["DUMMY_MODE"] = _get_bool_env("DUMMY_MODE", default=True) # Teraflow app.config["TFS_IP"] = os.getenv("TFS_IP", "127.0.0.1") app.config["UPLOAD_TYPE"] = os.getenv("UPLOAD_TYPE", "WEBUI") - app.config["TFS_L2VPN_SUPPORT"] = os.getenv("TFS_L2VPN_SUPPORT", "false").lower() == "true" + app.config["TFS_L2VPN_SUPPORT"] = _get_bool_env("TFS_L2VPN_SUPPORT", default=False) # IXIA app.config["IXIA_IP"] = os.getenv("IXIA_IP", "127.0.0.1") @@ -66,7 +78,7 @@ def create_config(app: Flask): app.config["TFS_E2E_IP"] = os.getenv("TFS_E2E_IP", "127.0.0.1") # WebUI - app.config["WEBUI_DEPLOY"] = os.getenv("WEBUI_DEPLOY", "false").lower() == "true" + app.config["WEBUI_DEPLOY"] = _get_bool_env("WEBUI_DEPLOY", default=False) # Restconf Controller app.config["RESTCONF_IP"] = os.getenv("RESTCONF_IP", "127.0.0.1") @@ -83,3 +95,4 @@ def create_config(app: Flask): return app + diff --git a/src/config/constants.py b/src/config/constants.py index 7bfeb69..b1b8ee2 100644 --- a/src/config/constants.py +++ b/src/config/constants.py @@ -14,22 +14,22 @@ # This file includes original contributions from Telefonica Innovación Digital S.L. -from pathlib import Path import os +from pathlib import Path +from typing import Final # Default port for NSC deployment -NSC_PORT = os.getenv("NSC_PORT", "8085") +NSC_PORT: Final[str] = os.getenv("NSC_PORT", "8085") # Paths -BASE_DIR = Path(__file__).resolve().parent.parent.parent -SRC_PATH = BASE_DIR / "src" -TEMPLATES_PATH = SRC_PATH / "templates" -DATABASE_PATH = SRC_PATH / "database" -CONFIG_PATH = SRC_PATH / "config" -NBI_L2_PATH = "restconf/data/ietf-l2vpn-svc:l2vpn-svc/vpn-services" -NBI_L3_PATH = "restconf/data/ietf-l3vpn-svc:l3vpn-svc/vpn-services" -NBI_IETF_NETWORKS_PATH = "restconf/data/ietf-network:networks" -NBI_SIMAP_SUSCRIPTION_PATH = "/restconf/operations/subscriptions:establish-subscription" - - - +BASE_DIR: Final[Path] = Path(__file__).resolve().parent.parent.parent +SRC_PATH: Final[Path] = BASE_DIR / "src" +TEMPLATES_PATH: Final[Path] = SRC_PATH / "templates" +DATABASE_PATH: Final[Path] = SRC_PATH / "database" +CONFIG_PATH: Final[Path] = SRC_PATH / "config" + +# RESTCONF Endpoints +NBI_L2_PATH: Final[str] = "restconf/data/ietf-l2vpn-svc:l2vpn-svc/vpn-services" +NBI_L3_PATH: Final[str] = "restconf/data/ietf-l3vpn-svc:l3vpn-svc/vpn-services" +NBI_IETF_NETWORKS_PATH: Final[str] = "restconf/data/ietf-network:networks" +NBI_SIMAP_SUSCRIPTION_PATH: Final[str] = "/restconf/operations/subscriptions:establish-subscription" diff --git a/src/database/alert_db.py b/src/database/alert_db.py index 31f949d..989a959 100644 --- a/src/database/alert_db.py +++ b/src/database/alert_db.py @@ -12,85 +12,86 @@ # See the License for the specific language governing permissions and # limitations under the License. -import sqlite3 import json import logging +import sqlite3 +from typing import Any + +logger = logging.getLogger(__name__) -DB_NAME = "alert.db" +DB_NAME: str = "alert.db" -def init_db(): + +def init_db() -> None: """Initialize the SQLite database for alerts.""" - conn = sqlite3.connect(DB_NAME) - cursor = conn.cursor() - cursor.execute(""" - CREATE TABLE IF NOT EXISTS alert ( - alert_id TEXT PRIMARY KEY, - data TEXT NOT NULL - ) - """) - conn.commit() - conn.close() - -def save_alert(alert_id: str, data_dict: dict): + with sqlite3.connect(DB_NAME) as conn: + cursor = conn.cursor() + cursor.execute(""" + CREATE TABLE IF NOT EXISTS alert ( + alert_id TEXT PRIMARY KEY, + data TEXT NOT NULL + ) + """) + conn.commit() + + +def save_alert(alert_id: str, data_dict: dict[str, Any]) -> None: """Save an alert to the database.""" data_str = json.dumps(data_dict) - conn = sqlite3.connect(DB_NAME) - cursor = conn.cursor() try: - cursor.execute("INSERT OR REPLACE INTO alert (alert_id, data) VALUES (?, ?)", (alert_id, data_str)) - conn.commit() + with sqlite3.connect(DB_NAME) as conn: + cursor = conn.cursor() + cursor.execute("INSERT OR REPLACE INTO alert (alert_id, data) VALUES (?, ?)", (alert_id, data_str)) + conn.commit() except sqlite3.Error as e: raise ValueError(f"Database error: {e}") - finally: - conn.close() -def update_alert(alert_id: str, data_dict: dict): + +def update_alert(alert_id: str, data_dict: dict[str, Any]) -> None: """Update an existing alert.""" data_str = json.dumps(data_dict) - conn = sqlite3.connect(DB_NAME) - cursor = conn.cursor() - cursor.execute("UPDATE alert SET data = ? WHERE alert_id = ?", (data_str, alert_id)) - if cursor.rowcount == 0: - conn.close() - raise ValueError(f"No alert found with ID '{alert_id}' to update.") - conn.commit() - conn.close() - -def delete_alert(alert_id: str): + with sqlite3.connect(DB_NAME) as conn: + cursor = conn.cursor() + cursor.execute("UPDATE alert SET data = ? WHERE alert_id = ?", (data_str, alert_id)) + if cursor.rowcount == 0: + raise ValueError(f"No alert found with ID '{alert_id}' to update.") + conn.commit() + + +def delete_alert(alert_id: str) -> None: """Delete a specific alert from the database.""" - conn = sqlite3.connect(DB_NAME) - cursor = conn.cursor() - cursor.execute("DELETE FROM alert WHERE alert_id = ?", (alert_id,)) - if cursor.rowcount == 0: - conn.close() - raise ValueError(f"No alert found with ID '{alert_id}' to delete.") - conn.commit() - conn.close() - -def get_alert(alert_id: str) -> dict: + with sqlite3.connect(DB_NAME) as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM alert WHERE alert_id = ?", (alert_id,)) + if cursor.rowcount == 0: + raise ValueError(f"No alert found with ID '{alert_id}' to delete.") + conn.commit() + + +def get_alert(alert_id: str) -> dict[str, Any]: """Retrieve a specific alert from the database.""" - conn = sqlite3.connect(DB_NAME) - cursor = conn.cursor() - cursor.execute("SELECT data FROM alert WHERE alert_id = ?", (alert_id,)) - row = cursor.fetchone() - conn.close() - if row: - return json.loads(row[0]) - raise ValueError(f"No alert found with ID '{alert_id}'.") - -def get_all_alerts() -> list: + with sqlite3.connect(DB_NAME) as conn: + cursor = conn.cursor() + cursor.execute("SELECT data FROM alert WHERE alert_id = ?", (alert_id,)) + row = cursor.fetchone() + if row: + return json.loads(row[0]) + raise ValueError(f"No alert found with ID '{alert_id}'.") + + +def get_all_alerts() -> list[dict[str, Any]]: """Retrieve all alerts from the database.""" - conn = sqlite3.connect(DB_NAME) - cursor = conn.cursor() - cursor.execute("SELECT data FROM alert") - rows = cursor.fetchall() - conn.close() - return [json.loads(row[0]) for row in rows] - -def delete_all_alerts(): + with sqlite3.connect(DB_NAME) as conn: + cursor = conn.cursor() + cursor.execute("SELECT data FROM alert") + rows = cursor.fetchall() + return [json.loads(row[0]) for row in rows] + + +def delete_all_alerts() -> None: """Delete all alerts from the database.""" - conn = sqlite3.connect(DB_NAME) - cursor = conn.cursor() - cursor.execute("DELETE FROM alert") - conn.commit() - conn.close() + with sqlite3.connect(DB_NAME) as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM alert") + conn.commit() + diff --git a/src/database/db.py b/src/database/db.py index 2616e11..18370a8 100644 --- a/src/database/db.py +++ b/src/database/db.py @@ -12,147 +12,149 @@ # See the License for the specific language governing permissions and # limitations under the License. -import sqlite3, json, logging +import json +import logging +import sqlite3 +from typing import Any + +logger = logging.getLogger(__name__) # Database file -DB_NAME = "slice.db" +DB_NAME: str = "slice.db" + + +def init_db() -> None: + """Initialize the SQLite database and create the slice and subscription tables if not exist.""" + with sqlite3.connect(DB_NAME) as conn: + cursor = conn.cursor() + cursor.execute(""" + CREATE TABLE IF NOT EXISTS slice ( + slice_id TEXT PRIMARY KEY, + intent TEXT NOT NULL, + controller TEXT NOT NULL + ) + """) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS subscription ( + subscription_id TEXT PRIMARY KEY, + slice_id TEXT NOT NULL + ) + """) + conn.commit() -# Initialize database and create table -def init_db(): - """ - Initialize the SQLite database and create the slice table if not exists. - """ - conn = sqlite3.connect(DB_NAME) - cursor = conn.cursor() - cursor.execute(""" - CREATE TABLE IF NOT EXISTS slice ( - slice_id TEXT PRIMARY KEY, - intent TEXT NOT NULL, - controller TEXT NOT NULL - ) - """) - cursor.execute(""" - CREATE TABLE IF NOT EXISTS subscription ( - subscription_id TEXT PRIMARY KEY, - slice_id TEXT NOT NULL - ) - """) - conn.commit() - conn.close() -def save_subscription(subscription_id: str, slice_id: str): - conn = sqlite3.connect(DB_NAME) - cursor = conn.cursor() - try: - cursor.execute("INSERT OR REPLACE INTO subscription (subscription_id, slice_id) VALUES (?, ?)", (subscription_id, slice_id)) +def save_subscription(subscription_id: str, slice_id: str) -> None: + """Save or replace a subscription_id to slice_id mapping.""" + with sqlite3.connect(DB_NAME) as conn: + cursor = conn.cursor() + cursor.execute( + "INSERT OR REPLACE INTO subscription (subscription_id, slice_id) VALUES (?, ?)", + (subscription_id, slice_id), + ) conn.commit() - finally: - conn.close() - -def get_slice_id_by_subscription(subscription_id: str) -> str: - conn = sqlite3.connect(DB_NAME) - cursor = conn.cursor() - cursor.execute("SELECT slice_id FROM subscription WHERE subscription_id = ?", (subscription_id,)) - row = cursor.fetchone() - conn.close() - if row: - return row[0] - return None - -# Save data to the database -def save_data(slice_id: str, intent_dict: dict, controller: str): + + +def get_slice_id_by_subscription(subscription_id: str) -> str | None: + """Retrieve the slice_id associated with a subscription_id.""" + with sqlite3.connect(DB_NAME) as conn: + cursor = conn.cursor() + cursor.execute("SELECT slice_id FROM subscription WHERE subscription_id = ?", (subscription_id,)) + row = cursor.fetchone() + return row[0] if row else None + + +def save_data(slice_id: str, intent_dict: dict[str, Any], controller: str) -> None: """ Save a new slice entry to the database. Args: - slice_id (str): Unique identifier for the slice - intent_dict (dict): Intent data - controller (str): Controller type - + slice_id (str): Unique identifier for the slice. + intent_dict (dict[str, Any]): Intent data. + controller (str): Controller type. + Raises: - ValueError: If a slice with the given slice_id already exists + ValueError: If a slice with the given slice_id already exists. """ intent_str = json.dumps(intent_dict) - conn = sqlite3.connect(DB_NAME) - cursor = conn.cursor() try: - cursor.execute("INSERT INTO slice (slice_id, intent, controller) VALUES (?, ?, ?)", (slice_id, intent_str, controller)) - conn.commit() - # Handle duplicate slice ID + with sqlite3.connect(DB_NAME) as conn: + cursor = conn.cursor() + cursor.execute( + "INSERT INTO slice (slice_id, intent, controller) VALUES (?, ?, ?)", + (slice_id, intent_str, controller), + ) + conn.commit() except sqlite3.IntegrityError: raise ValueError(f"Slice with id '{slice_id}' already exists.") - finally: - conn.close() -# Update data in the database -def update_data(slice_id: str, new_intent_dict: dict, controller: str): + +def update_data(slice_id: str, new_intent_dict: dict[str, Any], controller: str) -> None: """ Update an existing slice entry in the database. Args: - slice_id (str): Unique identifier for the slice - new_intent_dict (dict): New intent data - controller (str): Controller type - + slice_id (str): Unique identifier for the slice. + new_intent_dict (dict[str, Any]): New intent data. + controller (str): Controller type. + Raises: - ValueError: If no slice is found with the given slice_id + ValueError: If no slice is found with the given slice_id. """ intent_str = json.dumps(new_intent_dict) - conn = sqlite3.connect(DB_NAME) - cursor = conn.cursor() - cursor.execute("UPDATE slice SET intent = ?, controller = ? WHERE slice_id = ?", (intent_str, controller, slice_id)) - if cursor.rowcount == 0: - raise ValueError(f"No slice found with id '{slice_id}' to update.") - else: - logging.debug(f"Slice '{slice_id}' updated.") - conn.commit() - conn.close() - -# Delete data from the database -def delete_data(slice_id: str): + with sqlite3.connect(DB_NAME) as conn: + cursor = conn.cursor() + cursor.execute( + "UPDATE slice SET intent = ?, controller = ? WHERE slice_id = ?", + (intent_str, controller, slice_id), + ) + if cursor.rowcount == 0: + raise ValueError(f"No slice found with id '{slice_id}' to update.") + conn.commit() + logger.debug("Slice '%s' updated.", slice_id) + + +def delete_data(slice_id: str) -> None: """ Delete a slice entry from the database. Args: - slice_id (str): Unique identifier for the slice to delete - + slice_id (str): Unique identifier for the slice to delete. + Raises: - ValueError: If no slice is found with the given slice_id + ValueError: If no slice is found with the given slice_id. """ - conn = sqlite3.connect(DB_NAME) - cursor = conn.cursor() - cursor.execute("DELETE FROM slice WHERE slice_id = ?", (slice_id,)) - if cursor.rowcount == 0: - raise ValueError(f"No slice found with id '{slice_id}' to delete.") - else: - logging.debug(f"Slice '{slice_id}' deleted.") - conn.commit() - conn.close() - -# Get data from the database -def get_data(slice_id: str) -> dict[str, dict, str]: + with sqlite3.connect(DB_NAME) as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM slice WHERE slice_id = ?", (slice_id,)) + if cursor.rowcount == 0: + raise ValueError(f"No slice found with id '{slice_id}' to delete.") + conn.commit() + logger.debug("Slice '%s' deleted.", slice_id) + + +def get_data(slice_id: str) -> dict[str, Any]: """ Retrieve a specific slice entry from the database. Args: - slice_id (str): Unique identifier for the slice to retrieve - + slice_id (str): Unique identifier for the slice to retrieve. + Returns: - dict: Slice data including slice_id, intent (as dict), and controller - + dict[str, Any]: Slice data dictionary containing slice_id, intent, and controller. + Raises: - ValueError: If no slice is found with the given slice_id - Exception: For JSON decoding errors + ValueError: If no slice is found with the given slice_id. + Exception: If JSON decoding of the stored intent fails. """ - conn = sqlite3.connect(DB_NAME) - cursor = conn.cursor() - cursor.execute("SELECT * FROM slice WHERE slice_id = ?", (slice_id,)) - row = cursor.fetchone() - conn.close() + with sqlite3.connect(DB_NAME) as conn: + cursor = conn.cursor() + cursor.execute("SELECT * FROM slice WHERE slice_id = ?", (slice_id,)) + row = cursor.fetchone() + if not row: + raise ValueError(f"No slice found with id '{slice_id}'.") - if row: column_names = [description[0] for description in cursor.description] - result = dict(zip(column_names, row)) + result: dict[str, Any] = dict(zip(column_names, row)) if isinstance(result.get("intent"), str): try: result["intent"] = json.loads(result["intent"]) @@ -160,61 +162,33 @@ def get_data(slice_id: str) -> dict[str, dict, str]: raise Exception("Warning: 'intent' is not a valid JSON string.") return result - else: - raise ValueError(f"No slice found with id '{slice_id}'.") -# Get all slices -def get_all_data() -> dict[str, dict, str]: +def get_all_data() -> list[dict[str, Any]]: """ Retrieve all slice entries from the database. Returns: - list: List of slice data dictionaries including slice_id, intent (as dict), and controller - """ - conn = sqlite3.connect(DB_NAME) - cursor = conn.cursor() - cursor.execute("SELECT * FROM slice") - rows = cursor.fetchall() - conn.close() - return [ - { - "slice_id": row[0], - "intent": json.loads(row[1]), - "controller": row[2] - } - for row in rows - ] - -def delete_all_data(): + list[dict[str, Any]]: List of slice data dictionaries. """ - Delete all slice entries from the database. - """ - conn = sqlite3.connect(DB_NAME) - cursor = conn.cursor() - cursor.execute("DELETE FROM slice") - conn.commit() - conn.close() - logging.debug("All slice data deleted.") - -# Example usage -if __name__ == "__main__": - init_db() - - # Save a slice - test_intent = {"bandwidth": "1Gbps", "latency": "10ms", "provider": "opensec"} - save_data("slice-001", test_intent, "TFS") - - # Get the slice - result = get_data("slice-001") - if result: - print(f"Retrieved intent for slice-001: {result}") - - # Update the slice - updated_intent = {"bandwidth": "2Gbps", "latency": "5ms", "provider": "opensec"} - update_data("slice-001", updated_intent, "TFS") - - # Delete the slice - delete_data("slice-001") - - get_all_data() - delete_all_data() + with sqlite3.connect(DB_NAME) as conn: + cursor = conn.cursor() + cursor.execute("SELECT slice_id, intent, controller FROM slice") + rows = cursor.fetchall() + return [ + { + "slice_id": row[0], + "intent": json.loads(row[1]), + "controller": row[2], + } + for row in rows + ] + + +def delete_all_data() -> None: + """Delete all slice entries from the database.""" + with sqlite3.connect(DB_NAME) as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM slice") + conn.commit() + logger.debug("All slice data deleted.") + diff --git a/src/database/service_db.py b/src/database/service_db.py index 0e2aa1f..6f4121f 100644 --- a/src/database/service_db.py +++ b/src/database/service_db.py @@ -13,146 +13,136 @@ # limitations under the License. # This file is an original contribution from Telefonica Innovación Digital S.L. +import logging +import sqlite3 -import sqlite3, logging +logger = logging.getLogger(__name__) # Database file -DB_NAME = "service.db" +DB_NAME: str = "service.db" + + +def init_db() -> None: + """Initialize the SQLite database and create the service table if not exists.""" + with sqlite3.connect(DB_NAME) as conn: + cursor = conn.cursor() + cursor.execute(""" + CREATE TABLE IF NOT EXISTS service ( + service_id TEXT PRIMARY KEY, + slice_id TEXT NOT NULL + ) + """) + conn.commit() -# Initialize database and create table -def init_db(): - """ - Initialize the SQLite database and create the service table if not exists. - """ - conn = sqlite3.connect(DB_NAME) - cursor = conn.cursor() - cursor.execute(""" - CREATE TABLE IF NOT EXISTS service ( - service_id TEXT PRIMARY KEY, - slice_id TEXT NOT NULL - ) - """) - conn.commit() - conn.close() -# Save data to the database -def save_data(service_id: str, slice_id: str): +def save_data(service_id: str, slice_id: str) -> None: """ Save a new service entry to the database. Args: - service_id (str): Unique identifier for the service - slice_id (dict): Unique identifier for the slice - + service_id (str): Unique identifier for the service. + slice_id (str): Unique identifier for the slice. + Raises: - ValueError: If a service with the given service_id already exists + ValueError: If a service with the given service_id already exists. """ - conn = sqlite3.connect(DB_NAME) - cursor = conn.cursor() try: - cursor.execute("INSERT INTO service (service_id, slice_id) VALUES (?, ?)", (service_id, slice_id)) - conn.commit() - # Handle duplicate service ID + with sqlite3.connect(DB_NAME) as conn: + cursor = conn.cursor() + cursor.execute( + "INSERT INTO service (service_id, slice_id) VALUES (?, ?)", + (service_id, slice_id), + ) + conn.commit() except sqlite3.IntegrityError: raise ValueError(f"Service with id '{service_id}' already exists.") - finally: - conn.close() -# Update data in the database -def update_data(service_id: str, new_slice_id: str): + +def update_data(service_id: str, new_slice_id: str) -> None: """ Update the slice_id for an existing service entry in the database. Args: - service_id (str): Unique identifier for the service to update - new_slice_id (str): New slice ID to associate with the service + service_id (str): Unique identifier for the service to update. + new_slice_id (str): New slice ID to associate with the service. Raises: - ValueError: If no service is found with the given service_id + ValueError: If no service is found with the given service_id. """ - conn = sqlite3.connect(DB_NAME) - cursor = conn.cursor() - try: - cursor.execute("UPDATE service SET slice_id = ? WHERE service_id = ?", (new_slice_id, service_id)) + with sqlite3.connect(DB_NAME) as conn: + cursor = conn.cursor() + cursor.execute( + "UPDATE service SET slice_id = ? WHERE service_id = ?", + (new_slice_id, service_id), + ) if cursor.rowcount == 0: raise ValueError(f"No slice found with id '{service_id}' to update.") - else: - logging.debug(f"Slice '{service_id}' updated.") conn.commit() - finally: - conn.close() + logger.debug("Slice '%s' updated.", service_id) -# Delete data from the database -def delete_data(service_id: str): + +def delete_data(service_id: str) -> None: """ Delete a service entry from the database. Args: - service_id (str): Unique identifier for the service to delete + service_id (str): Unique identifier for the service to delete. Raises: - ValueError: If no service is found with the given service_id + ValueError: If no service is found with the given service_id. """ - conn = sqlite3.connect(DB_NAME) - cursor = conn.cursor() - try: + with sqlite3.connect(DB_NAME) as conn: + cursor = conn.cursor() cursor.execute("DELETE FROM service WHERE service_id = ?", (service_id,)) if cursor.rowcount == 0: raise ValueError(f"No service found with id '{service_id}' to delete.") - else: - logging.debug(f"Service '{service_id}' deleted.") conn.commit() - finally: - conn.close() + logger.debug("Service '%s' deleted.", service_id) + -# Get data from the database def get_data(service_id: str) -> dict[str, str]: - conn = sqlite3.connect(DB_NAME) - cursor = conn.cursor() - - cursor.execute("SELECT * FROM service WHERE service_id = ?", (service_id,)) - row = cursor.fetchone() - - if not row: - conn.close() - raise ValueError(f"No service found with id '{service_id}'.") - - column_names = [desc[0] for desc in cursor.description] - conn.close() - - return dict(zip(column_names, row)) - - -# Get all services -def get_all_data() -> list[dict[str, str]]: """ - Retrieve all service entries from the database. + Retrieve a specific service entry from the database. + + Args: + service_id (str): Unique identifier for the service to retrieve. Returns: - list: List of service data dictionaries including service_id and slice_id - """ - conn = sqlite3.connect(DB_NAME) - cursor = conn.cursor() - cursor.execute("SELECT * FROM service") - - rows = cursor.fetchall() - column_names = [description[0] for description in cursor.description] - - conn.close() + dict[str, str]: Service entry dictionary (service_id, slice_id). - return [dict(zip(column_names, row)) for row in rows] + Raises: + ValueError: If no service is found with the given service_id. + """ + with sqlite3.connect(DB_NAME) as conn: + cursor = conn.cursor() + cursor.execute("SELECT service_id, slice_id FROM service WHERE service_id = ?", (service_id,)) + row = cursor.fetchone() + if not row: + raise ValueError(f"No service found with id '{service_id}'.") + return {"service_id": row[0], "slice_id": row[1]} -def delete_all_data(): +def get_all_data() -> list[dict[str, str]]: """ - Delete all service entries from the database. + Retrieve all service entries from the database. + + Returns: + list[dict[str, str]]: List of service data dictionaries. """ - conn = sqlite3.connect(DB_NAME) - cursor = conn.cursor() - cursor.execute("DELETE FROM service") - conn.commit() - conn.close() - logging.debug("All service data deleted.") + with sqlite3.connect(DB_NAME) as conn: + cursor = conn.cursor() + cursor.execute("SELECT service_id, slice_id FROM service") + rows = cursor.fetchall() + return [{"service_id": row[0], "slice_id": row[1]} for row in rows] + + +def delete_all_data() -> None: + """Delete all service entries from the database.""" + with sqlite3.connect(DB_NAME) as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM service") + conn.commit() + logger.debug("All service data deleted.") def get_data_by_slice_id(slice_id: str) -> list[dict[str, str]]: @@ -160,53 +150,40 @@ def get_data_by_slice_id(slice_id: str) -> list[dict[str, str]]: Retrieve all service entries associated with a given slice_id. Args: - slice_id (str): Identifier of the slice - + slice_id (str): Identifier of the slice. + Returns: - list: List of service data dictionaries including service_id and slice_id - + list[dict[str, str]]: List of service data dictionaries. + Raises: - ValueError: If no services are found with the given slice_id + ValueError: If no services are found with the given slice_id. """ - conn = sqlite3.connect(DB_NAME) - cursor = conn.cursor() - - cursor.execute("SELECT * FROM service WHERE slice_id = ?", (slice_id,)) - rows = cursor.fetchall() - - if not rows: - conn.close() - raise ValueError(f"No services found with slice_id '{slice_id}'.") - - column_names = [desc[0] for desc in cursor.description] - conn.close() - - return [dict(zip(column_names, row)) for row in rows] - -def delete_by_slice_id(slice_id: str): + with sqlite3.connect(DB_NAME) as conn: + cursor = conn.cursor() + cursor.execute("SELECT service_id, slice_id FROM service WHERE slice_id = ?", (slice_id,)) + rows = cursor.fetchall() + if not rows: + raise ValueError(f"No services found with slice_id '{slice_id}'.") + return [{"service_id": row[0], "slice_id": row[1]} for row in rows] + + +def delete_by_slice_id(slice_id: str) -> None: """ Delete all service entries associated with a given slice_id. Args: - slice_id (str): Identifier of the slice whose services will be deleted - + slice_id (str): Identifier of the slice whose services will be deleted. + Raises: - ValueError: If no services are found with the given slice_id + ValueError: If no services are found with the given slice_id. """ - conn = sqlite3.connect(DB_NAME) - cursor = conn.cursor() - - cursor.execute("DELETE FROM service WHERE slice_id = ?", (slice_id,)) - - if cursor.rowcount == 0: - conn.close() - raise ValueError(f"No services found with slice_id '{slice_id}' to delete.") - - logging.debug(f"All services with slice_id '{slice_id}' deleted.") - - conn.commit() - conn.close() - + with sqlite3.connect(DB_NAME) as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM service WHERE slice_id = ?", (slice_id,)) + if cursor.rowcount == 0: + raise ValueError(f"No services found with slice_id '{slice_id}' to delete.") + conn.commit() + logger.debug("All services with slice_id '%s' deleted.", slice_id) # Example usage if __name__ == "__main__": diff --git a/src/database/store_data.py b/src/database/store_data.py index 97a2c20..dd8fc93 100644 --- a/src/database/store_data.py +++ b/src/database/store_data.py @@ -1,42 +1,45 @@ -# Copyright 2022-2026 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. - -from src.database.db import save_data, update_data -from src.database.sysrepo_store import create_data_store - -def store_data(intent, slice_id, controller_type=None): - """ - Store network slice intent information in a JSON database file. - - This method: - 1. Creates a JSON file if it doesn't exist - 2. Reads existing content - 3. Updates or adds new slice intent information - - Args: - intent (dict): Network slice intent to be stored - slice_id (str, optional): Existing slice ID to update. Defaults to None. - """ - # Update or add new slice intent - if slice_id: - if controller_type != "RESTCONF": - update_data(slice_id, intent, controller_type) - - else: - # Add new slice intent - slice_id = intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"] - if controller_type != "RESTCONF": - save_data(slice_id, intent, controller_type) \ No newline at end of file +# Copyright 2022-2026 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. + +from typing import Any + +from src.database.db import save_data, update_data + + +def store_data( + intent: dict[str, Any], + slice_id: str | None = None, + controller_type: str | None = None, +) -> None: + """ + Store network slice intent information in the database. + + Args: + intent (dict[str, Any]): Network slice intent to be stored. + slice_id (str, optional): Existing slice ID to update. Defaults to None. + controller_type (str, optional): Controller type. Defaults to None. + """ + if controller_type == "RESTCONF": + return + + effective_controller = controller_type or "TFS" + + if slice_id: + update_data(slice_id, intent, effective_controller) + return + + resolved_slice_id = intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"] + save_data(resolved_slice_id, intent, effective_controller) \ No newline at end of file diff --git a/src/database/sysrepo_store.py b/src/database/sysrepo_store.py index 26ad0a5..fef8107 100644 --- a/src/database/sysrepo_store.py +++ b/src/database/sysrepo_store.py @@ -14,60 +14,61 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. -import sysrepo import logging +from typing import Any + +try: + import sysrepo +except ImportError: + sysrepo = None + +logger = logging.getLogger(__name__) -#_conn = sysrepo.SysrepoConnection() def _get_connection(): - """ - Creates a new connection each time - """ + """Create a new SysrepoConnection instance.""" + if sysrepo is None: + raise RuntimeError("sysrepo module is not installed in the environment.") return sysrepo.SysrepoConnection() -def create_data_store(intent: dict, xpath: str= ""): - """ - CREATE: Creates new data in the datastore - """ + +def create_data_store(intent: dict[str, Any], xpath: str = "") -> bool: + """CREATE: Creates new data in the datastore.""" conn = _get_connection() sess = conn.start_session() - + try: _write_dict(sess, xpath, intent) sess.apply_changes() - logging.debug(f"Created data at {xpath}") + logger.debug("Created data at %s", xpath) return True except Exception as e: sess.discard_changes() - logging.error(f"Error creating data: {e}") - raise Exception(f"Error creating data: {e}") - + logger.error("Error creating data: %s", e) + raise Exception(f"Error creating data: {e}") from e finally: sess.stop() -def get_data_store(xpath: str = ""): - """ - Gets all slices from the datastore - """ - conn = _get_connection() - sess = conn.start_session() +def get_data_store(xpath: str = "") -> Any: + """Gets all slices from the datastore.""" try: - # get_data devuelve un objeto libyang que debe ser procesado - data = sess.get_data(xpath) - if data is None: - return None - - # Convertir a dict/JSON para uso seguro - return normalize_libyang_data(data) - + conn = _get_connection() + sess = conn.start_session() + try: + data = sess.get_data(xpath) + if data is None: + return None + return normalize_libyang_data(data) + finally: + sess.stop() except Exception as e: - logging.warning(f"Slices not found: {e}") + logger.warning("Slices not found: %s", e) return None - finally: - sess.stop() -def update_data_store(intent: dict, xpath: str = ""): + +def update_data_store(intent: dict[str, Any], xpath: str = "") -> bool: + """ UPDATE: Modifies data in the datastore @@ -84,86 +85,64 @@ def update_data_store(intent: dict, xpath: str = ""): """ conn = _get_connection() sess = conn.start_session() - - # PUT: Delete and recreate (complete replacement) + try: if not xpath: sess.delete_item("/ietf-network-slice-service:network-slice-services") else: sess.delete_item(xpath) - logging.debug(f"Deleted existing data at {xpath} for replacement") + logger.debug("Deleted existing data at %s for replacement", xpath) _write_dict(sess, xpath, intent) sess.apply_changes() - logging.debug(f"Replaced data at {xpath}") + logger.debug("Replaced data at %s", xpath) return True - except Exception as e: sess.discard_changes() - logging.error(f"Error updating data: {e}") - raise Exception(f"Error updating data: {e}") - + logger.error("Error updating data: %s", e) + raise Exception(f"Error updating data: {e}") from e finally: sess.stop() -def patch_data_store(intent: dict, xpath: str = ""): - """ - UPDATE: Modifies data in the datastore - Args: - intent: Data to modify - xpath: Path to resource - operation: - - "merge" (default): PATCH - updates specific fields - - "replace": PUT - completely replaces the resource - - "create": POST - only creates if it doesn't exist - - Returns: - bool: True if successful - """ +def patch_data_store(intent: dict[str, Any], xpath: str = "") -> bool: + """PATCH: Merges data in the datastore.""" conn = _get_connection() sess = conn.start_session() - # PUT: Delete and recreate (complete replacement) try: - # PATCH: Merge with existing data _write_dict(sess, xpath, intent) sess.apply_changes() - logging.debug(f"Merged data at {xpath}") + logger.debug("Merged data at %s", xpath) return True - except Exception as e: sess.discard_changes() - logging.error(f"Error patching data: {e}") - raise Exception(f"Error patching data: {e}") - + logger.error("Error patching data: %s", e) + raise Exception(f"Error patching data: {e}") from e finally: sess.stop() -def delete_data_store(xpath: str = ""): - """ - DELETE: Deletes data from the datastore - """ + +def delete_data_store(xpath: str = "") -> bool: + """DELETE: Deletes data from the datastore.""" conn = _get_connection() sess = conn.start_session() - + try: sess.delete_item(xpath) sess.apply_changes() - logging.debug(f"Deleted data at {xpath}") + logger.debug("Deleted data at %s", xpath) return True except Exception as e: sess.discard_changes() - logging.error(f"Error deleting data at {xpath}: {e}") - raise Exception(f"Error deleting data at {xpath}: {e}") + logger.error("Error deleting data at %s: %s", xpath, e) + raise Exception(f"Error deleting data at {xpath}: {e}") from e finally: sess.stop() -def _write_dict(sess, base_xpath, data, parent_key=None): - """ - Converts dict → YANG XPaths - parent_key: key already in xpath and should be excluded - """ +def _write_dict(sess: Any, base_xpath: str, data: Any, parent_key: str | None = None) -> None: + """Converts dictionary to YANG XPaths and sets them in sysrepo session.""" + LIST_KEYS = { 'slo-sle-template': 'id', 'metric-bound': 'metric-type', @@ -239,7 +218,7 @@ def _write_dict(sess, base_xpath, data, parent_key=None): for value in data: if value is None or value == '': - logging.debug(f"Skipping None/empty value in leaf-list") + logging.debug("Skipping None/empty value in leaf-list") continue logging.debug(f"Adding leaf-list value: {value}") diff --git a/src/database/telemetry_client_db.py b/src/database/telemetry_client_db.py index d9d2352..fa256df 100644 --- a/src/database/telemetry_client_db.py +++ b/src/database/telemetry_client_db.py @@ -1,314 +1,236 @@ -# Copyright 2022-2026 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 sqlite3, logging, json - -DB_NAME = "telemetry_client.db" - - -def init_db(): - """ - Initialize database and create telemetry_client table if not exists. - """ - conn = sqlite3.connect(DB_NAME) - cursor = conn.cursor() - - cursor.execute(""" - CREATE TABLE IF NOT EXISTS client ( - client_id TEXT PRIMARY KEY - ) - """) - - cursor.execute(""" - CREATE TABLE IF NOT EXISTS client_slice_subscription ( - client_id TEXT, - slice_id TEXT, - frequency INTEGER NOT NULL, - - PRIMARY KEY (client_id, slice_id), - - FOREIGN KEY (client_id) REFERENCES client(client_id) ON DELETE CASCADE - ) - """) - - cursor.execute(""" - CREATE TABLE IF NOT EXISTS telemetry ( - slice_id TEXT PRIMARY KEY, - - telemetry_list TEXT NOT NULL, - slo_sle_compliance TEXT NOT NULL, - - timestamp REAL NOT NULL - ) - """) - - conn.commit() - conn.close() - -def get_conn(): - conn = sqlite3.connect(DB_NAME) - conn.execute("PRAGMA foreign_keys = ON") - return conn - -def create_client(client_id: str): - conn = get_conn() - cursor = conn.cursor() - - try: - cursor.execute( - "INSERT INTO client (client_id) VALUES (?)", - (client_id,) - ) - conn.commit() - - except sqlite3.IntegrityError: - raise ValueError(f"Client '{client_id}' already exists") - - finally: - conn.close() - -def delete_client(client_id: str): - """ - Delete client. - """ - conn = get_conn() - cursor = conn.cursor() - - cursor.execute( - "DELETE FROM client WHERE client_id = ?", - (client_id,) - ) - - if cursor.rowcount == 0: - raise ValueError(f"No client found with id '{client_id}' to delete.") - - logging.debug(f"Client '{client_id}' deleted.") - - conn.commit() - conn.close() - - -def get_client(client_id: str) -> dict: - conn = get_conn() - cursor = conn.cursor() - - cursor.execute( - "SELECT * FROM client WHERE client_id = ?", - (client_id,) - ) - - row = cursor.fetchone() - - if not row: - conn.close() - raise ValueError(f"No client found with id '{client_id}'.") - - result = { - "client_id": row[0] - } - - conn.close() - - return result - - -def get_all_clients() -> list[dict]: - conn = get_conn() - cursor = conn.cursor() - - cursor.execute("SELECT client_id FROM client") - - rows = cursor.fetchall() - - conn.close() - - return [ - { - "client_id": row[0] - } - for row in rows - ] - - -def delete_all_clients(): - conn = get_conn() - cursor = conn.cursor() - - cursor.execute("DELETE FROM client") - - conn.commit() - conn.close() - - logging.debug("All clients deleted.") - -def upsert_subscription(client_id: str, slice_id: str, frequency: int): - conn = get_conn() - cursor = conn.cursor() - - cursor.execute(""" - INSERT INTO client_slice_subscription (client_id, slice_id, frequency) - VALUES (?, ?, ?) - ON CONFLICT(client_id, slice_id) - DO UPDATE SET frequency = excluded.frequency - """, (client_id, slice_id, frequency)) - - conn.commit() - conn.close() - -def get_subscription(client_id: str, slice_id: str) -> dict: - conn = get_conn() - cursor = conn.cursor() - - cursor.execute(""" - SELECT client_id, slice_id, frequency - FROM client_slice_subscription - WHERE client_id = ? AND slice_id = ? - """, (client_id, slice_id)) - - row = cursor.fetchone() - conn.close() - - if not row: - raise ValueError("Subscription not found") - - return { - "client_id": row[0], - "slice_id": row[1], - "frequency": row[2] - } - -def get_client_subscriptions(client_id: str) -> list[dict]: - conn = get_conn() - cursor = conn.cursor() - - cursor.execute(""" - SELECT slice_id, frequency - FROM client_slice_subscription - WHERE client_id = ? - """, (client_id,)) - - rows = cursor.fetchall() - conn.close() - - return [ - { - "slice_id": r[0], - "frequency": r[1] - } - for r in rows - ] - -def delete_subscription(client_id: str, slice_id: str): - conn = get_conn() - cursor = conn.cursor() - - cursor.execute(""" - DELETE FROM client_slice_subscription - WHERE client_id = ? AND slice_id = ? - """, (client_id, slice_id)) - - if cursor.rowcount == 0: - raise ValueError("Subscription not found") - - conn.commit() - conn.close() - -def delete_all_subscriptions(client_id: str): - conn = get_conn() - cursor = conn.cursor() - - cursor.execute(""" - DELETE FROM client_slice_subscription - WHERE client_id = ? - """, (client_id,)) - - conn.commit() - conn.close() - -def upsert_telemetry( - slice_id: str, - telemetry_list: dict, - slo_sle_compliance: dict, - timestamp: float -): - conn = get_conn() - cursor = conn.cursor() - - cursor.execute(""" - INSERT INTO telemetry ( - slice_id, - telemetry_list, - slo_sle_compliance, - timestamp - ) - VALUES (?, ?, ?, ?) - - ON CONFLICT(slice_id) - DO UPDATE SET - telemetry_list = excluded.telemetry_list, - slo_sle_compliance = excluded.slo_sle_compliance, - timestamp = excluded.timestamp - """, ( - slice_id, - json.dumps(telemetry_list), - json.dumps(slo_sle_compliance), - timestamp - )) - - conn.commit() - conn.close() - -def get_telemetry(slice_id: str): - conn = get_conn() - cursor = conn.cursor() - - cursor.execute(""" - SELECT - slice_id, - telemetry_list, - slo_sle_compliance, - timestamp - FROM telemetry - WHERE slice_id = ? - """, (slice_id,)) - - row = cursor.fetchone() - - conn.close() - - if not row: - raise ValueError( - f"No telemetry found for slice '{slice_id}'" - ) - - return { - "slice_id": row[0], - "telemetry_list": json.loads(row[1]), - "slo_sle_compliance": json.loads(row[2]), - "timestamp": row[3] - } - -def delete_telemetry(slice_id: str): - conn = get_conn() - cursor = conn.cursor() - - cursor.execute(""" - DELETE FROM telemetry - WHERE slice_id = ? - """, (slice_id,)) - - conn.commit() - conn.close() \ No newline at end of file +# Copyright 2022-2026 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 json +import logging +import sqlite3 +from typing import Any + +logger = logging.getLogger(__name__) + +DB_NAME: str = "telemetry_client.db" + + +def init_db() -> None: + """Initialize database and create tables if not exist.""" + with get_conn() as conn: + cursor = conn.cursor() + cursor.execute(""" + CREATE TABLE IF NOT EXISTS client ( + client_id TEXT PRIMARY KEY + ) + """) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS client_slice_subscription ( + client_id TEXT, + slice_id TEXT, + frequency INTEGER NOT NULL, + PRIMARY KEY (client_id, slice_id), + FOREIGN KEY (client_id) REFERENCES client(client_id) ON DELETE CASCADE + ) + """) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS telemetry ( + slice_id TEXT PRIMARY KEY, + telemetry_list TEXT NOT NULL, + slo_sle_compliance TEXT NOT NULL, + timestamp REAL NOT NULL + ) + """) + conn.commit() + + +def get_conn() -> sqlite3.Connection: + """Create a new SQLite connection with foreign keys enabled.""" + conn = sqlite3.connect(DB_NAME) + conn.execute("PRAGMA foreign_keys = ON") + return conn + + +def create_client(client_id: str) -> None: + """Create a new telemetry client.""" + try: + with get_conn() as conn: + cursor = conn.cursor() + cursor.execute("INSERT INTO client (client_id) VALUES (?)", (client_id,)) + conn.commit() + except sqlite3.IntegrityError: + raise ValueError(f"Client '{client_id}' already exists") + + +def delete_client(client_id: str) -> None: + """Delete client and associated subscriptions.""" + with get_conn() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM client WHERE client_id = ?", (client_id,)) + if cursor.rowcount == 0: + raise ValueError(f"No client found with id '{client_id}' to delete.") + conn.commit() + logger.debug("Client '%s' deleted.", client_id) + + +def get_client(client_id: str) -> dict[str, str]: + """Retrieve client details.""" + with get_conn() as conn: + cursor = conn.cursor() + cursor.execute("SELECT client_id FROM client WHERE client_id = ?", (client_id,)) + row = cursor.fetchone() + if not row: + raise ValueError(f"No client found with id '{client_id}'.") + return {"client_id": row[0]} + + +def get_all_clients() -> list[dict[str, str]]: + """Retrieve all clients.""" + with get_conn() as conn: + cursor = conn.cursor() + cursor.execute("SELECT client_id FROM client") + rows = cursor.fetchall() + return [{"client_id": row[0]} for row in rows] + + +def delete_all_clients() -> None: + """Delete all clients.""" + with get_conn() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM client") + conn.commit() + logger.debug("All clients deleted.") + + +def upsert_subscription(client_id: str, slice_id: str, frequency: int) -> None: + """Insert or update a client-slice subscription.""" + with get_conn() as conn: + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO client_slice_subscription (client_id, slice_id, frequency) + VALUES (?, ?, ?) + ON CONFLICT(client_id, slice_id) + DO UPDATE SET frequency = excluded.frequency + """, (client_id, slice_id, frequency)) + conn.commit() + + +def get_subscription(client_id: str, slice_id: str) -> dict[str, Any]: + """Retrieve a specific subscription.""" + with get_conn() as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT client_id, slice_id, frequency + FROM client_slice_subscription + WHERE client_id = ? AND slice_id = ? + """, (client_id, slice_id)) + row = cursor.fetchone() + if not row: + raise ValueError("Subscription not found") + return { + "client_id": row[0], + "slice_id": row[1], + "frequency": row[2], + } + + +def get_client_subscriptions(client_id: str) -> list[dict[str, Any]]: + """Retrieve all subscriptions for a given client.""" + with get_conn() as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT slice_id, frequency + FROM client_slice_subscription + WHERE client_id = ? + """, (client_id,)) + rows = cursor.fetchall() + return [{"slice_id": r[0], "frequency": r[1]} for r in rows] + + +def delete_subscription(client_id: str, slice_id: str) -> None: + """Delete a specific subscription.""" + with get_conn() as conn: + cursor = conn.cursor() + cursor.execute(""" + DELETE FROM client_slice_subscription + WHERE client_id = ? AND slice_id = ? + """, (client_id, slice_id)) + if cursor.rowcount == 0: + raise ValueError("Subscription not found") + conn.commit() + + +def delete_all_subscriptions(client_id: str) -> None: + """Delete all subscriptions for a client.""" + with get_conn() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM client_slice_subscription WHERE client_id = ?", (client_id,)) + conn.commit() + + +def upsert_telemetry( + slice_id: str, + telemetry_list: dict[str, Any], + slo_sle_compliance: dict[str, Any], + timestamp: float, +) -> None: + """Insert or update telemetry data for a slice.""" + with get_conn() as conn: + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO telemetry ( + slice_id, + telemetry_list, + slo_sle_compliance, + timestamp + ) + VALUES (?, ?, ?, ?) + ON CONFLICT(slice_id) + DO UPDATE SET + telemetry_list = excluded.telemetry_list, + slo_sle_compliance = excluded.slo_sle_compliance, + timestamp = excluded.timestamp + """, ( + slice_id, + json.dumps(telemetry_list), + json.dumps(slo_sle_compliance), + timestamp, + )) + conn.commit() + + +def get_telemetry(slice_id: str) -> dict[str, Any]: + """Retrieve telemetry metrics for a slice.""" + with get_conn() as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT slice_id, telemetry_list, slo_sle_compliance, timestamp + FROM telemetry + WHERE slice_id = ? + """, (slice_id,)) + row = cursor.fetchone() + if not row: + raise ValueError(f"No telemetry found for slice '{slice_id}'") + return { + "slice_id": row[0], + "telemetry_list": json.loads(row[1]), + "slo_sle_compliance": json.loads(row[2]), + "timestamp": row[3], + } + + +def delete_telemetry(slice_id: str) -> None: + """Delete telemetry record for a slice.""" + with get_conn() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM telemetry WHERE slice_id = ?", (slice_id,)) + conn.commit() \ No newline at end of file diff --git a/src/main.py b/src/main.py index 9e83a22..6419dc4 100644 --- a/src/main.py +++ b/src/main.py @@ -16,223 +16,224 @@ import logging import time -from src.utils.dump_templates import dump_templates -from src.utils.build_response import build_response -from src.nbi_processor.main import nbi_processor -from src.database.store_data import store_data +from typing import Any + +import requests as http_requests +from flask import current_app + +from src.database.db import save_subscription from src.database.service_db import delete_data +from src.database.store_data import store_data +from src.database.sysrepo_store import get_data_store, update_data_store from src.mapper.main import mapper +from src.nbi_processor.main import nbi_processor +from src.planner.planner import Planner from src.realizer.main import realizer from src.realizer.send_controller import send_controller -from src.database.sysrepo_store import update_data_store, get_data_store +from src.utils.build_response import build_response +from src.utils.dump_templates import dump_templates + + +def _subscribe_to_alerts(ietf_intents: list[dict[str, Any]], slice_id: str | None) -> None: + """Subscribe to TAPI notification alerts if enabled in application configuration.""" + if not current_app.config.get("SUBSCRIBE_ALERTS", False): + return + + sub_url = current_app.config.get("SUBSCRIBE_ALERTS_URL") + if not sub_url: + return + + sub_slice_id = slice_id + if not sub_slice_id: + try: + for intent in ietf_intents: + sub_slice_id = intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"] + break + except Exception: + pass + + if not sub_slice_id: + return + + logging.info(f"Subscribing to alerts at: {sub_url}") + payload = { + "tapi-notification:input": { + "subscription-filter": { + "requested-notification-types": [ + "ALARM_EVENT", + "PATH_RESTORED", + ], + }, + "subscriber-id": "e2e-orchestrator", + }, + } + try: + headers = {"Content-Type": "application/json"} + sub_resp = http_requests.post(sub_url, json=payload, headers=headers, timeout=10) + logging.info(f"Subscription response status: {sub_resp.status_code}") + if sub_resp.status_code in (200, 201): + resp_json = sub_resp.json() + logging.info(f"Subscription response: {resp_json}") + output = resp_json.get("tapi-notification:output", {}) + subscription_id = output.get("subscription-id") or output.get( + "tapi-notification:subscription", {} + ).get("subscription-id") + if subscription_id: + save_subscription(subscription_id, sub_slice_id) + logging.info( + f"Successfully mapped subscription_id {subscription_id} to slice_id {sub_slice_id}" + ) + else: + logging.warning("subscription-id not found in response output") + else: + logging.error(f"Subscription failed: {sub_resp.text}") + except Exception as e: + logging.error(f"Failed to subscribe to notification service: {e}") + + +def _is_change_scheduler_viable(cs_result: Any) -> bool: + """Evaluate whether Change Scheduler response confirms viability.""" + if not isinstance(cs_result, dict): + return False + + if cs_result.get("success", True) or cs_result.get("status_code") in (200, 201, 202): + return True + + cs_response = cs_result.get("response", {}) + if isinstance(cs_response, dict): + if cs_response.get("status") in ("VIABLE", "SCHEDULED") or cs_response.get("viable") is True: + return True + + return False + class NSController: """ - Network Slice Controller (NSC) - A class to manage network slice creation, - modification, and deletion across different network domains. - - This controller handles the translation, mapping, and realization of network - slice intents from different formats (3GPP and IETF) to network-specific - configurations. - - Key Functionalities: - - Intent Processing: Translate and process network slice intents - - Slice Management: Create, modify, and delete network slices - - NRP (Network Resource Partition) Mapping: Match slice requirements with available resources - - Slice Realization: Convert intents to specific network configurations (L2VPN, L3VPN) + Network Slice Controller (NSC) - Manages network slice lifecycle across domains. + + Handles translation, mapping, planning, and realization of network slice intents + from 3GPP and IETF formats to SDN controller configurations. """ - def __init__(self, controller_type = "TFS"): + def __init__(self, controller_type: str = "TFS") -> None: """ Initialize the Network Slice Controller. Args: - controller_type (str): Flag to determine if configurations - should be uploaded to Teraflow or IXIA system. - - Attributes: - controller_type (str): Flag for Teraflow or Ixia upload - response (dict): Stores slice creation responses - start_time (float): Tracks slice setup start time - end_time (float): Tracks slice setup end time - setup_time (float): Total time taken for slice setup in milliseconds + controller_type (str, optional): Target SDN controller ("TFS", "IXIA", "E2E", "RESTCONF"). """ - self.controller_type = controller_type - - self.path = "" - self.response = [] - self.start_time = 0 - self.end_time = 0 - self.setup_time = 0 - - def nsc(self, intent_json, slice_id=None, old_service_id=None): + self.controller_type: str = controller_type + self.path: str = "" + self.response: list[dict[str, Any]] = [] + self.start_time: float = 0.0 + self.end_time: float = 0.0 + self.setup_time: float = 0.0 + + def nsc( + self, + intent_json: dict[str, Any], + slice_id: str | None = None, + old_service_id: str | None = None, + ) -> dict[str, Any]: """ - Main Network Slice Controller method to process and realize network slice intents. - - Workflow: - 1. Load IETF template - 2. Process intent (detect format, translate if needed) - 3. Extract slice data - 4. Store slice information - 5. Map slice to Network Resource Pool (NRP) - 6. Realize slice configuration - 7. Send configuration to network controllers + Main Network Slice Controller workflow to process and realize network slice intents. Args: - intent_json (dict): Network slice intent in 3GPP or IETF format - slice_id (str, optional): Existing slice identifier for modification - old_service_id (str, optional): Old service identifier to delete when modifying + intent_json (dict[str, Any]): Network slice intent in 3GPP or IETF format. + slice_id (str, optional): Existing slice identifier for modification. Defaults to None. + old_service_id (str, optional): Old service identifier to delete when modifying. Defaults to None. Returns: - dict: Contains slice creation responses and setup time in milliseconds - + dict[str, Any]: Contains slice creation responses and setup time in milliseconds. """ - # Start performance tracking self.start_time = time.perf_counter() + requests: dict[str, list[Any]] = {"services": []} + services: list[dict[str, Any]] = [] - # Reset requests - requests = {"services":[]} - response = None - - # Process intent (translate if 3GPP) ietf_intents = nbi_processor(intent_json) - - is_update = True if slice_id else False + is_update = bool(slice_id) for intent in ietf_intents: logging.debug(intent) payload = { "intent": intent, - "is_update": is_update + "is_update": is_update, } - # Mapper services, rules = mapper(payload, 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 + + self.response = build_response(intent, self.response, 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: + request = realizer( + service, + controller_type=self.controller_type, + response=self.response, + rules=rules, + ) + 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) - # Check if there are services to process if not requests.get("services"): raise RuntimeError("No service to process.") - # Send config to controllers - is_update = True if slice_id else False try: - response = send_controller(self.controller_type, requests, is_update=is_update, old_service_id=old_service_id) + response = send_controller( + self.controller_type, + requests, + is_update=is_update, + old_service_id=old_service_id, + ) except Exception as e: for service in services: - service_id = service.get("id") - if service_id: - delete_data(service_id) + service_id_to_delete = service.get("id") + if service_id_to_delete: + delete_data(service_id_to_delete) raise Exception(f"Controller upload failed: {e}") if not response: raise Exception("Controller upload failed") - # Alert subscription if flag is enabled - from flask import current_app - import os - import requests as http_requests - if current_app.config.get("SUBSCRIBE_ALERTS"): - sub_url = current_app.config.get("SUBSCRIBE_ALERTS_URL") - if sub_url: - sub_slice_id = slice_id - if not sub_slice_id: - try: - for intent in ietf_intents: - sub_slice_id = intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"] - break - except Exception: - pass - - if sub_slice_id: - logging.info(f"Subscribing to alerts at: {sub_url}") - payload = { - "tapi-notification:input": { - "subscription-filter": { - "requested-notification-types": [ - "ALARM_EVENT", - "PATH_RESTORED" - ] - }, - "subscriber-id": "e2e-orchestrator" - } - } - try: - headers = {"Content-Type": "application/json"} - sub_resp = http_requests.post(sub_url, json=payload, headers=headers, timeout=10) - logging.info(f"Subscription response status: {sub_resp.status_code}") - if sub_resp.status_code in (200, 201): - resp_json = sub_resp.json() - logging.info(f"Subscription response: {resp_json}") - output = resp_json.get("tapi-notification:output", {}) - subscription_id = output.get("subscription-id") or output.get("tapi-notification:subscription", {}).get("subscription-id") - if subscription_id: - from src.database.db import save_subscription - save_subscription(subscription_id, sub_slice_id) - logging.info(f"Successfully mapped subscription_id {subscription_id} to slice_id {sub_slice_id}") - else: - logging.warning("subscription-id not found in response output") - else: - logging.error(f"Subscription failed: {sub_resp.text}") - except Exception as e: - logging.error(f"Failed to subscribe to notification service: {e}") - - # End performance tracking + _subscribe_to_alerts(ietf_intents, slice_id) + self.end_time = time.perf_counter() setup_time = (self.end_time - self.start_time) * 1000 return { "slices": self.response, - "setup_time": setup_time + "setup_time": setup_time, } - - def monitoring(self, slice_id, slo_sle_template): - """ - Monitor the status of a specific network slice. - Workflow: - 1. Validate the provided slice_id - 2. Request the planner the shortest path for the slice - 3. Request the realizer to retrieve the metrics for the links of the specified slice - 4. Realizer retrieves the metrics from the controller for the specified slice - 5. Request mapper to aggregate the metrics and store them in the database - 6. Return the monitoring information for the specified slice + def monitoring(self, slice_id: str, slo_sle_template: dict[str, Any]) -> dict[str, Any]: + """ + Monitor status and SLO compliance of a specific network slice. Args: - slice_id (str): The identifier of the network slice to monitor - slo_sle_template (dict): The SLO-SLE template for the network slice - sdps (list): The SDPs for the network slice + slice_id (str): Network slice identifier. + slo_sle_template (dict[str, Any]): SLO-SLE template for compliance evaluation. Returns: - dict: Contains monitoring information for the specified slice + dict[str, Any]: Aggregated monitoring metrics and compliance status. """ payload = { "slice_id": slice_id, - "slo_sle_template": slo_sle_template + "slo_sle_template": slo_sle_template, } - # Request the realizer to retrieve the metrics for the links of the specified slice realizer(payload, action="MONITOR", controller_type=self.controller_type) + metrics: dict[str, Any] = mapper(payload, action="MONITOR") - # Request the mapper to aggregate the metrics and store them in the database - metrics = mapper(payload, action="MONITOR") - - # Check if SLO/SLE compliance indicates non-compliance slo_compliance = metrics.get("slo_sle_compliance", {}) if isinstance(metrics, dict) else {} is_compliant = slo_compliance.get("is_compliant", True) + if not is_compliant: - logging.warning(f"SLO/SLE compliance violation (is_compliant=False) detected for slice '{slice_id}'. Triggering automatic reconfig_slice...") + logging.warning( + f"SLO/SLE compliance violation (is_compliant=False) detected for slice '{slice_id}'. Triggering automatic reconfig_slice..." + ) try: reconfig_res = self.reconfig_slice(slice_id) if isinstance(metrics, dict): @@ -244,53 +245,42 @@ class NSController: return metrics - def reconfig_slice(self, slice_id): + def reconfig_slice(self, slice_id: str) -> dict[str, Any]: """ - Reconfigure a network slice by computing the optimal path, comparing it with - the active service path, and sending scheduled topology changes to Change Scheduler. - Once Change Scheduler confirms NDT viability, perform slice PUT modification. + Reconfigure a network slice by computing optimal path and coordinating with Change Scheduler. Args: - slice_id (str): Identifier of the network slice to reconfigure. + slice_id (str): Network slice identifier. Returns: - dict: Result from Planner using CHANGE_SCHEDULER strategy and slice modification. + dict[str, Any]: Result from Planner and slice modification. """ reconfig_data = realizer({"slice_id": slice_id}, action="RECONFIG", controller_type=self.controller_type) - - from src.planner.planner import Planner planner = Planner() cs_result = planner.planner(reconfig_data, type="CHANGE_SCHEDULER") - # Evaluate Change Scheduler response viability - is_viable = False - if isinstance(cs_result, dict): - cs_response = cs_result.get("response", {}) - if cs_result.get("success", True) or cs_result.get("status_code") in (200, 201, 202): - is_viable = True - if isinstance(cs_response, dict): - if cs_response.get("status") in ("VIABLE", "SCHEDULED") or cs_response.get("viable") is True: - is_viable = True - - if is_viable: - logging.info(f"Change Scheduler confirmed viability for slice '{slice_id}'. Performing slice PUT modification...") + if _is_change_scheduler_viable(cs_result): + logging.info( + f"Change Scheduler confirmed viability for slice '{slice_id}'. Performing slice PUT modification..." + ) try: - xpath = f"/ietf-network-slice-service:network-slice-services" + xpath = "/ietf-network-slice-service:network-slice-services" intent = get_data_store(xpath) if not intent: raise ValueError("Network slice services not found") - if intent: - if isinstance(intent, dict) and "network-slice-services" in intent: - intent["ietf-network-slice-service:network-slice-services"] = intent.pop("network-slice-services") - logging.debug(f"Intent found for slice '{slice_id}': {intent}") - mod_result = self.nsc(intent, slice_id=slice_id) - - if isinstance(cs_result, dict): - cs_result["slice_modification"] = mod_result - logging.info(f"Slice '{slice_id}' successfully modified via PUT following Change Scheduler viability confirmation.") - update_data_store(intent) - else: - logging.warning(f"No stored intent found for slice '{slice_id}' during reconfiguration PUT modification.") + + if isinstance(intent, dict) and "network-slice-services" in intent: + intent["ietf-network-slice-service:network-slice-services"] = intent.pop("network-slice-services") + + logging.debug(f"Intent found for slice '{slice_id}': {intent}") + mod_result = self.nsc(intent, slice_id=slice_id) + + if isinstance(cs_result, dict): + cs_result["slice_modification"] = mod_result + logging.info( + f"Slice '{slice_id}' successfully modified via PUT following Change Scheduler viability confirmation." + ) + update_data_store(intent) except Exception as e: logging.error(f"Error during slice '{slice_id}' PUT modification: {e}") if isinstance(cs_result, dict): @@ -300,3 +290,4 @@ class NSController: + diff --git a/src/mapper/aggregate_monitoring.py b/src/mapper/aggregate_monitoring.py index 91d3c0f..5007c2c 100644 --- a/src/mapper/aggregate_monitoring.py +++ b/src/mapper/aggregate_monitoring.py @@ -1,64 +1,79 @@ -import logging -import time -from flask import current_app -from src.database.telemetry_client_db import get_telemetry, upsert_telemetry -from src.utils.safe_get import safe_get - - -def aggregate_monitoring(slice_id, slo_sle_template): - """ - Aggregates monitoring data for a given slice - - Args: - slice_id (str): The identifier of the network slice. - slo_sle_template (dict): The SLO-SLE template for the network slice. - """ - - if slice_id in current_app.config["TELEMETRY_CACHE"]: - slos = safe_get(slo_sle_template, ["slo-policy", "metric-bound"]) - slo_bandwidth = [ s["bound"] for s in slos if s.get("metric-type", "") == "ietf-network-slice-service:two-way-bandwidth" ][0] if slos else 0 - slo_latency = [ s["bound"] for s in slos if s.get("metric-type", "") == "ietf-network-slice-service:two-way-delay-maximum" ][0] if slos else "N/A" - - links = current_app.config["TELEMETRY_CACHE"][slice_id] - - if not links: - raise Exception(f"No telemetry data available for slice '{slice_id}'") - - bandwidth = min([link["bandwidth"] for _, link in links.items()]) - latency = sum([link["latency"] for _, link in links.items()]) - - violated_metrics = [] - - if slo_latency != "N/A" and latency > float(slo_latency): - violated_metrics.append("latency") - - if slo_bandwidth != 0 and bandwidth < float(slo_bandwidth): - violated_metrics.append("bandwidth") - - telemetry_json = { - "latency": latency, - "bandwidth": bandwidth - } - - compliance_json = { - "is_compliant": len(violated_metrics) == 0, - "violated_metrics": violated_metrics - } - - current_timestamp = time.time() - - upsert_telemetry( - slice_id=slice_id, - telemetry_list=telemetry_json, - slo_sle_compliance=compliance_json, - timestamp=current_timestamp - ) - - return { - "slice_id": slice_id, - "telemetry_list": telemetry_json, - "slo_sle_compliance": compliance_json, - "timestamp": current_timestamp - } - else: - raise Exception(f"Telemetry cache for slice '{slice_id}' is not initialized. Please make sure the SDN controller is sending telemetry data for this slice.") \ No newline at end of file +import time +from typing import Any + +from flask import current_app + +from src.database.telemetry_client_db import upsert_telemetry +from src.utils.safe_get import safe_get + + +def aggregate_monitoring(slice_id: str, slo_sle_template: dict[str, Any] | None) -> dict[str, Any]: + """ + Aggregates monitoring data for a given slice. + + Args: + slice_id (str): The identifier of the network slice. + slo_sle_template (dict[str, Any] | None): The SLO-SLE template for the network slice. + + Returns: + dict[str, Any]: Aggregated telemetry record. + + Raises: + Exception: If telemetry cache is not initialized or no telemetry data is available. + """ + telemetry_cache = current_app.config.get("TELEMETRY_CACHE", {}) + if slice_id not in telemetry_cache: + raise Exception( + f"Telemetry cache for slice '{slice_id}' is not initialized. " + "Please make sure the SDN controller is sending telemetry data for this slice." + ) + + links = telemetry_cache[slice_id] + if not links: + raise Exception(f"No telemetry data available for slice '{slice_id}'") + + slos = safe_get(slo_sle_template, ["slo-policy", "metric-bound"]) or [] + slo_bandwidth = next( + (s["bound"] for s in slos if s.get("metric-type", "") == "ietf-network-slice-service:two-way-bandwidth"), + 0, + ) + slo_latency = next( + (s["bound"] for s in slos if s.get("metric-type", "") == "ietf-network-slice-service:two-way-delay-maximum"), + "N/A", + ) + + bandwidth = min(link["bandwidth"] for link in links.values()) + latency = sum(link["latency"] for link in links.values()) + + violated_metrics: list[str] = [] + if slo_latency != "N/A" and latency > float(slo_latency): + violated_metrics.append("latency") + + if slo_bandwidth != 0 and bandwidth < float(slo_bandwidth): + violated_metrics.append("bandwidth") + + telemetry_json = { + "latency": latency, + "bandwidth": bandwidth, + } + + compliance_json = { + "is_compliant": len(violated_metrics) == 0, + "violated_metrics": violated_metrics, + } + + current_timestamp = time.time() + + upsert_telemetry( + slice_id=slice_id, + telemetry_list=telemetry_json, + slo_sle_compliance=compliance_json, + timestamp=current_timestamp, + ) + + return { + "slice_id": slice_id, + "telemetry_list": telemetry_json, + "slo_sle_compliance": compliance_json, + "timestamp": current_timestamp, + } \ No newline at end of file diff --git a/src/mapper/extract_sdp_info.py b/src/mapper/extract_sdp_info.py index 1c77831..d9d33fe 100644 --- a/src/mapper/extract_sdp_info.py +++ b/src/mapper/extract_sdp_info.py @@ -15,6 +15,7 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. import logging + from src.utils.safe_get import safe_get diff --git a/src/mapper/get_service_template.py b/src/mapper/get_service_template.py index d7b5976..4352b11 100644 --- a/src/mapper/get_service_template.py +++ b/src/mapper/get_service_template.py @@ -15,8 +15,10 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. 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. diff --git a/src/mapper/main.py b/src/mapper/main.py index 64a7f2c..80af575 100644 --- a/src/mapper/main.py +++ b/src/mapper/main.py @@ -1,188 +1,200 @@ -# Copyright 2022-2026 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 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 .aggregate_monitoring import aggregate_monitoring -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 -from src.database.service_db import save_data, update_data - -def mapper(payload, controller_type="TFS", action="CREATE"): - """ - Map an IETF network slice intent to the most suitable Network Resource Partition (NRP). - - This method: - 1. If NRP is enabled, retrieves the current NRP view - 2. Extracts Service Level Objectives (SLOs) from the intent - 3. Finds NRPs that can meet the SLO requirements - 4. Selects the best NRP based on viability and availability - 5. Attaches the slice to the selected NRP or creates a new one - 6. If planner is enabled, computes the optimal path for the slice - - Args: - ietf_intent (dict): IETF-formatted network slice intent. - controller_type (str): Type of SDN controller. - is_update (bool): Whether it is a modification request. - controller_type (str): Type of SDN controller. - is_update (bool): Whether it is a modification request. - - Returns: - dict or None: Optimal path if planner is enabled; otherwise, None. - """ - optimal_path = None - services = None - - if action == "CREATE": - ietf_intent = payload.get("intent", None) - services = [ietf_intent] - if current_app.config["NRP_ENABLED"]: - # Retrieve NRP view - nrp_view = realizer(None, True, "READ") - - # Extract Service Level Objectives (SLOs) from the intent - slos = ietf_intent["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["slo-policy"]["metric-bound"] - if slos: - # Find candidate NRPs that can meet the SLO requirements - candidates = [ - (nrp, slo_viability(slos, nrp)[1]) - for nrp in nrp_view - if slo_viability(slos, nrp)[0] and nrp["available"] - ] - logging.debug(f"Candidates: {candidates}") - - # Select the best NRP based on candidates - best_nrp = max(candidates, key=lambda x: x[1])[0] if candidates else None - logging.debug(f"Best NRP: {best_nrp}") - - if best_nrp: - best_nrp["slices"].append(ietf_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"]) - # Update NRP view - realizer(ietf_intent, True, "UPDATE") - # TODO Here we should put how the slice is attached to an already created nrp - else: - # Request the controller to create a new NRP that meets the SLOs - answer = realizer(ietf_intent, True, "CREATE", best_nrp) - if not answer: - logging.error("Slice rejected due to lack of NRPs") - return None - # TODO Here we should put how the slice is attached to the new nrp - - if current_app.config["PLANNER_ENABLED"]: - is_update = payload.get("is_update", False) - optimal_path = Planner().planner(ietf_intent, current_app.config["PLANNER_TYPE"], is_update=is_update) - logging.debug(f"Optimal path: {optimal_path}") - - 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"]) or []): - 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"]) or []): - 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']) - if isinstance(connectivity_type, str) and ":" in connectivity_type: - connectivity_type = connectivity_type.split(":")[-1] - 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) - if not current_app.config["DUMMY_MODE"]: - # Save mapping from service_id to slice_id - save_data(service_id=safe_get(service, ["id"]), slice_id=service_id) - logging.debug(f"Service added: {service}") - - # Break only for point-to-point - if connectivity_type == "point-to-point": - break - elif action == "MONITOR": - logging.debug("Mapper action: MONITOR") - slice_id = payload.get("slice_id", None) - slo_sle_template = payload.get("slo_sle_template", None) - metrics = aggregate_monitoring(slice_id, slo_sle_template) - return metrics - - return services, optimal_path \ No newline at end of file +# Copyright 2022-2026 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 typing import Any + +from flask import current_app + +from src.database.service_db import save_data +from src.database.sysrepo_store import get_data_store, normalize_libyang_data +from src.planner.planner import Planner +from src.realizer.main import realizer +from src.utils.safe_get import safe_get + +from .aggregate_monitoring import aggregate_monitoring +from .get_service_template import get_service_template +from .process_connnectivity import normalize_connectivity_type, process_connectivity +from .slo_viability import slo_viability + + +def _handle_nrp_mapping(ietf_intent: dict[str, Any]) -> bool: + """Evaluate and assign NRP for slice intent if NRP is enabled.""" + nrp_view = realizer(None, True, "READ") + slos = safe_get( + ietf_intent, + [ + "ietf-network-slice-service:network-slice-services", + "slo-sle-templates", + "slo-sle-template", + 0, + "slo-policy", + "metric-bound", + ], + ) + if not slos: + return True + + candidates = [ + (nrp, slo_viability(slos, nrp)[1]) + for nrp in nrp_view + if slo_viability(slos, nrp)[0] and nrp.get("available") + ] + logging.debug(f"Candidates: {candidates}") + + best_nrp = max(candidates, key=lambda x: x[1])[0] if candidates else None + logging.debug(f"Best NRP: {best_nrp}") + + if best_nrp: + slice_id = safe_get( + ietf_intent, + ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "id"], + ) + if slice_id: + best_nrp.setdefault("slices", []).append(slice_id) + realizer(ietf_intent, True, "UPDATE") + return True + + answer = realizer(ietf_intent, True, "CREATE", best_nrp) + if not answer: + logging.error("Slice rejected due to lack of NRPs") + return False + return True + + + +def _collect_available_templates(ietf_intent: dict[str, Any]) -> list[dict[str, Any]]: + """Retrieve and combine sysrepo datastore and intent SLO/SLE templates.""" + raw_templates = get_data_store( + "/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template" + ) + normalized = normalize_libyang_data(raw_templates) + available_templates: list[dict[str, Any]] = safe_get( + normalized, ["slo-sle-templates", "slo-sle-template"] + ) or [] + + intent_templates = safe_get( + ietf_intent, + ["ietf-network-slice-service:network-slice-services", "slo-sle-templates", "slo-sle-template"], + ) or [] + available_templates.extend(intent_templates) + return available_templates + + +def _map_restconf_services(ietf_intent: dict[str, Any]) -> list[dict[str, Any]]: + """Transform IETF intent into discrete RESTCONF service constructs.""" + available_templates = _collect_available_templates(ietf_intent) + services: list[dict[str, Any]] = [] + + slice_services = safe_get( + ietf_intent, ["ietf-network-slice-service:network-slice-services", "slice-service"] + ) or [] + + for slice_service in slice_services: + service_id = safe_get(slice_service, ["id"]) + way = safe_get(slice_service, ["service-tags", "tag-type", 0, "tag-type-value", 0]) + service_template = get_service_template(slice_service, available_templates) + + connection_groups = safe_get(slice_service, ["connection-groups", "connection-group"]) or [] + for connection_group in connection_groups: + cg_id = safe_get(connection_group, ["id"]) + group_id = f"{service_id}-{cg_id}" + + group_template = get_service_template(connection_group, available_templates) or service_template + raw_conn_type = safe_get(connection_group, ["connectivity-type"]) + connectivity_type = normalize_connectivity_type(raw_conn_type) + + constructs = safe_get(connection_group, ["connectivity-construct"]) or [] + for construct in constructs: + construct_id_raw = safe_get(construct, ["id"]) + full_construct_id = f"{group_id}-{construct_id_raw}" + + final_template = get_service_template(construct, available_templates) or group_template + + sdps = process_connectivity( + cg_id, + connectivity_type, + construct, + construct_id_raw, + slice_service, + ) + if not sdps: + continue + + service = { + "id": full_construct_id, + "template": final_template, + "connectivity_type": connectivity_type, + "sdps": sdps, + "way": way, + } + services.append(service) + + if not current_app.config.get("DUMMY_MODE", False): + save_data(service_id=service["id"], slice_id=service_id) + + if connectivity_type == "point-to-point": + break + + return services + + +def mapper( + payload: dict[str, Any], + controller_type: str = "TFS", + action: str = "CREATE", +) -> tuple[Any, Any] | dict[str, Any] | None: + """ + Map an IETF network slice intent to suitable Network Resource Partitions or controllers. + + Args: + payload (dict[str, Any]): Mapping request payload. + controller_type (str, optional): SDN controller type. Defaults to "TFS". + action (str, optional): Requested action ('CREATE', 'MONITOR'). Defaults to "CREATE". + + Returns: + tuple[Any, Any] | dict[str, Any] | None: Mapped services/optimal path or telemetry dictionary. + """ + match action: + case "CREATE": + ietf_intent = payload.get("intent") + services: Any = [ietf_intent] + optimal_path = None + + if current_app.config.get("NRP_ENABLED", False): + if not _handle_nrp_mapping(ietf_intent): + return None + + if current_app.config.get("PLANNER_ENABLED", False): + is_update = payload.get("is_update", False) + planner_type = current_app.config.get("PLANNER_TYPE", "SHORTEST_PATH") + optimal_path = Planner().planner(ietf_intent, planner_type, is_update=is_update) + logging.debug(f"Optimal path: {optimal_path}") + + if controller_type == "RESTCONF": + services = _map_restconf_services(ietf_intent) + + return services, optimal_path + + case "MONITOR": + logging.debug("Mapper action: MONITOR") + slice_id = payload.get("slice_id") + slo_sle_template = payload.get("slo_sle_template") + return aggregate_monitoring(slice_id, slo_sle_template) + + case _: + return None, None + \ No newline at end of file diff --git a/src/mapper/process_connnectivity.py b/src/mapper/process_connnectivity.py index b424843..4b51a99 100644 --- a/src/mapper/process_connnectivity.py +++ b/src/mapper/process_connnectivity.py @@ -1,89 +1,164 @@ -# Copyright 2022-2026 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. - -from src.utils.safe_get import safe_get -from .extract_sdp_info import extract_sdp_info - -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 == "any-to-any": - a2a_list = safe_get(connectivity_construct, ["a2a-sdp"]) or [] - for sdp in a2a_list: - 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 == "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 +# Copyright 2022-2026 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. + +from typing import Any + +from src.utils.safe_get import safe_get + +from .extract_sdp_info import extract_sdp_info + + +def normalize_connectivity_type(connectivity_type: str | None) -> str: + """ + Normalize connectivity type to standard format: 'point-to-point', 'any-to-any', or 'hub-spoke'. + + Args: + connectivity_type (str | None): Raw connectivity string (e.g. 'ietf-vpn-common:any-to-any'). + + Returns: + str: Normalized connectivity type name. + """ + if not connectivity_type or not isinstance(connectivity_type, str): + return "" + return connectivity_type.split(":")[-1].strip().lower() + + +def _process_any_to_any( + connectivity_construct: dict[str, Any], + slice_service: dict[str, Any], + connection_group_id: str, + connectivity_construct_id: str, +) -> list[dict[str, Any]]: + """Process any-to-any connectivity SDPs.""" + sdps: list[dict[str, Any]] = [] + a2a_list = safe_get(connectivity_construct, ["a2a-sdp"]) or [] + for sdp_id in a2a_list: + sdp_info, match_criteria = extract_sdp_info( + sdp_id, slice_service, connection_group_id, connectivity_construct_id + ) + sdps.append({ + "sdp": sdp_info, + "match_criteria": match_criteria, + "type": "both", + }) + return sdps + + +def _process_hub_spoke( + connectivity_construct: dict[str, Any], + slice_service: dict[str, Any], + connection_group_id: str, + connectivity_construct_id: str, +) -> list[dict[str, Any]]: + """Process hub-and-spoke (P2MP) sender and receiver SDPs.""" + sdps: list[dict[str, Any]] = [] + + 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 + ) + sdps.append({ + "sdp": sdp_source, + "match_criteria": match_criteria, + "type": "sender", + }) + + receivers = safe_get(connectivity_construct, ["p2mp-receiver-sdp"]) or [] + for sdp_id in receivers: + sdp_info, match_criteria = extract_sdp_info( + sdp_id, slice_service, connection_group_id, connectivity_construct_id + ) + sdps.append({ + "sdp": sdp_info, + "match_criteria": match_criteria, + "type": "receiver", + }) + + return sdps + + +def _process_point_to_point( + connectivity_construct: dict[str, Any], + slice_service: dict[str, Any], + connection_group_id: str, + connectivity_construct_id: str, +) -> list[dict[str, Any]]: + """Process point-to-point (P2P) sender and receiver SDPs.""" + sdps: list[dict[str, Any]] = [] + + 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 + ) + sdps.append({ + "sdp": sdp_source, + "match_criteria": match_criteria, + "type": "sender", + }) + + 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 + ) + sdps.append({ + "sdp": sdp_destination, + "match_criteria": match_criteria, + "type": "receiver", + }) + + return sdps + + +def process_connectivity( + connection_group_id: str, + connectivity_type: str, + connectivity_construct: dict[str, Any], + connectivity_construct_id: str, + slice_service: dict[str, Any], +) -> list[dict[str, Any]]: + """ + Process connectivity construct based on normalized type. + + Args: + connection_group_id (str): ID of the connection group. + connectivity_type (str): Type of connectivity (raw or normalized). + connectivity_construct (dict[str, Any]): Connectivity configuration construct. + connectivity_construct_id (str): Construct ID. + slice_service (dict[str, Any]): Parent slice service dict. + + Returns: + list[dict[str, Any]]: List of resolved SDP dictionaries. + """ + normalized_type = normalize_connectivity_type(connectivity_type) + + match normalized_type: + case "any-to-any": + return _process_any_to_any( + connectivity_construct, slice_service, connection_group_id, connectivity_construct_id + ) + case "hub-spoke": + return _process_hub_spoke( + connectivity_construct, slice_service, connection_group_id, connectivity_construct_id + ) + case "point-to-point": + return _process_point_to_point( + connectivity_construct, slice_service, connection_group_id, connectivity_construct_id + ) + case _: + return [] \ No newline at end of file diff --git a/src/mapper/slo_viability.py b/src/mapper/slo_viability.py index cc6ea62..d1851cb 100644 --- a/src/mapper/slo_viability.py +++ b/src/mapper/slo_viability.py @@ -1,64 +1,91 @@ -# Copyright 2022-2026 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 - -def slo_viability(slice_slos, nrp_slos): - """ - Compare Service Level Objectives (SLOs) between a slice and a Network Resource Partition (NRP). - - This method assesses whether an NRP can satisfy the SLOs of a network slice. - - Args: - slice_slos (list): Service Level Objectives of the slice - nrp_slos (dict): Service Level Objectives of the Network Resource Pool - - Returns: - tuple: A boolean indicating viability and a flexibility score - - First value: True if NRP meets SLOs, False otherwise - - Second value: A score representing how well the NRP meets the SLOs - """ - # Define SLO types for maximum and minimum constraints - slo_type = { - "max": ["one-way-delay-maximum", "two-way-delay-maximum", "one-way-delay-percentile", "two-way-delay-percentile", - "one-way-delay-variation-maximum", "two-way-delay-variation-maximum", - "one-way-delay-variation-percentile", "two-way-delay-variation-percentile", - "one-way-packet-loss", "two-way-packet-loss"], - "min": ["one-way-bandwidth", "two-way-bandwidth", "shared-bandwidth"] - } - score = 0 - flexibility_scores = [] - for slo in slice_slos: - for nrp_slo in nrp_slos['slos']: - if slo["metric-type"] == nrp_slo["metric-type"]: - # Handle maximum type SLOs - if slo["metric-type"] in slo_type["max"]: - logging.debug(f"SLO: {slo}, NRP SLO: {nrp_slo}") - flexibility = (slo["bound"] - nrp_slo["bound"]) / slo["bound"] - if slo["bound"] < nrp_slo["bound"]: - return False, 0 # Does not meet maximum constraint - # Handle minimum type SLOs - if slo["metric-type"] in slo_type["min"]: - logging.debug(f"SLO: {slo}, NRP SLO: {nrp_slo}") - flexibility = (nrp_slo["bound"] - slo["bound"]) / slo["bound"] - if slo["bound"] > nrp_slo["bound"]: - return False, 0 # Does not meet minimum constraint - flexibility_scores.append(flexibility) - break # Exit inner loop after finding matching metric - - # Calculate final viability score - score = sum(flexibility_scores) / len(flexibility_scores) if flexibility_scores else 0 - return True, score # If it passed all verifications, the NRP is viable \ No newline at end of file +# Copyright 2022-2026 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 typing import Any, Final + +logger = logging.getLogger(__name__) + +MAX_SLO_TYPES: Final[frozenset[str]] = frozenset({ + "one-way-delay-maximum", + "two-way-delay-maximum", + "one-way-delay-percentile", + "two-way-delay-percentile", + "one-way-delay-variation-maximum", + "two-way-delay-variation-maximum", + "one-way-delay-variation-percentile", + "two-way-delay-variation-percentile", + "one-way-packet-loss", + "two-way-packet-loss", +}) + +MIN_SLO_TYPES: Final[frozenset[str]] = frozenset({ + "one-way-bandwidth", + "two-way-bandwidth", + "shared-bandwidth", +}) + + +def slo_viability( + slice_slos: list[dict[str, Any]], + nrp_slos: dict[str, Any], +) -> tuple[bool, float]: + """ + Compare Service Level Objectives (SLOs) between a slice and an NRP. + + Args: + slice_slos (list[dict[str, Any]]): Service Level Objectives of the slice. + nrp_slos (dict[str, Any]): Service Level Objectives of the Network Resource Partition. + + Returns: + tuple[bool, float]: (is_viable, flexibility_score) + """ + nrp_slo_list = nrp_slos.get("slos", []) + flexibility_scores: list[float] = [] + + for slo in slice_slos: + metric_type = slo.get("metric-type") + slice_bound = slo.get("bound") + if slice_bound is None: + continue + + for nrp_slo in nrp_slo_list: + if nrp_slo.get("metric-type") != metric_type: + continue + + nrp_bound = nrp_slo.get("bound") + if nrp_bound is None: + continue + + logger.debug("SLO: %s, NRP SLO: %s", slo, nrp_slo) + divisor = slice_bound if slice_bound != 0 else 1.0 + + if metric_type in MAX_SLO_TYPES: + if slice_bound < nrp_bound: + return False, 0.0 + flexibility = (slice_bound - nrp_bound) / divisor + flexibility_scores.append(flexibility) + + elif metric_type in MIN_SLO_TYPES: + if slice_bound > nrp_bound: + return False, 0.0 + flexibility = (nrp_bound - slice_bound) / divisor + flexibility_scores.append(flexibility) + + break + + score = sum(flexibility_scores) / len(flexibility_scores) if flexibility_scores else 0.0 + return True, score \ No newline at end of file diff --git a/src/nbi_processor/detect_format.py b/src/nbi_processor/detect_format.py index 03ab9a0..06d2760 100644 --- a/src/nbi_processor/detect_format.py +++ b/src/nbi_processor/detect_format.py @@ -1,40 +1,51 @@ -# Copyright 2022-2026 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. - -def detect_format(json_data): - """ - Detect the format of the input network slice intent. - - This method identifies whether the input JSON is in 3GPP or IETF format - by checking for specific keys in the JSON structure. - - Args: - json_data (dict): Input network slice intent JSON - - Returns: - str or None: - - "IETF" if IETF-specific keys are found - - "3GPP" if 3GPP-specific keys are found - - None if no recognizable format is detected - """ - # Check for IETF-specific key - if ("ietf-network-slice-service:network-slice-services" in json_data or "network-slice-services" in json_data): - return "IETF" - # Check for 3GPP-specific keys - if any(key in json_data for key in ["NetworkSlice1", "TopSliceSubnet1", "CNSliceSubnet1", "RANSliceSubnet1"]): - return "3GPP" - - return None \ No newline at end of file +# Copyright 2022-2026 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. + +from typing import Any, Final + +IETF_KEYS: Final[tuple[str, ...]] = ( + "ietf-network-slice-service:network-slice-services", + "network-slice-services", +) + +GPP_KEYS: Final[tuple[str, ...]] = ( + "NetworkSlice1", + "TopSliceSubnet1", + "CNSliceSubnet1", + "RANSliceSubnet1", +) + + +def detect_format(json_data: Any) -> str | None: + """ + Detect the format of the input network slice intent. + + Args: + json_data (Any): Input network slice intent data. + + Returns: + str | None: "IETF", "3GPP", or None if unrecognized. + """ + if not isinstance(json_data, dict): + return None + + if any(key in json_data for key in IETF_KEYS): + return "IETF" + + if any(key in json_data for key in GPP_KEYS): + return "3GPP" + + return None \ No newline at end of file diff --git a/src/nbi_processor/main.py b/src/nbi_processor/main.py index 2e1787c..1e74fad 100644 --- a/src/nbi_processor/main.py +++ b/src/nbi_processor/main.py @@ -1,56 +1,55 @@ -# Copyright 2022-2026 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 .detect_format import detect_format -from .translator import translator - -def nbi_processor(intent_json): - """ - Process and translate network slice intents from different formats (3GPP or IETF). - - This method detects the input JSON format and converts 3GPP intents to IETF format. - - Args: - intent_json (dict): Input network slice intent in either 3GPP or IETF format. - - Returns: - list: A list of IETF-formatted network slice intents. - - Raises: - ValueError: If the JSON request format is not recognized. - """ - # Detect the input JSON format (3GPP or IETF) - format = detect_format(intent_json) - ietf_intents = [] - - # TODO Needs to be generalized to support different names of slicesubnets - # Process different input formats - if format == "3GPP": - # Translate each subnet in 3GPP format to IETF format - for subnet in intent_json["RANSliceSubnet1"]["networkSliceSubnetRef"]: - ietf_intents.append(translator(intent_json, subnet)) - logging.info(f"3GPP requests translated to IETF template") - elif format == "IETF": - # If already in IETF format, add directly - logging.info(f"IETF intent received") - ietf_intents.append(intent_json) - else: - # Handle unrecognized format - logging.error(f"JSON request format not recognized") - raise ValueError("JSON request format not recognized") - - return ietf_intents or None \ No newline at end of file +# Copyright 2022-2026 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 typing import Any + +from .detect_format import detect_format +from .translator import translator + +logger = logging.getLogger(__name__) + + +def nbi_processor(intent_json: dict[str, Any]) -> list[dict[str, Any]] | None: + """ + Process and translate network slice intents from different formats (3GPP or IETF). + + Args: + intent_json (dict[str, Any]): Input network slice intent in either 3GPP or IETF format. + + Returns: + list[dict[str, Any]] | None: A list of IETF-formatted network slice intents. + + Raises: + ValueError: If the JSON request format is not recognized. + """ + detected_format = detect_format(intent_json) + ietf_intents: list[dict[str, Any]] = [] + + match detected_format: + case "3GPP": + for subnet in intent_json["RANSliceSubnet1"]["networkSliceSubnetRef"]: + ietf_intents.append(translator(intent_json, subnet)) + logger.info("3GPP requests translated to IETF template") + case "IETF": + + logger.info("IETF intent received") + ietf_intents.append(intent_json) + case _: + logger.error("JSON request format not recognized") + raise ValueError("JSON request format not recognized") + + return ietf_intents if ietf_intents else None \ No newline at end of file diff --git a/src/nbi_processor/translator.py b/src/nbi_processor/translator.py index 79c1aaf..476e796 100644 --- a/src/nbi_processor/translator.py +++ b/src/nbi_processor/translator.py @@ -1,107 +1,125 @@ -# Copyright 2022-2026 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 uuid, os -from src.utils.load_template import load_template -from src.config.constants import TEMPLATES_PATH - -def translator(gpp_intent, subnet): - """ - Translate a 3GPP network slice intent to IETF format. - - This method converts a 3GPP intent into a standardized IETF intent template, - mapping key parameters such as QoS profiles, service endpoints, and connection details. - - Notes: - - Generates a unique slice service ID using UUID - - Maps QoS requirements, source/destination endpoints - - Logs the translated intent to a JSON file for reference - - Args: - gpp_intent (dict): Original 3GPP network slice intent - subnet (str): Specific subnet reference within the 3GPP intent - - Returns: - dict: Translated IETF-formatted network slice intent - """ - # Load IETF template and create a copy to modify - ietf_i = load_template(os.path.join(TEMPLATES_PATH, "ietf_template_empty.json")) - - # Extract endpoint transport objects - ep_transport_objects = gpp_intent[subnet]["EpTransport"] - - # Populate template with SLOs (currently supporting QoS profile, latency and bandwidth) - ietf_i["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["id"] = gpp_intent[ep_transport_objects[0]]["qosProfile"] - - profile = gpp_intent.get(subnet, {}).get("SliceProfileList", [{}])[0].get("RANSliceSubnetProfile", {}) - - - metrics = { - ("uLThptPerSliceSubnet", "MaxThpt"): ("one-way-bandwidth", "kbps"), - ("uLLatency",): ("one-way-delay-maximum", "milliseconds"), - ("EnergyConsumption",): ("energy_consumption", "Joules"), - ("EnergyEfficiency",): ("energy_efficiency", "W/bps"), - ("CarbonEmissions",): ("carbon_emission", "gCO2eq"), - ("RenewableEnergyUsage",): ("renewable_energy_usage", "rate") - } - - # Aux - def get_nested(d, keys): - for k in keys: - if isinstance(d, dict) and k in d: - d = d[k] - else: - return None - return d - - for key_path, (metric_type, metric_unit) in metrics.items(): - value = get_nested(profile, key_path) - if value is not None: - ietf_i["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]\ - ["slo-sle-template"][0]["slo-policy"]["metric-bound"].append({ - "metric-type": metric_type, - "metric-unit": metric_unit, - "bound": value - }) - - - # Generate unique slice service ID and description - ietf_i["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"] = f"slice-service-{uuid.uuid4()}" - ietf_i["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["description"] = f"Transport network slice mapped with 3GPP slice {next(iter(gpp_intent))}" - ietf_i["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["slo-sle-template"] = ietf_i["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["id"] - - # Configure Source SDP - ietf_i["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][0]["node-id"] = ep_transport_objects[0].split(" ", 1)[1] - ietf_i["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][0]["sdp-ip-address"] = gpp_intent[gpp_intent[ep_transport_objects[0]]["EpApplicationRef"][0]]["localAddress"] - ietf_i["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][0]["service-match-criteria"]["match-criterion"][0]["match-type"][0]["type"] = gpp_intent[ep_transport_objects[0]]["logicalInterfaceInfo"]["logicalInterfaceType"].lower() - ietf_i["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][0]["service-match-criteria"]["match-criterion"][0]["match-type"][0]["vlan"] = [gpp_intent[ep_transport_objects[0]]["logicalInterfaceInfo"]["logicalInterfaceId"]] - ietf_i["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][0]["attachment-circuits"]["attachment-circuit"][0]["ac-ipv4-address"] = gpp_intent[ep_transport_objects[0]]["IpAddress"] - ietf_i["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][0]["attachment-circuits"]["attachment-circuit"][0]["sdp-peering"]["peer-sap-id"] = gpp_intent[ep_transport_objects[0]]["NextHopInfo"] - - # Configure Destination SDP - ietf_i["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][1]["node-id"] = ep_transport_objects[1].split(" ", 1)[1] - ietf_i["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][1]["sdp-ip-address"] = gpp_intent[gpp_intent[ep_transport_objects[1]]["EpApplicationRef"][0]]["localAddress"] - ietf_i["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][1]["service-match-criteria"]["match-criterion"][0]["match-type"][0]["type"] = gpp_intent[ep_transport_objects[1]]["logicalInterfaceInfo"]["logicalInterfaceType"].lower() - ietf_i["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][1]["service-match-criteria"]["match-criterion"][0]["match-type"][0]["vlan"] = [gpp_intent[ep_transport_objects[1]]["logicalInterfaceInfo"]["logicalInterfaceId"]] - ietf_i["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][1]["attachment-circuits"]["attachment-circuit"][0]["ac-ipv4-address"] = gpp_intent[ep_transport_objects[1]]["IpAddress"] - ietf_i["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][1]["attachment-circuits"]["attachment-circuit"][0]["sdp-peering"]["peer-sap-id"] = gpp_intent[ep_transport_objects[1]]["NextHopInfo"] - - # Configure Connection Group and match-criteria - ietf_i["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["connection-groups"]["connection-group"][0]["id"] = f"{ep_transport_objects[0].split(' ', 1)[1]}_{ep_transport_objects[1].split(' ', 1)[1]}" - ietf_i["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][0]["service-match-criteria"]["match-criterion"][0]["target-connection-group-id"] = f"{ep_transport_objects[0].split(' ', 1)[1]}_{ep_transport_objects[1].split(' ', 1)[1]}" - ietf_i["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][1]["service-match-criteria"]["match-criterion"][0]["target-connection-group-id"] = f"{ep_transport_objects[0].split(' ', 1)[1]}_{ep_transport_objects[1].split(' ', 1)[1]}" - - return ietf_i \ No newline at end of file +# Copyright 2022-2026 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 uuid +from pathlib import Path +from typing import Any, Final + +from src.config.constants import TEMPLATES_PATH +from src.utils.load_template import load_template +from src.utils.safe_get import safe_get + +METRICS_MAPPING: Final[dict[tuple[str, ...], tuple[str, str]]] = { + ("uLThptPerSliceSubnet", "MaxThpt"): ("one-way-bandwidth", "kbps"), + ("uLLatency",): ("one-way-delay-maximum", "milliseconds"), + ("EnergyConsumption",): ("energy_consumption", "Joules"), + ("EnergyEfficiency",): ("energy_efficiency", "W/bps"), + ("CarbonEmissions",): ("carbon_emission", "gCO2eq"), + ("RenewableEnergyUsage",): ("renewable_energy_usage", "rate"), +} + + +def _extract_metrics_bounds(profile: dict[str, Any]) -> list[dict[str, Any]]: + """Extract metric bounds from a 3GPP slice profile dictionary.""" + metric_bounds: list[dict[str, Any]] = [] + for key_path, (metric_type, metric_unit) in METRICS_MAPPING.items(): + value = safe_get(profile, key_path) + if value is not None: + metric_bounds.append({ + "metric-type": metric_type, + "metric-unit": metric_unit, + "bound": value, + }) + return metric_bounds + + +def _configure_sdp(sdp_entry: dict[str, Any], ep_name: str, gpp_intent: dict[str, Any]) -> str: + """Configure SDP node, IP, VLAN, and attachment circuits from 3GPP intent.""" + ep_data = gpp_intent.get(ep_name, {}) + node_id = ep_name.split(" ", 1)[1] if " " in ep_name else ep_name + + app_ref = ep_data.get("EpApplicationRef", [None])[0] + local_address = gpp_intent.get(app_ref, {}).get("localAddress", "") if app_ref else "" + logical_info = ep_data.get("logicalInterfaceInfo", {}) + interface_type = logical_info.get("logicalInterfaceType", "").lower() + vlan_id = logical_info.get("logicalInterfaceId") + + sdp_entry["node-id"] = node_id + sdp_entry["sdp-ip-address"] = local_address + + match_criterion = sdp_entry["service-match-criteria"]["match-criterion"][0] + match_type = match_criterion["match-type"][0] + match_type["type"] = interface_type + match_type["vlan"] = [vlan_id] if vlan_id is not None else [] + + attachment = sdp_entry["attachment-circuits"]["attachment-circuit"][0] + attachment["ac-ipv4-address"] = ep_data.get("IpAddress", "") + attachment["sdp-peering"]["peer-sap-id"] = ep_data.get("NextHopInfo", "") + + return node_id + + +def translator(gpp_intent: dict[str, Any], subnet: str) -> dict[str, Any]: + """ + Translate a 3GPP network slice intent to IETF format. + + Args: + gpp_intent (dict[str, Any]): Original 3GPP network slice intent. + subnet (str): Specific subnet reference within the 3GPP intent. + + Returns: + dict[str, Any]: Translated IETF-formatted network slice intent. + """ + template_file = Path(TEMPLATES_PATH) / "ietf_template_empty.json" + ietf_intent = load_template(str(template_file)) + if not isinstance(ietf_intent, dict): + raise ValueError("Failed to load empty IETF template.") + + ep_transport_objects = gpp_intent[subnet]["EpTransport"] + + + # Populate SLO template + ep_0 = gpp_intent.get(ep_transport_objects[0], {}) + qos_profile = ep_0.get("qosProfile", "") + + slo_template = ietf_intent["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0] + slo_template["id"] = qos_profile + + profile_list = gpp_intent.get(subnet, {}).get("SliceProfileList", [{}]) + profile = profile_list[0].get("RANSliceSubnetProfile", {}) if profile_list else {} + + bounds = _extract_metrics_bounds(profile) + slo_template["slo-policy"]["metric-bound"].extend(bounds) + + # Slice service headers + slice_service = ietf_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0] + slice_id = f"slice-service-{uuid.uuid4()}" + gpp_root_key = next(iter(gpp_intent)) + + slice_service["id"] = slice_id + slice_service["description"] = f"Transport network slice mapped with 3GPP slice {gpp_root_key}" + slice_service["slo-sle-template"] = qos_profile + + # Configure SDPs + sdp_list = slice_service["sdps"]["sdp"] + node_id_src = _configure_sdp(sdp_list[0], ep_transport_objects[0], gpp_intent) + node_id_dst = _configure_sdp(sdp_list[1], ep_transport_objects[1], gpp_intent) + + connection_group_id = f"{node_id_src}_{node_id_dst}" + slice_service["connection-groups"]["connection-group"][0]["id"] = connection_group_id + sdp_list[0]["service-match-criteria"]["match-criterion"][0]["target-connection-group-id"] = connection_group_id + sdp_list[1]["service-match-criteria"]["match-criterion"][0]["target-connection-group-id"] = connection_group_id + + return ietf_intent \ No newline at end of file diff --git a/src/planner/change_scheduler_planner/change_scheduler.py b/src/planner/change_scheduler_planner/change_scheduler.py index 7eb08e4..d0295e6 100644 --- a/src/planner/change_scheduler_planner/change_scheduler.py +++ b/src/planner/change_scheduler_planner/change_scheduler.py @@ -15,9 +15,11 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. import logging -import requests from datetime import datetime, timezone + +import requests from flask import current_app + from src.database.service_db import get_data_by_slice_id from src.planner.shortest_path import get_shortest_path, normalize_node_id from src.realizer.restconf.connectors.tfs_connector import tfs_connector diff --git a/src/planner/e2e_optical_planner/e2e_optical.py b/src/planner/e2e_optical_planner/e2e_optical.py index 75854b4..ee18719 100644 --- a/src/planner/e2e_optical_planner/e2e_optical.py +++ b/src/planner/e2e_optical_planner/e2e_optical.py @@ -12,10 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. +import json import logging + import requests -import json -from src.utils.safe_get import safe_get + def e2e_optical_planner(intent, ip: str, action: str = "create") -> dict: """ diff --git a/src/planner/energy_planner/energy.py b/src/planner/energy_planner/energy.py index 5f23c5c..00978ae 100644 --- a/src/planner/energy_planner/energy.py +++ b/src/planner/energy_planner/energy.py @@ -1,393 +1,333 @@ -# Copyright 2022-2026 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, random, os, json, heapq -from src.config.constants import SRC_PATH -from flask import current_app -from src.utils.safe_get import safe_get - - -def energy_planner(intent): - """ - Plan an optimal network path based on energy consumption metrics. - - This function calculates the most energy-efficient path between source - and destination nodes, considering energy consumption, carbon emissions, - energy efficiency, and renewable energy usage constraints. - - Args: - intent (dict): Network slice intent containing service delivery points - and energy-related SLO constraints - - Returns: - list or None: Ordered list of node names representing the optimal path, - or None if no valid path is found or topology is not recognized - - Notes: - - Only supports topology with nodes A through G - - Can use external PCE or internal Dijkstra-based algorithm - - Considers DLOS (Delay and Loss Objectives) for energy metrics: - EC (Energy Consumption), CE (Carbon Emission), - EE (Energy Efficiency), URE (Renewable Energy Usage) - - Raises: - Exception: For errors in energy metrics or topology retrieval - """ - energy_metrics = retrieve_energy() - topology = retrieve_topology() - source = safe_get(intent, ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "sdps", "sdp", 0, "node-id"]) - destination = safe_get(intent, ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "sdps", "sdp", 1, "node-id"]) - optimal_path = [] - allowed_ids = {"A", "B", "C", "D", "E", "F", "G"} - - if source not in allowed_ids or destination not in allowed_ids: - logging.warning(f"Topology not recognized (source: {source}, destination: {destination}). Skipping energy-based planning.") - return None - - # If using an external PCE - if current_app.config["PCE_EXTERNAL"]: - logging.debug("Using external PCE for path planning") - def build_slice_input(node_source, node_destination): - """Build input format for external PCE slice computation.""" - return { - "clientName": "demo-client", - "requestId": random.randint(1000, 9999), - "sites": [node_source["nodeId"], node_destination["nodeId"]], - "graph": { - "nodes": [ - { - "nodeId": node_source["nodeId"], - "name": node_source["name"], - "footprint": node_source["footprint"], - "sticky": [node_source["nodeId"]] - }, - { - "nodeId": node_destination["nodeId"], - "name": node_destination["name"], - "footprint": node_destination["footprint"], - "sticky": [node_destination["nodeId"]] - } - ], - "links": [ - { - "fromNodeId": node_source["nodeId"], - "toNodeId": node_destination["nodeId"], - "bandwidth": 1000000000, - "metrics": [ - { - "metric": "DELAY", - "value": 10, - "bound": True, - "required": True - } - ] - } - ], - "constraints": { - "maxVulnerability": 3, - "maxDeployedServices": 10, - "metricLimits": [] - } - } - } - - source = next((node for node in topology["nodes"] if node["name"] == source), None) - destination = next((node for node in topology["nodes"] if node["name"] == destination), None) - slice_input = build_slice_input(source, destination) - - def simulate_slice_output(input_data): - """ - Simulate external PCE response for slice computation. - - Args: - input_data (dict): Input data for slice computation - - Returns: - dict: Simulated slice output with path information - """ - return { - "input": input_data, - "slice": { - "nodes": [ - {"site": 1, "service": 1}, - {"site": 2, "service": 2} - ], - "links": [ - { - "fromNodeId": 1, - "toNodeId": 2, - "lspId": 500, - "path": { - "ingressNodeId": 1, - "egressNodeId": 2, - "hops": [ - {"nodeId": 3, "linkId": "A-C", "portId": 1}, - {"nodeId": 2, "linkId": "C-B", "portId": 2} - ] - } - } - ], - "metric": {"value": 9} - }, - "error": None - } - - slice_output = simulate_slice_output(slice_input) - # Build optimal path from PCE response - optimal_path.append(source["name"]) - for link in slice_output["slice"]["links"]: - for hop in link["path"]["hops"]: - optimal_path.append(next((node for node in topology["nodes"] if node["nodeId"] == hop['nodeId']), None)["name"]) - - else: - logging.debug("Using internal PCE for path planning") - ietf_dlos = intent["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["slo-policy"]["metric-bound"] - logging.debug(ietf_dlos) - - # Extract DLOS (Delay and Loss Objectives) constraints - dlos = { - "EC": next((item.get("bound") for item in ietf_dlos if item.get("metric-type") == "energy_consumption"), None), - "CE": next((item.get("bound") for item in ietf_dlos if item.get("metric-type") == "carbon_emission"), None), - "EE": next((item.get("bound") for item in ietf_dlos if item.get("metric-type") == "energy_efficiency"), None), - "URE": next((item.get("bound") for item in ietf_dlos if item.get("metric-type") == "renewable_energy_usage"), None) - } - logging.debug(f"Planning optimal path from {source} to {destination} with DLOS: {dlos}") - optimal_path = calculate_optimal_path(topology, energy_metrics, source, destination, dlos) - - if not optimal_path: - logging.error("No valid energy path found") - return None - - return optimal_path - - -def retrieve_energy(): - """ - Retrieve energy consumption data for network nodes. - - Returns: - dict: Energy metrics including power consumption, carbon emissions, - efficiency, and renewable energy usage for each node - - Notes: - TODO: Implement logic to retrieve real-time data from controller - Currently reads from static JSON file - """ - with open(os.path.join(SRC_PATH, "planner/energy_planner/energy_ddbb.json"), "r") as archivo: - energy_metrics = json.load(archivo) - return energy_metrics - - -def retrieve_topology(): - """ - Retrieve network topology information. - - Returns: - dict: Network topology with nodes and links - - Notes: - - If PCE_EXTERNAL is True, retrieves topology for external PCE format - - Otherwise retrieves topology in internal format - TODO: Implement logic to retrieve real-time data from controller - Currently reads from static JSON files - """ - if current_app.config["PCE_EXTERNAL"]: - # TODO: Implement the logic to retrieve topology data from external PCE - # GET /sss/v1/topology/node and /sss/v1/topology/link - with open(os.path.join(SRC_PATH, "planner/energy_planner/ext_topo_ddbb.json"), "r") as archivo: - topology = json.load(archivo) - else: - # TODO: Implement the logic to retrieve topology data from controller - with open(os.path.join(SRC_PATH, "planner/energy_planner/topo_ddbb.json"), "r") as archivo: - topology = json.load(archivo) - return topology - - -def calculate_optimal_path(topology, energy_metrics, source, destination, dlos): - """ - Calculate the optimal path using Dijkstra's algorithm with energy constraints. - - This function implements a constrained shortest path algorithm that considers - energy consumption, carbon emissions, energy efficiency, and renewable energy - usage as optimization criteria. - - Args: - topology (dict): Network topology with nodes and links - energy_metrics (dict): Energy consumption data for each node - source (str): Source node identifier - destination (str): Destination node identifier - dlos (dict): Constraint bounds for: - - EC: Energy Consumption limit - - CE: Carbon Emission limit - - EE: Energy Efficiency limit - - URE: Minimum Renewable Energy Usage - - Returns: - list: Ordered list of node names forming the optimal path, - or empty list if no valid path exists - - Notes: - - Uses modified Dijkstra's algorithm with multiple constraints - - Paths violating any DLOS constraint are discarded - - Node weights computed using compute_node_weight function - """ - logging.debug("Starting optimal path calculation...") - - # Create a dictionary with the weights of each node - node_data_map = {} - for node_data in energy_metrics: - node_id = node_data["name"] - ec = node_data["typical-power"] - ce = node_data["carbon-emissions"] - ee = node_data["efficiency"] - ure = node_data["renewable-energy-usage"] - - total_power_supply = sum(ps["typical-power"] for ps in node_data["power-supply"]) - total_power_boards = sum(b["typical-power"] for b in node_data["boards"]) - total_power_components = sum(c["typical-power"] for c in node_data["components"]) - total_power_transceivers = sum(t["typical-power"] for t in node_data["transceivers"]) - - logging.debug(f"Node {node_id}: EC={ec}, CE={ce}, EE={ee}, URE={ure}") - logging.debug(f"Node {node_id}: PS={total_power_supply}, BO={total_power_boards}, CO={total_power_components}, TR={total_power_transceivers}") - - weight = compute_node_weight(ec, ce, ee, ure, - total_power_supply, - total_power_boards, - total_power_components, - total_power_transceivers) - logging.debug(f"Weight for node {node_id}: {weight}") - - node_data_map[node_id] = { - "weight": weight, - "ec": ec, - "ce": ce, - "ee": ee, - "ure": ure - } - - # Create a graph representation of the topology - graph = {} - for node in topology["ietf-network:networks"]["network"][0]["node"]: - graph[node["node-id"]] = [] - for link in topology["ietf-network:networks"]["network"][0]["link"]: - src = link["source"]["source-node"] - dst = link["destination"]["dest-node"] - graph[src].append((dst, node_data_map[dst]["weight"])) - logging.debug(f"Added link: {src} -> {dst} with weight {node_data_map[dst]['weight']}") - - # Dijkstra's algorithm with restrictions - # Queue: (accumulated cost, current node, path, sum_ec, sum_ce, sum_ee, min_ure) - queue = [(0, source, [], 0, 0, 0, 1)] - visited = set() - - logging.debug(f"Starting search from {source} to {destination} with restrictions: {dlos}") - - while queue: - cost, node, path, sum_ec, sum_ce, sum_ee, min_ure = heapq.heappop(queue) - logging.debug(f"Exploring node {node} with cost {cost} and path {path + [node]}") - - if node in visited: - logging.debug(f"Node {node} already visited, skipped.") - continue - visited.add(node) - path = path + [node] - - node_metrics = node_data_map[node] - sum_ec += node_metrics["ec"] - sum_ce += node_metrics["ce"] - sum_ee += node_metrics["ee"] - min_ure = min(min_ure, node_metrics["ure"]) if path[:-1] else node_metrics["ure"] - - logging.debug(f"Accumulated -> EC: {sum_ec}, CE: {sum_ce}, EE: {sum_ee}, URE min: {min_ure}") - - # Check constraint violations - if dlos["EC"] is not None and sum_ec > dlos["EC"]: - logging.debug(f"Discarded path {path} for exceeding EC ({sum_ec} > {dlos['EC']})") - continue - if dlos["CE"] is not None and sum_ce > dlos["CE"]: - logging.debug(f"Discarded path {path} for exceeding CE ({sum_ce} > {dlos['CE']})") - continue - if dlos["EE"] is not None and sum_ee > dlos["EE"]: - logging.debug(f"Discarded path {path} for exceeding EE ({sum_ee} > {dlos['EE']})") - continue - if dlos["URE"] is not None and min_ure < dlos["URE"]: - logging.debug(f"Discarded path {path} for not reaching minimum URE ({min_ure} < {dlos['URE']})") - continue - - if node == destination: - logging.debug(f"Destination {destination} reached with a valid path: {path}") - return path - - for neighbor, weight in graph.get(node, []): - if neighbor not in visited: - logging.debug(f"Queue -> neighbour: {neighbor}, weight: {weight}") - heapq.heappush(queue, ( - cost + weight, - neighbor, - path, - sum_ec, - sum_ce, - sum_ee, - min_ure - )) - - logging.debug("No valid path found that meets the restrictions.") - return [] - - -def compute_node_weight(ec, ce, ee, ure, total_power_supply, total_power_boards, - total_power_components, total_power_transceivers, - alpha=1, beta=1, gamma=1, delta=1): - """ - Calculate node weight based on energy and environmental metrics. - - Computes a green index that represents the environmental impact of routing - traffic through a node, considering power consumption and carbon emissions. - - Args: - ec (float): Base energy consumption of the node - ce (float): Carbon emissions factor - ee (float): Energy efficiency metric - ure (float): Renewable energy usage ratio (0-1) - total_power_supply (float): Total power from supply units - total_power_boards (float): Total power consumed by boards - total_power_components (float): Total power consumed by components - total_power_transceivers (float): Total power consumed by transceivers - alpha (float, optional): Weight for energy consumption. Defaults to 1 - beta (float, optional): Weight for carbon emissions. Defaults to 1 - gamma (float, optional): Weight for energy efficiency. Defaults to 1 - delta (float, optional): Weight for renewable energy. Defaults to 1 - - Returns: - float: Computed green index representing environmental impact - - Notes: - Formula: green_index = (power_idle + power_traffic) * time / 1000 * (1 - ure) * ce - - Assumes 100 units of traffic - - Measured over 1 hour time period - """ - traffic = 100 - # Measure one hour of traffic - time = 1 - - power_idle = ec + total_power_supply + total_power_boards + total_power_components + total_power_transceivers - power_traffic = traffic * ee - - power_total = (power_idle + power_traffic) - - green_index = power_total * time / 1000 * (1 - ure) * ce - - return green_index \ No newline at end of file +# Copyright 2022-2026 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 heapq +import json +import logging +import random +from pathlib import Path +from typing import Any + +from flask import current_app + +from src.config.constants import SRC_PATH +from src.utils.safe_get import safe_get + +ALLOWED_ENERGY_NODE_IDS = frozenset({"A", "B", "C", "D", "E", "F", "G"}) + + +def _build_slice_input(node_source: dict[str, Any], node_destination: dict[str, Any]) -> dict[str, Any]: + """Build input format for external PCE slice computation.""" + return { + "clientName": "demo-client", + "requestId": random.randint(1000, 9999), + "sites": [node_source["nodeId"], node_destination["nodeId"]], + "graph": { + "nodes": [ + { + "nodeId": node_source["nodeId"], + "name": node_source["name"], + "footprint": node_source["footprint"], + "sticky": [node_source["nodeId"]], + }, + { + "nodeId": node_destination["nodeId"], + "name": node_destination["name"], + "footprint": node_destination["footprint"], + "sticky": [node_destination["nodeId"]], + }, + ], + "links": [ + { + "fromNodeId": node_source["nodeId"], + "toNodeId": node_destination["nodeId"], + "bandwidth": 1000000000, + "metrics": [ + { + "metric": "DELAY", + "value": 10, + "bound": True, + "required": True, + } + ], + } + ], + "constraints": { + "maxVulnerability": 3, + "maxDeployedServices": 10, + "metricLimits": [], + }, + }, + } + + +def _simulate_slice_output(input_data: dict[str, Any]) -> dict[str, Any]: + """Simulate external PCE response for slice computation.""" + return { + "input": input_data, + "slice": { + "nodes": [ + {"site": 1, "service": 1}, + {"site": 2, "service": 2}, + ], + "links": [ + { + "fromNodeId": 1, + "toNodeId": 2, + "lspId": 500, + "path": { + "ingressNodeId": 1, + "egressNodeId": 2, + "hops": [ + {"nodeId": 3, "linkId": "A-C", "portId": 1}, + {"nodeId": 2, "linkId": "C-B", "portId": 2}, + ], + }, + } + ], + "metric": {"value": 9}, + }, + "error": None, + } + + +def _plan_external_pce( + topology: dict[str, Any], + source_name: str, + destination_name: str, +) -> list[str]: + """Compute optimal path using external PCE simulation.""" + logging.debug("Using external PCE for path planning") + source_node = next((n for n in topology.get("nodes", []) if n.get("name") == source_name), None) + destination_node = next((n for n in topology.get("nodes", []) if n.get("name") == destination_name), None) + + if not source_node or not destination_node: + return [] + + slice_input = _build_slice_input(source_node, destination_node) + slice_output = _simulate_slice_output(slice_input) + + path = [source_node["name"]] + for link in slice_output["slice"]["links"]: + for hop in link["path"]["hops"]: + matched = next((n for n in topology.get("nodes", []) if n.get("nodeId") == hop["nodeId"]), None) + if matched: + path.append(matched["name"]) + return path + + +def energy_planner(intent: dict[str, Any]) -> list[str] | None: + """ + Plan an optimal network path based on energy consumption metrics. + + Args: + intent (dict[str, Any]): Network slice intent containing SDPs and energy SLOs. + + Returns: + list[str] | None: Ordered list of node names representing the optimal path, or None. + """ + energy_metrics = retrieve_energy() + topology = retrieve_topology() + source = safe_get( + intent, + ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "sdps", "sdp", 0, "node-id"], + ) + destination = safe_get( + intent, + ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "sdps", "sdp", 1, "node-id"], + ) + + if source not in ALLOWED_ENERGY_NODE_IDS or destination not in ALLOWED_ENERGY_NODE_IDS: + logging.warning( + "Topology not recognized (source: %s, destination: %s). Skipping energy-based planning.", + source, + destination, + ) + return None + + if current_app.config.get("PCE_EXTERNAL", False): + optimal_path = _plan_external_pce(topology, source, destination) + else: + logging.debug("Using internal PCE for path planning") + ietf_dlos = safe_get( + intent, + [ + "ietf-network-slice-service:network-slice-services", + "slo-sle-templates", + "slo-sle-template", + 0, + "slo-policy", + "metric-bound", + ], + ) or [] + logging.debug(f"{ietf_dlos}") + + dlos = { + "EC": next((item.get("bound") for item in ietf_dlos if item.get("metric-type") == "energy_consumption"), None), + "CE": next((item.get("bound") for item in ietf_dlos if item.get("metric-type") == "carbon_emission"), None), + "EE": next((item.get("bound") for item in ietf_dlos if item.get("metric-type") == "energy_efficiency"), None), + "URE": next((item.get("bound") for item in ietf_dlos if item.get("metric-type") == "renewable_energy_usage"), None), + } + logging.debug(f"Planning optimal path from {source} to {destination} with DLOS: {dlos}") + optimal_path = calculate_optimal_path(topology, energy_metrics, source, destination, dlos) + + if not optimal_path: + logging.error("No valid energy path found") + return None + + return optimal_path + + +def retrieve_energy() -> list[dict[str, Any]]: + """Retrieve energy consumption data for network nodes from JSON database.""" + file_path = Path(SRC_PATH) / "planner" / "energy_planner" / "energy_ddbb.json" + with file_path.open("r", encoding="utf-8") as archivo: + return json.load(archivo) + + +def retrieve_topology() -> dict[str, Any]: + """Retrieve network topology information.""" + filename = "ext_topo_ddbb.json" if current_app.config.get("PCE_EXTERNAL", False) else "topo_ddbb.json" + file_path = Path(SRC_PATH) / "planner" / "energy_planner" / filename + with file_path.open("r", encoding="utf-8") as archivo: + return json.load(archivo) + + +def calculate_optimal_path( + topology: dict[str, Any], + energy_metrics: list[dict[str, Any]], + source: str, + destination: str, + dlos: dict[str, float | None], +) -> list[str]: + """Calculate the optimal path using Dijkstra's algorithm with energy constraints.""" + logging.debug("Starting optimal path calculation...") + + node_data_map: dict[str, dict[str, Any]] = {} + for node_data in energy_metrics: + node_id = node_data["name"] + ec = node_data["typical-power"] + ce = node_data["carbon-emissions"] + ee = node_data["efficiency"] + ure = node_data["renewable-energy-usage"] + + total_power_supply = sum(ps["typical-power"] for ps in node_data.get("power-supply", [])) + total_power_boards = sum(b["typical-power"] for b in node_data.get("boards", [])) + total_power_components = sum(c["typical-power"] for c in node_data.get("components", [])) + total_power_transceivers = sum(t["typical-power"] for t in node_data.get("transceivers", [])) + + weight = compute_node_weight( + ec, + ce, + ee, + ure, + total_power_supply, + total_power_boards, + total_power_components, + total_power_transceivers, + ) + node_data_map[node_id] = { + "weight": weight, + "ec": ec, + "ce": ce, + "ee": ee, + "ure": ure, + } + + graph: dict[str, list[tuple[str, float]]] = {} + network_list = safe_get(topology, ["ietf-network:networks", "network"]) or [] + if network_list: + net = network_list[0] + for node in net.get("node", []): + graph[node["node-id"]] = [] + for link in net.get("link", []): + src = link["source"]["source-node"] + dst = link["destination"]["dest-node"] + if dst in node_data_map: + graph.setdefault(src, []).append((dst, node_data_map[dst]["weight"])) + + queue: list[tuple[float, str, list[str], float, float, float, float]] = [(0.0, source, [], 0.0, 0.0, 0.0, 1.0)] + visited: set[str] = set() + + while queue: + cost, node, path, sum_ec, sum_ce, sum_ee, min_ure = heapq.heappop(queue) + if node in visited: + continue + visited.add(node) + current_path = path + [node] + + if node not in node_data_map: + continue + + node_metrics = node_data_map[node] + sum_ec += node_metrics["ec"] + sum_ce += node_metrics["ce"] + sum_ee += node_metrics["ee"] + min_ure = min(min_ure, node_metrics["ure"]) if path else node_metrics["ure"] + + if dlos["EC"] is not None and sum_ec > dlos["EC"]: + continue + if dlos["CE"] is not None and sum_ce > dlos["CE"]: + continue + if dlos["EE"] is not None and sum_ee > dlos["EE"]: + continue + if dlos["URE"] is not None and min_ure < dlos["URE"]: + continue + + if node == destination: + return current_path + + for neighbor, weight in graph.get(node, []): + if neighbor not in visited: + heapq.heappush( + queue, + ( + cost + weight, + neighbor, + current_path, + sum_ec, + sum_ce, + sum_ee, + min_ure, + ), + ) + + return [] + + +def compute_node_weight( + ec: float, + ce: float, + ee: float, + ure: float, + total_power_supply: float, + total_power_boards: float, + total_power_components: float, + total_power_transceivers: float, + alpha: float = 1, + beta: float = 1, + gamma: float = 1, + delta: float = 1, +) -> float: + """Calculate node weight based on energy and environmental metrics.""" + traffic = 100 + time = 1 + + power_idle = ec + total_power_supply + total_power_boards + total_power_components + total_power_transceivers + power_traffic = traffic * ee + power_total = power_idle + power_traffic + + return power_total * time / 1000 * (1 - ure) * ce \ No newline at end of file diff --git a/src/planner/hrat_planner/hrat.py b/src/planner/hrat_planner/hrat.py index 9fd147b..4b7f352 100644 --- a/src/planner/hrat_planner/hrat.py +++ b/src/planner/hrat_planner/hrat.py @@ -14,7 +14,10 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. -import logging, requests +import logging + +import requests + def hrat_planner(data: str, ip: str, action: str = "create") -> dict: """ diff --git a/src/planner/planner.py b/src/planner/planner.py index 876e27f..941bcdc 100644 --- a/src/planner/planner.py +++ b/src/planner/planner.py @@ -15,70 +15,85 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. import logging -from src.planner.energy_planner.energy import energy_planner -from src.planner.hrat_planner.hrat import hrat_planner -from src.planner.e2e_optical_planner.e2e_optical import e2e_optical_planner -from src.planner.change_scheduler_planner.change_scheduler import change_scheduler_planner +from typing import Any + from flask import current_app +from src.planner.change_scheduler_planner.change_scheduler import ( + change_scheduler_planner, +) +from src.planner.e2e_optical_planner.e2e_optical import e2e_optical_planner +from src.planner.energy_planner.energy import energy_planner +from src.planner.hrat_planner.hrat import hrat_planner + class Planner: - """ - Planner class to compute optimal paths for network slices. - Uses different strategies based on configuration. - """ - """ - Planner class to compute the optimal path for a network slice based on energy consumption and topology. - """ - - def planner(self, intent, type, is_update=False): + """Planner class to compute optimal paths for network slices using different strategies.""" + + def _handle_change_scheduler(self, intent: Any) -> Any: + """Handle execution parameters for change scheduler planner.""" + slice_id = None + current_path = None + network = None + + if isinstance(intent, str): + slice_id = intent + elif isinstance(intent, dict): + slice_id = intent.get("slice_id") + current_path = intent.get("service_path") + network = intent.get("network_topology") + + kwargs: dict[str, Any] = {} + try: + if "CHANGE_SCHEDULER_IP" in current_app.config: + kwargs["ip"] = current_app.config["CHANGE_SCHEDULER_IP"] + if "CHANGE_SCHEDULER_PORT" in current_app.config: + kwargs["port"] = current_app.config["CHANGE_SCHEDULER_PORT"] + except RuntimeError: + pass + + return change_scheduler_planner( + slice_id, + current_path=current_path, + network=network, + **kwargs, + ) + + def planner( + self, + intent: Any, + type: str | None = None, + planner_type: str | None = None, + is_update: bool = False, + ) -> Any: """ - Plan the optimal path for a network slice based on energy consumption and topology. + Plan the optimal path for a network slice based on the requested strategy. Args: - intent (dict): Network slice intent - type (str): Planner type (ENERGY, HRAT, TFS_OPTICAL, CHANGE_SCHEDULER) - is_update (bool): Whether this is an update/modification request + intent (Any): Network slice intent data or identifier. + type (str, optional): Planner strategy (legacy param). + planner_type (str, optional): Planner strategy. + is_update (bool, optional): Whether this is an update request. Defaults to False. Returns: - dict or None: Planner result or None if type is invalid + Any: Result of the planner execution or None if unsupported. """ - # Log selected planner type - logging.info(f"Planner type selected: {type}") - # Use energy planner strategy - if type == "ENERGY" : return energy_planner(intent) - # Use HRAT planner with configured IP - elif type == "HRAT" : return hrat_planner(intent, current_app.config["HRAT_IP"]) - # Use E2E optical planner with configured IP - elif type == "E2E_OPTICAL": - action = "update" if is_update else "create" - return e2e_optical_planner(intent, current_app.config["E2E_OPTICAL_IP"], action = action) - elif type == "CHANGE_SCHEDULER": - slice_id = None - current_path = None - network = None - if isinstance(intent, str): - slice_id = intent - elif isinstance(intent, dict): - slice_id = intent.get("slice_id") - current_path = intent.get("service_path") - network = intent.get("network_topology") - kwargs = {} - try: - if "CHANGE_SCHEDULER_IP" in current_app.config: - kwargs["ip"] = current_app.config["CHANGE_SCHEDULER_IP"] - if "CHANGE_SCHEDULER_PORT" in current_app.config: - kwargs["port"] = current_app.config["CHANGE_SCHEDULER_PORT"] - except RuntimeError: - pass - return change_scheduler_planner( - slice_id, - current_path=current_path, - network=network, - **kwargs - ) - - - - # Return None if planner type is unsupported - else : return None + effective_type = type or planner_type or "" + logging.info(f"Planner type selected: {effective_type}") + + match effective_type: + + case "ENERGY": + return energy_planner(intent) + case "HRAT": + return hrat_planner(intent, current_app.config["HRAT_IP"]) + case "E2E_OPTICAL": + action = "update" if is_update else "create" + return e2e_optical_planner( + intent, current_app.config["E2E_OPTICAL_IP"], action=action + ) + case "CHANGE_SCHEDULER": + return self._handle_change_scheduler(intent) + case _: + return None + diff --git a/src/planner/shortest_path.py b/src/planner/shortest_path.py index 8a68aff..7a79ac5 100644 --- a/src/planner/shortest_path.py +++ b/src/planner/shortest_path.py @@ -1,93 +1,111 @@ -# Copyright 2022-2026 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, traceback -from collections import deque -from typing import Dict, Tuple - -import logging, traceback -from collections import deque -from typing import Dict, Tuple - -def normalize_node_id(node_id: str) -> str: - if isinstance(node_id, str) and node_id.startswith("urn:tfs:node:"): - return node_id.split(":")[-1] - return node_id - -def get_shortest_path(network, src_node_id, dst_node_id, directed_graph: bool = False) -> Tuple[Dict[str, any], int]: - try: - source_node_idx = normalize_node_id(src_node_id) - destination_node_idx = normalize_node_id(dst_node_id) - - nodes = network["node"] - links = network["ietf-network-topology:link"] - - # Build graph - graph = { - normalize_node_id(node["node-id"]): set() - for node in nodes - } - - for link in links: - src = normalize_node_id(link["source"]["source-node"]) - dst = normalize_node_id(link["destination"]["dest-node"]) - - graph[src].add(dst) - if not directed_graph: - graph[dst].add(src) - - # BFS - visited = {node: False for node in graph} - prev = {node: None for node in graph} - - if source_node_idx not in graph: - return {"message": f"Source node '{source_node_idx}' not found"}, 404 - - if destination_node_idx not in graph: - return {"message": f"Destination node '{destination_node_idx}' not found"}, 404 - - queue = deque([source_node_idx]) - visited[source_node_idx] = True - - while queue: - current = queue.popleft() - - if current == destination_node_idx: - break - - for neighbor in graph[current]: - if not visited[neighbor]: - visited[neighbor] = True - prev[neighbor] = current - queue.append(neighbor) - - if not visited[destination_node_idx]: - return {"message": "No path found"}, 404 - - # Rebuild path - path = [] - at = destination_node_idx - - while at is not None: - path.append(at) - at = prev[at] - - path.reverse() - return path, 200 - - except Exception as e: - logging.exception("Error calculating shortest path") - raise \ No newline at end of file +# Copyright 2022-2026 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 collections import deque +from typing import Any + +logger = logging.getLogger(__name__) + + +def normalize_node_id(node_id: str) -> str: + """Normalize node identifier by stripping the TFS URN prefix if present.""" + if isinstance(node_id, str) and node_id.startswith("urn:tfs:node:"): + return node_id.split(":")[-1] + return node_id + + +def get_shortest_path( + network: dict[str, Any], + src_node_id: str, + dst_node_id: str, + directed_graph: bool = False, +) -> tuple[list[str] | dict[str, str], int]: + """ + Calculate the shortest path between source and destination nodes using BFS. + + Args: + network (dict[str, Any]): Network topology containing nodes and links. + src_node_id (str): Source node identifier. + dst_node_id (str): Destination node identifier. + directed_graph (bool, optional): Treat links as directed. Defaults to False. + + Returns: + tuple[list[str] | dict[str, str], int]: (path_list or error_dict, http_status_code) + """ + try: + source_node_idx = normalize_node_id(src_node_id) + destination_node_idx = normalize_node_id(dst_node_id) + + nodes = network.get("node", []) + links = network.get("ietf-network-topology:link", []) + + # Build graph + graph: dict[str, set[str]] = { + normalize_node_id(node["node-id"]): set() + for node in nodes + } + + for link in links: + src = normalize_node_id(link["source"]["source-node"]) + dst = normalize_node_id(link["destination"]["dest-node"]) + + if src in graph: + graph[src].add(dst) + if not directed_graph and dst in graph: + graph[dst].add(src) + + if source_node_idx not in graph: + return {"message": f"Source node '{source_node_idx}' not found"}, 404 + + if destination_node_idx not in graph: + return {"message": f"Destination node '{destination_node_idx}' not found"}, 404 + + # BFS + visited: dict[str, bool] = {node: False for node in graph} + prev: dict[str, str | None] = {node: None for node in graph} + + queue: deque[str] = deque([source_node_idx]) + visited[source_node_idx] = True + + while queue: + current = queue.popleft() + if current == destination_node_idx: + break + + for neighbor in graph.get(current, set()): + if not visited.get(neighbor, False): + visited[neighbor] = True + prev[neighbor] = current + queue.append(neighbor) + + if not visited.get(destination_node_idx, False): + return {"message": "No path found"}, 404 + + # Rebuild path + path: list[str] = [] + at: str | None = destination_node_idx + + while at is not None: + path.append(at) + at = prev.get(at) + + path.reverse() + return path, 200 + + except Exception: + logger.exception("Error calculating shortest path") + raise \ No newline at end of file diff --git a/src/realizer/e2e/e2e_connect.py b/src/realizer/e2e/e2e_connect.py index f4eea6e..54236d4 100644 --- a/src/realizer/e2e/e2e_connect.py +++ b/src/realizer/e2e/e2e_connect.py @@ -14,10 +14,12 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. -from ..tfs.helpers.tfs_connector import tfs_connector -import logging, requests +import logging import os +from ..tfs.helpers.tfs_connector import tfs_connector + + def e2e_connect(requests, controller_ip, is_update=False, old_service_id=None): """ Function to connect end-to-end services in TeraFlowSDN (TFS) controller. diff --git a/src/realizer/e2e/main.py b/src/realizer/e2e/main.py index ed63429..28ddd62 100644 --- a/src/realizer/e2e/main.py +++ b/src/realizer/e2e/main.py @@ -15,9 +15,11 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. import logging + from .service_types.del_l3ipowdm_slice import del_l3ipowdm_slice from .service_types.l3ipowdm_slice import l3ipowdm_slice + def e2e(ietf_intent, way=None, response=None, rules = None): logging.debug(f"E2E Realizer selected: {way}") if way == "L3oWDM": realizing_request = l3ipowdm_slice(rules) diff --git a/src/realizer/e2e/service_types/del_l3ipowdm_slice.py b/src/realizer/e2e/service_types/del_l3ipowdm_slice.py index 4eb0557..ca412c0 100644 --- a/src/realizer/e2e/service_types/del_l3ipowdm_slice.py +++ b/src/realizer/e2e/service_types/del_l3ipowdm_slice.py @@ -14,11 +14,15 @@ # This file includes original contributions from Telefonica Innovación Digital S.L. -import logging, os -from src.config.constants import TEMPLATES_PATH, NBI_L2_PATH -from src.utils.load_template import load_template +import logging +import os + from flask import current_app +from src.config.constants import NBI_L2_PATH, TEMPLATES_PATH +from src.utils.load_template import load_template + + def del_l3ipowdm_slice(ietf_intent, response): """ Translate slice intent into a TeraFlow service request. @@ -75,7 +79,7 @@ def del_l3ipowdm_slice(ietf_intent, response): resource_value["vlan_id"] = int(vlan_value) resource_value["circuit_id"] = vlan_value resource_value["remote_router"] = destination_router_id if i == 1 else origin_router_id - resource_value["ni_name"] = 'ELAN{:s}'.format(str(vlan_value)) + resource_value["ni_name"] = f'ELAN{vlan_value!s:s}' config_rule["custom"]["resource_key"] = f"/device[{router_id}]/endpoint[{router_if}]/settings" elif current_app.config["UPLOAD_TYPE"] == "NBI": @@ -103,7 +107,7 @@ def del_l3ipowdm_slice(ietf_intent, response): site["site-location"] = sdp["node-id"] site["site-network-access"]["interface"]["ip-address"] = sdp["sdp-ip-address"] - logging.info(f"L2VPN Intent realized\n") + logging.info("L2VPN Intent realized\n") return tfs_request def tfs_l2vpn_support(requests): diff --git a/src/realizer/e2e/service_types/l3ipowdm_slice.py b/src/realizer/e2e/service_types/l3ipowdm_slice.py index 6e6f9d3..a1ce59a 100644 --- a/src/realizer/e2e/service_types/l3ipowdm_slice.py +++ b/src/realizer/e2e/service_types/l3ipowdm_slice.py @@ -14,10 +14,13 @@ # This file includes original contributions from Telefonica Innovación Digital S.L. -import logging, os +import logging +import os + from src.config.constants import TEMPLATES_PATH from src.utils.load_template import load_template + def l3ipowdm_slice(rules): """ Prepare a Optical service request for an optical slice. diff --git a/src/realizer/get_metrics.py b/src/realizer/get_metrics.py index d204217..2a4f3df 100644 --- a/src/realizer/get_metrics.py +++ b/src/realizer/get_metrics.py @@ -1,8 +1,11 @@ -import logging import asyncio -from .restconf.connectors.tfs_connector import tfs_connector +import logging + from flask import current_app +from .restconf.connectors.tfs_connector import tfs_connector + + def get_metrics(path, slice_id, controller_type): if controller_type == "RESTCONF": links = [] diff --git a/src/realizer/ixia/helpers/NEII_V4.py b/src/realizer/ixia/helpers/NEII_V4.py index 16ddaeb..137639a 100644 --- a/src/realizer/ixia/helpers/NEII_V4.py +++ b/src/realizer/ixia/helpers/NEII_V4.py @@ -14,8 +14,11 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. +import ipaddress +import logging + from .automatizacion_ne2v4 import automatizacion -import ipaddress, logging + class NEII_controller: def __init__(self, ixia_ip): @@ -39,7 +42,6 @@ class NEII_controller: self.nuevo_perfil(ip) if accion=="4": self.existentes(ip) - return ## MAIN MENU FUNCTIONS ## diff --git a/src/realizer/ixia/helpers/automatizacion_ne2v4.py b/src/realizer/ixia/helpers/automatizacion_ne2v4.py index 65e6a33..183e37f 100644 --- a/src/realizer/ixia/helpers/automatizacion_ne2v4.py +++ b/src/realizer/ixia/helpers/automatizacion_ne2v4.py @@ -15,6 +15,8 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. import requests + + class automatizacion: def obtener_informacion_ip(ip): ''' @@ -222,7 +224,6 @@ class automatizacion: La respuesta de la API (texto). """ -import requests class automatizacion: def obtener_informacion_ip(ip): ''' diff --git a/src/realizer/ixia/ixia_connect.py b/src/realizer/ixia/ixia_connect.py index 107d985..3b09566 100644 --- a/src/realizer/ixia/ixia_connect.py +++ b/src/realizer/ixia/ixia_connect.py @@ -16,6 +16,7 @@ from .helpers.NEII_V4 import NEII_controller + def ixia_connect(requests, ixia_ip): """ Connect to the IXIA NEII controller and send the requests. diff --git a/src/realizer/ixia/main.py b/src/realizer/ixia/main.py index ff3c890..37e0937 100644 --- a/src/realizer/ixia/main.py +++ b/src/realizer/ixia/main.py @@ -16,6 +16,7 @@ import logging + def ixia(ietf_intent): """ Prepare an Ixia service request based on the IETF intent. @@ -90,5 +91,5 @@ def ixia(ietf_intent): .get("sle-policy", {}).get("reliability"), } - logging.info(f"IXIA Intent realized\n") + logging.info("IXIA Intent realized\n") return intent \ No newline at end of file diff --git a/src/realizer/main.py b/src/realizer/main.py index f80ba3e..75ef8ec 100644 --- a/src/realizer/main.py +++ b/src/realizer/main.py @@ -15,117 +15,173 @@ # This file includes original contributions from Telefonica Innovación Digital S.L. import logging -from .select_way import select_way -from .nrp_handler import nrp_handler -from .get_metrics import get_metrics -from src.utils.safe_get import safe_get +from typing import Any + +from flask import current_app + from src.database.service_db import get_data_by_slice_id from src.realizer.restconf.connectors.tfs_connector import tfs_connector -from flask import current_app +from src.utils.safe_get import safe_get -def realizer(payload, need_nrp=False, order=None, nrp=None, controller_type=None, response=None, rules = None, action="CREATE"): - """ - Manage the slice creation workflow. +from .get_metrics import get_metrics +from .nrp_handler import nrp_handler +from .select_way import select_way + + +def _determine_e2e_way(rules: Any) -> str | None: + """Determine the realization way for E2E controller based on rule actions.""" + if isinstance(rules, list) and len(rules) > 0: + rules = rules[0] + actions = rules.get("actions", []) if isinstance(rules, dict) else [] + + has_transceiver = any(a.get("type", "").startswith("XR_AGENT_ACTIVATE_TRANSCEIVER") for a in actions) + has_optical = any(a.get("type", "").startswith("PROVISION_MEDIA_CHANNEL") for a in actions) + has_l3 = any(a.get("type", "").startswith("CONFIG_VPNL3") for a in actions) + has_l2 = any(a.get("type", "").startswith("CONFIG_VPNL2") for a in actions) + + del_transceiver = any(a.get("type", "").startswith("DEACTIVATE_XR_AGENT_TRANSCEIVER") for a in actions) + del_optical = any(a.get("type", "").startswith("DEPROVISION_OPTICAL_RESOURCE") for a in actions) + del_l3 = any(a.get("type", "").startswith("REMOVE_VPNL3") for a in actions) + del_l2 = any(a.get("type", "").startswith("REMOVE_VPNL2") for a in actions) + + if has_transceiver or (has_optical and has_l3): + return "L3oWDM" + if has_optical and has_l2: + return "L2oWDM" + if has_optical: + return "OPTIC" + if has_l3: + return "L3VPN" + if has_l2: + return "L2VPN" + + if del_transceiver or (del_optical and del_l3): + return "DEL_L3oWDM" + if del_optical and del_l2: + return "DEL_L2oWDM" + if del_optical: + return "DEL_OPTIC" + if del_l3: + return "DEL_L3VPN" + if del_l2: + return "DEL_L2VPN" - This method handles two primary scenarios: - 1. Interact with network controllers for NRP (Network Resource Partition) operations when need_nrp is True - 2. Slice service selection when need_nrp is False + logging.warning("Cannot determine the realization way from rules. Skipping request.") + return None + + +def _realize_create( + payload: Any, + need_nrp: bool = False, + order: str | None = None, + nrp: dict[str, Any] | None = None, + controller_type: str | None = None, + response: Any = None, + rules: Any = None, +) -> Any: + """Handle slice creation or NRP configuration workflow.""" + if need_nrp: + return nrp_handler(order, nrp) + + service = payload + if controller_type == "E2E": + way = _determine_e2e_way(rules) + if not way: + return None + else: + way = service.get("way") 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}") + return select_way(controller=controller_type, way=way, ietf_intent=service, response=response, rules=rules) + + +def _realize_monitor(payload: dict[str, Any], controller_type: str | None) -> None: + """Handle slice monitoring metric gathering.""" + logging.debug("Realizer action: MONITOR") + slice_id = payload.get("slice_id") + service_data = get_data_by_slice_id(slice_id) + if len(service_data) > 1: + raise ValueError(f"Slices with multiple associated services are not supported: '{slice_id}'") + service_id = service_data[0]["service_id"] + + restconf_ip = current_app.config["RESTCONF_IP"] + path, response_code = tfs_connector().get_service_path(restconf_ip, service_id) + if response_code == 200: + logging.debug(f"Retrieved service path for slice '{slice_id}' (service '{service_id}'): {path}") + get_metrics(path, slice_id, controller_type) + else: + raise Exception("Error: Service path not retrieved") + + +def _realize_reconfig(payload: Any) -> dict[str, Any]: + """Handle slice reconfiguration path and topology retrieval.""" + logging.debug("Realizer action: RECONFIG") + slice_id = payload.get("slice_id") if isinstance(payload, dict) else payload + service_data = get_data_by_slice_id(slice_id) + logging.debug(f"DEBUG: Slice data found for slice '{slice_id}': {service_data}") + if not service_data: + raise ValueError(f"No services found for slice '{slice_id}'") + + service_id = service_data[0]["service_id"] + logging.debug(f"DEBUG: Service ID for slice '{slice_id}': {service_id}") + + tfs_ip = current_app.config.get("RESTCONF_IP", "127.0.0.1") + logging.debug(f"DEBUG: TFS IP: {tfs_ip}") + conn = tfs_connector() + + path, path_code = conn.get_service_path(tfs_ip, service_id) + logging.debug(f"DEBUG: Path: {path}") + if path_code != 200 or not path: + raise Exception(f"Could not retrieve service path for service '{service_id}'") + + network, topo_code = conn.get_network_topology(tfs_ip, slice_id) + logging.debug(f"DEBUG: Network topology: {network}") + if topo_code != 200 or not network: + raise Exception(f"Could not retrieve network topology for slice '{slice_id}'") + + return { + "slice_id": slice_id, + "service_path": path, + "network_topology": network, + } + + +def realizer( + payload: Any, + need_nrp: bool = False, + order: str | None = None, + nrp: dict[str, Any] | None = None, + controller_type: str | None = None, + response: Any = None, + rules: Any = None, + action: str = "CREATE", +) -> Any: + """ + Manage the slice realization workflow dispatching based on action. Args: - ietf_intent (dict): IETF-formatted network slice intent. - need_nrp (bool, optional): Flag to indicate if NRP operations are needed. Defaults to False. - order (str, optional): Type of NRP operation (READ, UPDATE, CREATE). Defaults to None. - nrp (dict, optional): Specific Network Resource Partition to operate on. Defaults to None. - controller_type (str, optional): Type of controller (TFS, IXIA, E2E). Defaults to None. - response (dict, optional): Response built for user feedback. Defaults to None. - rules (dict, optional): Specific rules for slice realization. Defaults to None. - + payload (Any): Intent, service, or slice data payload. + need_nrp (bool, optional): Whether NRP handling is needed. Defaults to False. + order (str, optional): NRP operation (READ, UPDATE, CREATE). Defaults to None. + nrp (dict, optional): Network Resource Partition data. Defaults to None. + controller_type (str, optional): Target SDN controller type. Defaults to None. + response (Any, optional): Outgoing response object. Defaults to None. + rules (Any, optional): Dynamic realization rules. Defaults to None. + action (str, optional): Action type ('CREATE', 'MONITOR', 'RECONFIG'). Defaults to "CREATE". + Returns: - dict: A realization request for the specified network slice type. + Any: Response from downstream controller realization. """ - if action == "CREATE": - service = payload - if need_nrp: - # Perform NRP-related operations - nrp_view = nrp_handler(order, nrp) - return nrp_view - else: - # Select slice service method - if controller_type == "E2E": - if isinstance(rules, list) and len(rules) > 0: rules = rules[0] - actions = rules.get("actions", []) if (rules and not type(rules)== str) else [] - - has_transceiver = any(a.get("type", "").startswith("XR_AGENT_ACTIVATE_TRANSCEIVER") for a in actions) - has_optical = any(a.get("type", "").startswith("PROVISION_MEDIA_CHANNEL") for a in actions) - has_l3 = any(a.get("type", "").startswith("CONFIG_VPNL3") for a in actions) - has_l2 = any(a.get("type", "").startswith("CONFIG_VPNL2") for a in actions) - - del_transceiver = any(a.get("type", "").startswith("DEACTIVATE_XR_AGENT_TRANSCEIVER") for a in actions) - del_optical = any(a.get("type", "").startswith("DEPROVISION_OPTICAL_RESOURCE") for a in actions) - del_l3 = any(a.get("type", "").startswith("REMOVE_VPNL3") for a in actions) - del_l2 = any(a.get("type", "").startswith("REMOVE_VPNL2") for a in actions) - - if has_transceiver: selected_way = "L3oWDM" - elif has_optical and has_l3: selected_way = "L3oWDM" - elif has_optical and has_l2: selected_way = "L2oWDM" - elif has_optical: selected_way = "OPTIC" - elif has_l3: selected_way = "L3VPN" - elif has_l2: selected_way = "L2VPN" - - elif del_transceiver: selected_way = "DEL_L3oWDM" - elif del_optical and del_l3: selected_way = "DEL_L3oWDM" - elif del_optical and del_l2: selected_way = "DEL_L2oWDM" - elif del_optical: selected_way = "DEL_OPTIC" - elif del_l3: selected_way = "DEL_L3VPN" - elif del_l2: selected_way = "DEL_L2VPN" - else: - logging.warning("Cannot determine the realization way from rules. Skipping request.") - return None - way = selected_way - else: - 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 - elif action == "MONITOR": - logging.debug("Realizer action: MONITOR") - slice_id = payload.get("slice_id", None) - service_data = get_data_by_slice_id(slice_id) - if len(service_data) > 1: - raise ValueError(f"Slices with multiple associated services are not supported: '{slice_id}'") - service_id = service_data[0]["service_id"] - # Retrieve Service Path - path, response = tfs_connector().get_service_path(current_app.config["RESTCONF_IP"], service_id) - if response == 200: - logging.debug(f"Retrieved service path for slice '{slice_id}' (service '{service_id}'): {path}") - get_metrics(path, slice_id, controller_type) - else: - raise Exception("Error: Service path not retrieved") - elif action == "RECONFIG": - logging.debug("Realizer action: RECONFIG") - slice_id = payload.get("slice_id", None) if isinstance(payload, dict) else payload - service_data = get_data_by_slice_id(slice_id) - logging.debug(f"DEBUG: Slice data found for slice '{slice_id}': {service_data}") - if not service_data: - raise ValueError(f"No services found for slice '{slice_id}'") - service_id = service_data[0]["service_id"] - logging.debug(f"DEBUG: Service ID for slice '{slice_id}': {service_id}") - tfs_ip = current_app.config.get("RESTCONF_IP", "127.0.0.1") - logging.debug(f"DEBUG: TFS IP: {tfs_ip}") - conn = tfs_connector() - - path, path_code = conn.get_service_path(tfs_ip, service_id) - logging.debug(f"DEBUG: Path: {path}") - if path_code != 200 or not path: - raise Exception(f"Could not retrieve service path for service '{service_id}'") - - network, topo_code = conn.get_network_topology(tfs_ip, slice_id) - logging.debug(f"DEBUG: Network topology: {network}") - if topo_code != 200 or not network: - raise Exception(f"Could not retrieve network topology for slice '{slice_id}'") - - return { - "slice_id": slice_id, - "service_path": path, - "network_topology": network - } + match action: + case "CREATE": + return _realize_create(payload, need_nrp, order, nrp, controller_type, response, rules) + case "MONITOR": + return _realize_monitor(payload, controller_type) + case "RECONFIG": + return _realize_reconfig(payload) + case _: + return None + diff --git a/src/realizer/nrp_handler.py b/src/realizer/nrp_handler.py index f17f2c9..0d01598 100644 --- a/src/realizer/nrp_handler.py +++ b/src/realizer/nrp_handler.py @@ -14,9 +14,13 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. -import logging, os, json +import json +import logging +import os + from src.config.constants import DATABASE_PATH + def nrp_handler(request, nrp): """ Manage Network Resource Partition (NRP) operations. diff --git a/src/realizer/restconf/connectors/cisco_connector.py b/src/realizer/restconf/connectors/cisco_connector.py index 9136b16..c5ddb5c 100644 --- a/src/realizer/restconf/connectors/cisco_connector.py +++ b/src/realizer/restconf/connectors/cisco_connector.py @@ -15,9 +15,11 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. import logging + from netmiko import ConnectHandler -class cisco_connector(): + +class cisco_connector: """Class to interact with Cisco devices via SSH using Netmiko.""" def __init__(self, address, configs=None): self.address=address @@ -49,7 +51,7 @@ class cisco_connector(): connection.disconnect() except Exception as e: - logging.error(f"Failed to execute commands on {self.address}: {str(e)}") + logging.error(f"Failed to execute commands on {self.address}: {e!s}") def create_command_template(self, config): """ diff --git a/src/realizer/restconf/connectors/frr_connector.py b/src/realizer/restconf/connectors/frr_connector.py index cebb3ee..fc64bb8 100644 --- a/src/realizer/restconf/connectors/frr_connector.py +++ b/src/realizer/restconf/connectors/frr_connector.py @@ -15,9 +15,11 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. import logging + from netmiko import ConnectHandler -class frr_connector(): + +class frr_connector: """Class to interact with FRR devices via SSH using Netmiko.""" def __init__(self, address): @@ -37,7 +39,7 @@ class frr_connector(): connection.disconnect() except Exception as e: - logging.error(f"Failed to execute commands on {self.address}: {str(e)}") + logging.error(f"Failed to execute commands on {self.address}: {e!s}") raise def setup_slice(self, config: dict, assignments: dict[int, int]) -> list[str]: diff --git a/src/realizer/restconf/connectors/tfs_connector.py b/src/realizer/restconf/connectors/tfs_connector.py index 9c3d9de..b2c6dc3 100644 --- a/src/realizer/restconf/connectors/tfs_connector.py +++ b/src/realizer/restconf/connectors/tfs_connector.py @@ -14,17 +14,28 @@ # This file includes original contributions from Telefonica Innovación Digital S.L. +import asyncio +import json +import logging +import threading + +import aiohttp +import requests + +from src.config.constants import ( + NBI_IETF_NETWORKS_PATH, + NBI_L2_PATH, + NBI_L3_PATH, + NBI_SIMAP_SUSCRIPTION_PATH, +) from src.database.service_db import get_data_by_slice_id -import logging, requests, json, aiohttp, asyncio, threading -from src.config.constants import NBI_L2_PATH, NBI_L3_PATH, NBI_IETF_NETWORKS_PATH, NBI_SIMAP_SUSCRIPTION_PATH from src.utils.safe_get import safe_get -from typing import Dict, Tuple, List # Temp until moving to .env SDN_SUBSCRIPTION_PERIOD = 10 # seconds SDN_SUBS_INACTIVITY_THRESHOLD = 30 # seconds -class tfs_connector(): +class tfs_connector: """ Helper class to interact with TeraFlowSDN Northbound Interface (NBI) and WebUI. """ @@ -129,7 +140,7 @@ class tfs_connector(): logging.debug("Http response: %s",response.text) return response - def get_network_topology(self, tfs_ip: str, slice_id: str) -> Tuple[Dict[str, any], int]: + def get_network_topology(self, tfs_ip: str, slice_id: str) -> tuple[dict[str, any], int]: user="admin" password="admin" url = f'http://{user}:{password}@{tfs_ip}' @@ -161,7 +172,7 @@ class tfs_connector(): device_data = response.json() return device_data.get("name", "") - def get_service_path(self, tfs_ip: str, service_id: str) -> Tuple[List[str], int]: + def get_service_path(self, tfs_ip: str, service_id: str) -> tuple[list[str], int]: """ Get ordered list of node names along the service path from TFS. @@ -245,7 +256,7 @@ class tfs_connector(): logging.exception(f"Error retrieving service path for service '{service_id}': {e}") return [], 500 - def get_slice_topology(self, tfs_ip: str, slice_id: str) -> Tuple[Dict[str, any], int]: + def get_slice_topology(self, tfs_ip: str, slice_id: str) -> tuple[dict[str, any], int]: """ Build and return the Network Slice Topology according to draft-ietf-teas-network-slice-topology-yang-04. Retrieves physical network topology and filters nodes, links, and termination points by the slice service path diff --git a/src/realizer/restconf/main.py b/src/realizer/restconf/main.py index ac9dad9..61d2d59 100644 --- a/src/realizer/restconf/main.py +++ b/src/realizer/restconf/main.py @@ -15,9 +15,11 @@ # 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). diff --git a/src/realizer/restconf/restconf_connect.py b/src/realizer/restconf/restconf_connect.py index e9d880f..b0cba44 100644 --- a/src/realizer/restconf/restconf_connect.py +++ b/src/realizer/restconf/restconf_connect.py @@ -14,15 +14,17 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. -from src.utils.slice_manager import SliceManager -from .connectors.tfs_connector import tfs_connector -from .connectors.frr_connector import frr_connector -from src.utils.send_response import send_response -from src.utils.safe_get import safe_get +import logging + from flask import current_app + from src.config.constants import NBI_L2_PATH, NBI_L3_PATH -import logging +from src.utils.safe_get import safe_get +from src.utils.send_response import send_response +from src.utils.slice_manager import SliceManager +from .connectors.frr_connector import frr_connector +from .connectors.tfs_connector import tfs_connector FRR_DEVICES = [ { @@ -88,7 +90,7 @@ def restconf_connect(requests, restconf_ip): 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)}") + message=f"FRR config failed on {device_config['management_address']}: {e!s}") else: return send_response(False, code=400, message=f"Unsupported service type: {key}") diff --git a/src/realizer/restconf/service_types/builders/configure_match_criteria.py b/src/realizer/restconf/service_types/builders/configure_match_criteria.py index 648218f..84f9a0f 100644 --- a/src/realizer/restconf/service_types/builders/configure_match_criteria.py +++ b/src/realizer/restconf/service_types/builders/configure_match_criteria.py @@ -15,6 +15,7 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. import logging + from src.utils.safe_get import safe_get diff --git a/src/realizer/restconf/service_types/builders/configure_slos.py b/src/realizer/restconf/service_types/builders/configure_slos.py index 7721e51..facc760 100644 --- a/src/realizer/restconf/service_types/builders/configure_slos.py +++ b/src/realizer/restconf/service_types/builders/configure_slos.py @@ -15,9 +15,12 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. 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"] 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 index 536cd9b..884ea6a 100644 --- a/src/realizer/restconf/service_types/builders/create_site_from_sdp.py +++ b/src/realizer/restconf/service_types/builders/create_site_from_sdp.py @@ -15,10 +15,13 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. import logging + from src.utils.safe_get import safe_get -from .create_network_access import create_network_access + from .configure_match_criteria import configure_match_criteria from .configure_slos import configure_slos +from .create_network_access import create_network_access + def create_site_from_sdp(sdp, ietf_intent, connectivity_type, layer_type): """ diff --git a/src/realizer/restconf/service_types/l2vpn.py b/src/realizer/restconf/service_types/l2vpn.py index 5d48474..e2302d3 100644 --- a/src/realizer/restconf/service_types/l2vpn.py +++ b/src/realizer/restconf/service_types/l2vpn.py @@ -15,9 +15,9 @@ # 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 +from .builders.create_site_from_sdp import create_site_from_sdp +from .builders.initialize_structure import initialize_structure def l2vpn(ietf_intent): diff --git a/src/realizer/restconf/service_types/l3vpn.py b/src/realizer/restconf/service_types/l3vpn.py index 2fdf7bb..cd69996 100644 --- a/src/realizer/restconf/service_types/l3vpn.py +++ b/src/realizer/restconf/service_types/l3vpn.py @@ -15,8 +15,10 @@ # 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 +from .builders.initialize_structure import initialize_structure + def l3vpn(ietf_intent): """ diff --git a/src/realizer/select_way.py b/src/realizer/select_way.py index d0f2c58..ab87fad 100644 --- a/src/realizer/select_way.py +++ b/src/realizer/select_way.py @@ -1,54 +1,57 @@ -# Copyright 2022-2026 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 .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): - """ - Determine the method of slice realization. - - Args: - controller (str): The controller to use for slice realization. Defaults to None. - Supported values: - - "IXIA": IXIA NEII for network testing - - "TFS": TeraFlow Service for network slice management - - "E2E": End-to-End controller for e2e slice management - way (str): The type of technology to use. Defaults to None. - ietf_intent (dict): IETF-formatted network slice intent. Defaults to None. - response (dict): Response built for user feedback. Defaults to None. - rules (list, optional): Specific rules for slice realization. Defaults to None. - - Returns: - dict: A realization request for the specified network slice type. - - """ - realizing_request = None - if controller == "TFS": - realizing_request = tfs(ietf_intent, way, response) - elif controller == "IXIA": - 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) - return realizing_request \ No newline at end of file +# Copyright 2022-2026 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 typing import Any + +from .e2e.main import e2e +from .ixia.main import ixia +from .restconf.main import restconf +from .tfs.main import tfs + + +def select_way( + controller: str | None = None, + way: str | None = None, + ietf_intent: Any = None, + response: Any = None, + rules: Any = None, +) -> Any: + """ + Determine the method of slice realization and invoke the appropriate provider. + + Args: + controller (str, optional): Target controller (TFS, IXIA, E2E, RESTCONF). + way (str, optional): Technology way identifier. + ietf_intent (Any, optional): IETF formatted network slice intent. + response (Any, optional): Outgoing user response dictionary. + rules (Any, optional): Optional rule specifications. + + Returns: + Any: Response payload from the selected controller integration. + """ + match controller: + case "TFS": + return tfs(ietf_intent, way, response) + case "IXIA": + return ixia(ietf_intent) + case "E2E": + return e2e(ietf_intent, way, response, rules) + case "RESTCONF": + return restconf(ietf_intent, way, response) + case _: + logging.warning(f"Unsupported controller: {controller}. Defaulting to TFS realization.") + return tfs(ietf_intent, way, response) \ No newline at end of file diff --git a/src/realizer/send_controller.py b/src/realizer/send_controller.py index da285b7..1302e73 100644 --- a/src/realizer/send_controller.py +++ b/src/realizer/send_controller.py @@ -15,56 +15,59 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. import logging +from typing import Any + 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 .ixia.ixia_connect import ixia_connect from .restconf.restconf_connect import restconf_connect +from .tfs.tfs_connect import tfs_connect + -def send_controller(controller_type, requests, is_update=False, old_service_id=None): +def send_controller( + controller_type: str, + requests: Any, + is_update: bool = False, + old_service_id: str | None = None, +) -> Any: """ Route provisioning requests to the appropriate network controller. - - This function acts as a dispatcher that sends configuration requests to - different SDN controller types based on the specified controller type. - + Args: - controller_type (str): Type of controller to send requests to: - - "TFS": TeraFlow SDN controller - - "IXIA": Ixia network emulation controller - - "E2E": TeraFlow End-to-End controller - requests (dict or list): Configuration requests to be sent to the controller - is_update (bool): Whether it is a modification/update request - old_service_id (str): Old service ID to delete (optional) - + controller_type (str): Type of controller ("TFS", "IXIA", "E2E", "RESTCONF"). + requests (Any): Configuration request payload. + is_update (bool, optional): Whether it is a modification request. Defaults to False. + old_service_id (str, optional): Old service ID to delete on update. Defaults to None. + Returns: - bool or dict: Response from the controller indicating success/failure - of the provisioning operation. Returns True in DUMMY_MODE. - - Notes: - - If DUMMY_MODE is enabled in config, returns True without sending requests - - Uses IP addresses from Flask application configuration: - * TFS_IP for TeraFlow - * IXIA_IP for Ixia - * TFS_E2E for End-to-End - - Logs the controller type that received the request - - Raises: - Exception: May be raised by individual connect functions on communication errors + Any: Response from the controller, or True if DUMMY_MODE is active. """ - if current_app.config["DUMMY_MODE"]: + if current_app.config.get("DUMMY_MODE", False): return True - - if controller_type == "TFS": - response = tfs_connect(requests, current_app.config["TFS_IP"]) - logging.info("Request sent to Teraflow") - elif controller_type == "IXIA": - response = ixia_connect(requests, current_app.config["IXIA_IP"]) - logging.info("Requests sent to Ixia") - elif controller_type == "E2E": - response = e2e_connect(requests, current_app.config["TFS_E2E_IP"], is_update=is_update, old_service_id=old_service_id) - 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 + + match controller_type: + case "TFS": + response = tfs_connect(requests, current_app.config["TFS_IP"]) + logging.info("Request sent to Teraflow") + return response + case "IXIA": + response = ixia_connect(requests, current_app.config["IXIA_IP"]) + logging.info("Requests sent to Ixia") + return response + case "E2E": + response = e2e_connect( + requests, + current_app.config["TFS_E2E_IP"], + is_update=is_update, + old_service_id=old_service_id, + ) + logging.info("Requests sent to Teraflow E2E") + return response + case "RESTCONF": + response = restconf_connect(requests, current_app.config["RESTCONF_IP"]) + logging.info("Requests sent to restconf controller") + return response + case _: + logging.warning(f"Unknown controller type: {controller_type}") + return None \ No newline at end of file diff --git a/src/realizer/tfs/helpers/cisco_connector.py b/src/realizer/tfs/helpers/cisco_connector.py index 27a3b9d..cc26560 100644 --- a/src/realizer/tfs/helpers/cisco_connector.py +++ b/src/realizer/tfs/helpers/cisco_connector.py @@ -15,9 +15,11 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. import logging + from netmiko import ConnectHandler -class cisco_connector(): + +class cisco_connector: """Class to interact with Cisco devices via SSH using Netmiko.""" def __init__(self, address, configs=None): self.address=address diff --git a/src/realizer/tfs/helpers/tfs_connector.py b/src/realizer/tfs/helpers/tfs_connector.py index c927b23..2d20da0 100644 --- a/src/realizer/tfs/helpers/tfs_connector.py +++ b/src/realizer/tfs/helpers/tfs_connector.py @@ -14,10 +14,15 @@ # This file includes original contributions from Telefonica Innovación Digital S.L. -import logging, requests, json +import json +import logging + +import requests + from src.config.constants import NBI_L2_PATH, NBI_L3_PATH -class tfs_connector(): + +class tfs_connector: """ Helper class to interact with TeraFlowSDN Northbound Interface (NBI) and WebUI. """ diff --git a/src/realizer/tfs/main.py b/src/realizer/tfs/main.py index 8e98a7b..d79bc30 100644 --- a/src/realizer/tfs/main.py +++ b/src/realizer/tfs/main.py @@ -15,9 +15,11 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. import logging + from .service_types.tfs_l2vpn import tfs_l2vpn from .service_types.tfs_l3vpn import tfs_l3vpn + def tfs(ietf_intent, way=None, response=None): """ Generates a TFS realizing request based on the specified way (L2 or L3). diff --git a/src/realizer/tfs/service_types/tfs_l2vpn.py b/src/realizer/tfs/service_types/tfs_l2vpn.py index 9725459..3a3ca1d 100644 --- a/src/realizer/tfs/service_types/tfs_l2vpn.py +++ b/src/realizer/tfs/service_types/tfs_l2vpn.py @@ -14,12 +14,17 @@ # This file includes original contributions from Telefonica Innovación Digital S.L. -import logging, os -from src.config.constants import TEMPLATES_PATH, NBI_L2_PATH +import logging +import os + +from flask import current_app + +from src.config.constants import NBI_L2_PATH, TEMPLATES_PATH from src.utils.load_template import load_template from src.utils.safe_get import safe_get + from ..helpers.cisco_connector import cisco_connector -from flask import current_app + def tfs_l2vpn(ietf_intent, response): """ @@ -85,7 +90,7 @@ def tfs_l2vpn(ietf_intent, response): resource_value["vlan_id"] = int(vlan_value) resource_value["circuit_id"] = vlan_value resource_value["remote_router"] = destination_router_id if i == 1 else origin_router_id - resource_value["ni_name"] = 'ELAN{:s}'.format(str(vlan_value)) + resource_value["ni_name"] = f'ELAN{vlan_value!s:s}' config_rule["custom"]["resource_key"] = f"/device[{router_id}]/endpoint[{router_if}]/settings" elif current_app.config["UPLOAD_TYPE"] == "NBI": @@ -113,7 +118,7 @@ def tfs_l2vpn(ietf_intent, response): site["site-location"] = sdp["node-id"] site["site-network-access"]["interface"]["ip-address"] = sdp["sdp-ip-address"] - logging.info(f"L2VPN Intent realized") + logging.info("L2VPN Intent realized") return tfs_request def tfs_l2vpn_support(requests): diff --git a/src/realizer/tfs/service_types/tfs_l3vpn.py b/src/realizer/tfs/service_types/tfs_l3vpn.py index 5d561d4..52b07ad 100644 --- a/src/realizer/tfs/service_types/tfs_l3vpn.py +++ b/src/realizer/tfs/service_types/tfs_l3vpn.py @@ -14,11 +14,15 @@ # This file includes original contributions from Telefonica Innovación Digital S.L. -import logging, os -from src.config.constants import TEMPLATES_PATH, NBI_L3_PATH +import logging +import os + +from flask import current_app + +from src.config.constants import NBI_L3_PATH, TEMPLATES_PATH from src.utils.load_template import load_template from src.utils.safe_get import safe_get -from flask import current_app + def tfs_l3vpn(ietf_intent, response): """ @@ -82,7 +86,7 @@ def tfs_l3vpn(ietf_intent, response): resource_value["address_ip"] = destination_router_id if i == 1 else origin_router_id resource_value["policy_AZ"] = "policyA" resource_value["policy_ZA"] = "policyB" - resource_value["ni_name"] = 'ELAN{:s}'.format(str(vlan_value)) + resource_value["ni_name"] = f'ELAN{vlan_value!s:s}' config_rule["custom"]["resource_key"] = f"/device[{router_id}]/endpoint[{router_if}]/settings" elif current_app.config["UPLOAD_TYPE"] == "NBI": @@ -136,6 +140,6 @@ def tfs_l3vpn(ietf_intent, response): access["service"]["svc-mtu"] = int(cvalue) - logging.info(f"L3VPN Intent realized") + logging.info("L3VPN Intent realized") #self.answer[self.subnet]["VLAN"] = vlan_value return tfs_request \ No newline at end of file diff --git a/src/realizer/tfs/tfs_connect.py b/src/realizer/tfs/tfs_connect.py index 99697a1..36a260c 100644 --- a/src/realizer/tfs/tfs_connect.py +++ b/src/realizer/tfs/tfs_connect.py @@ -14,11 +14,14 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. -from .helpers.tfs_connector import tfs_connector from flask import current_app + from src.utils.send_response import send_response + +from .helpers.tfs_connector import tfs_connector from .service_types.tfs_l2vpn import tfs_l2vpn_support + def tfs_connect(requests, tfs_ip): """ Connect to TeraflowSDN (TFS) controller and upload services. diff --git a/src/tests/conftest.py b/src/tests/conftest.py index b0dd889..fcc4203 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -14,14 +14,11 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. -import sys -import os -import sqlite3 -import time import base64 -from unittest.mock import MagicMock, patch +import sys +from unittest.mock import MagicMock + import pytest -from flask import Flask # ----------------------------------------------------------------------------- # Safely mock C-extensions (sysrepo, libyang) if not installed in current environment @@ -89,7 +86,7 @@ def auth_headers(flask_app): """Generates Basic Auth headers matching app config.""" username = flask_app.config["API_USERNAME"] password = flask_app.config["API_PASSWORD"] - token = base64.b64encode(f"{username}:{password}".encode('utf-8')).decode('utf-8') + token = base64.b64encode(f"{username}:{password}".encode()).decode('utf-8') return { "Authorization": f"Basic {token}", "Content-Type": "application/json" @@ -105,10 +102,10 @@ def temp_sqlite_db(tmp_path, monkeypatch): monkeypatch.setattr("src.database.telemetry_client_db.DB_NAME", str(tmp_path / "test_telemetry.db")) monkeypatch.setattr("src.database.alert_db.DB_NAME", str(tmp_path / "test_alert.db")) + from src.database.alert_db import init_db as init_alert from src.database.db import init_db as init_slice from src.database.service_db import init_db as init_service from src.database.telemetry_client_db import init_db as init_telemetry - from src.database.alert_db import init_db as init_alert init_slice() init_service() diff --git a/src/tests/test_api.py b/src/tests/test_api.py index f030bb2..ee7025d 100644 --- a/src/tests/test_api.py +++ b/src/tests/test_api.py @@ -15,17 +15,17 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. import json -import pytest import os -from unittest.mock import patch, Mock, MagicMock -from pathlib import Path -from dotenv import load_dotenv import sqlite3 import time +from unittest.mock import MagicMock, patch + +import pytest +from dotenv import load_dotenv from flask import Flask -from src.main import NSController -from src.api.main import Api +from src.api.main import Api +from src.main import NSController # Load environment variables load_dotenv() diff --git a/src/tests/test_database.py b/src/tests/test_database.py index 7b1c0ce..2729914 100644 --- a/src/tests/test_database.py +++ b/src/tests/test_database.py @@ -14,21 +14,22 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. -import pytest -import sqlite3 import json import os +import sqlite3 import time -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock, patch + +import pytest + from src.database.db import ( + delete_all_data, + delete_data, + get_all_data, + get_data, init_db, save_data, update_data, - delete_data, - get_data, - get_all_data, - delete_all_data, - DB_NAME ) from src.database.store_data import store_data @@ -66,7 +67,7 @@ def test_db(tmp_path): # Remove the file if it exists if os.path.exists(test_db_name): os.remove(test_db_name) - except Exception as e: + except Exception: # On Windows, sometimes files are locked. Try again after a delay import time time.sleep(0.5) @@ -563,11 +564,11 @@ class TestDatabaseIntegration: # Update some updated_intent = {"updated": True} - for i in range(0, 3): + for i in range(3): update_data(f"slice-{i}", updated_intent, "TFS") # Verify updates - for i in range(0, 3): + for i in range(3): result = get_data(f"slice-{i}") assert result["intent"]["updated"] is True @@ -605,7 +606,12 @@ class TestServiceDB: """Tests for service_db module.""" def test_service_db_crud(self, temp_sqlite_db): - from src.database.service_db import save_data, get_data_by_slice_id, delete_by_slice_id, get_all_data + from src.database.service_db import ( + delete_by_slice_id, + get_all_data, + get_data_by_slice_id, + save_data, + ) save_data("service-1", "slice-100") @@ -630,8 +636,13 @@ class TestTelemetryClientDB: def test_client_and_subscriptions(self, temp_sqlite_db): from src.database.telemetry_client_db import ( - create_client, get_client, get_all_clients, delete_client, delete_all_clients, - upsert_subscription, get_subscription, get_client_subscriptions, delete_subscription, delete_all_subscriptions + create_client, + delete_subscription, + get_all_clients, + get_client, + get_client_subscriptions, + get_subscription, + upsert_subscription, ) create_client("c1") @@ -657,7 +668,13 @@ class TestAlertDB: """Tests for alert_db module.""" def test_alert_db_crud(self, temp_sqlite_db): - from src.database.alert_db import save_alert, get_alert, get_all_alerts, delete_alert, delete_all_alerts + from src.database.alert_db import ( + delete_alert, + delete_all_alerts, + get_alert, + get_all_alerts, + save_alert, + ) save_alert("alert-1", {"uuid": "alert-1", "severity": "HIGH"}) alert = get_alert("alert-1") @@ -677,7 +694,12 @@ class TestSysrepoStore: """Tests for sysrepo_store functions using mocked sysrepo session.""" def test_sysrepo_store_helpers(self): - from src.database.sysrepo_store import create_data_store, get_data_store, delete_data_store, update_data_store, normalize_libyang_data + from src.database.sysrepo_store import ( + create_data_store, + delete_data_store, + get_data_store, + normalize_libyang_data, + ) libyang_data = { "ietf-network-slice-service:network-slice-services": { @@ -719,8 +741,11 @@ class TestSysrepoStore: assert mock_sess.set_item.call_count >= 5 def test_database_error_branches(self, tmp_path): - from src.database.service_db import init_db as init_service_db, update_data as service_update, delete_data as service_delete - from src.database.alert_db import init_db as init_alert_db, update_alert + from src.database.alert_db import init_db as init_alert_db + from src.database.alert_db import update_alert + from src.database.service_db import delete_data as service_delete + from src.database.service_db import init_db as init_service_db + from src.database.service_db import update_data as service_update s_db = str(tmp_path / "test_service.db") a_db = str(tmp_path / "test_alert.db") diff --git a/src/tests/test_e2e.py b/src/tests/test_e2e.py index 6efce97..c5a28b3 100644 --- a/src/tests/test_e2e.py +++ b/src/tests/test_e2e.py @@ -14,14 +14,16 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. -import pytest import json from itertools import product from pathlib import Path from unittest.mock import MagicMock + +import pytest + +from app import create_app from src.api.main import Api from src.main import NSController -from app import create_app # Folder where request JSON files are located REQUESTS_DIR = Path(__file__).parent / "requests" @@ -135,7 +137,9 @@ def mock_external_servers(monkeypatch, tmp_path): pass try: - from src.realizer.restconf.connectors.tfs_connector import tfs_connector as restconf_tfs_connector + from src.realizer.restconf.connectors.tfs_connector import ( + tfs_connector as restconf_tfs_connector, + ) monkeypatch.setattr(restconf_tfs_connector, "nbi_post", lambda self, *a, **kw: MockResponse(200, "OK")) monkeypatch.setattr(restconf_tfs_connector, "nbi_delete", lambda self, *a, **kw: MockResponse(200, "OK")) except Exception: diff --git a/src/tests/test_initialization.py b/src/tests/test_initialization.py index b4fed33..1378bd0 100644 --- a/src/tests/test_initialization.py +++ b/src/tests/test_initialization.py @@ -14,14 +14,14 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. -import os +from unittest.mock import patch + import pytest from flask import Flask -from unittest.mock import patch -from src.main import NSController -from src.config.config import create_config from app import create_app +from src.config.config import create_config +from src.main import NSController def test_init_default_values(): diff --git a/src/tests/test_mapper.py b/src/tests/test_mapper.py index 65bb0d6..f724fbe 100644 --- a/src/tests/test_mapper.py +++ b/src/tests/test_mapper.py @@ -14,10 +14,11 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. +from unittest.mock import MagicMock, call, patch + import pytest -import logging -from unittest.mock import patch, MagicMock, call from flask import Flask + from src.mapper.main import mapper from src.mapper.slo_viability import slo_viability diff --git a/src/tests/test_namespaces.py b/src/tests/test_namespaces.py index 166fc4c..b070022 100644 --- a/src/tests/test_namespaces.py +++ b/src/tests/test_namespaces.py @@ -16,9 +16,7 @@ import io import json -import pytest -from unittest.mock import patch, MagicMock - +from unittest.mock import patch # ============================================================================= # 1. Tests for Basic Auth Enforcement Across Namespaces diff --git a/src/tests/test_nbi_processor.py b/src/tests/test_nbi_processor.py index 29f715f..d1d37b0 100644 --- a/src/tests/test_nbi_processor.py +++ b/src/tests/test_nbi_processor.py @@ -14,13 +14,14 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. -import pytest from unittest.mock import patch + +import pytest + from src.nbi_processor.detect_format import detect_format from src.nbi_processor.main import nbi_processor from src.nbi_processor.translator import translator - # ---------- Tests detect_format ---------- def test_detect_format_ietf(): @@ -160,8 +161,6 @@ def test_translator_basic(mock_load_template, gpp_intent, fake_template): assert slice_service["slo-sle-template"] == "qosA" # viene del ep1 import re -import uuid - # ---------- Extra detect_format ---------- diff --git a/src/tests/test_planner.py b/src/tests/test_planner.py index ecc0fba..f5cda75 100644 --- a/src/tests/test_planner.py +++ b/src/tests/test_planner.py @@ -14,20 +14,26 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. +from unittest.mock import MagicMock, patch + import pytest -from unittest.mock import patch, MagicMock import requests -from src.planner.planner import Planner -from src.planner.shortest_path import normalize_node_id, get_shortest_path -from src.planner.energy_planner.energy import energy_planner, retrieve_energy, retrieve_topology -from src.planner.hrat_planner.hrat import hrat_planner -from src.planner.e2e_optical_planner.e2e_optical import e2e_optical_planner -from src.planner.change_scheduler_planner.change_scheduler import change_scheduler_planner, _find_link_info -from src.main import NSController from src.api.main import Api - - +from src.main import NSController +from src.planner.change_scheduler_planner.change_scheduler import ( + _find_link_info, + change_scheduler_planner, +) +from src.planner.e2e_optical_planner.e2e_optical import e2e_optical_planner +from src.planner.energy_planner.energy import ( + energy_planner, + retrieve_energy, + retrieve_topology, +) +from src.planner.hrat_planner.hrat import hrat_planner +from src.planner.planner import Planner +from src.planner.shortest_path import get_shortest_path, normalize_node_id # ============================================================================= # 1. Tests for main Planner Dispatcher (src/planner/planner.py) diff --git a/src/tests/test_realizer.py b/src/tests/test_realizer.py index 26d9e79..a57b792 100644 --- a/src/tests/test_realizer.py +++ b/src/tests/test_realizer.py @@ -14,14 +14,15 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. +from unittest.mock import MagicMock, patch + import pytest -from unittest.mock import patch, MagicMock + +from src.realizer.get_metrics import get_metrics from src.realizer.main import realizer +from src.realizer.nrp_handler import nrp_handler from src.realizer.select_way import select_way from src.realizer.send_controller import send_controller -from src.realizer.nrp_handler import nrp_handler -from src.realizer.get_metrics import get_metrics - # ============================================================================= # 1. Tests for Realizer Entrypoint (src/realizer/main.py) @@ -352,7 +353,9 @@ class TestRestconfServiceTypesAndBuilders: assert len(res["ietf-l3vpn-svc:l3vpn-svc"]["sites"]["site"]) == 1 def test_initialize_structure(self): - from src.realizer.restconf.service_types.builders.initialize_structure import initialize_structure + from src.realizer.restconf.service_types.builders.initialize_structure import ( + initialize_structure, + ) l2_struct = initialize_structure("vpn-l2", "point-to-point", layer_type="l2") assert "ietf-l2vpn-svc:l2vpn-svc" in l2_struct @@ -362,7 +365,9 @@ class TestRestconfServiceTypesAndBuilders: assert "ietf-l3vpn-svc:l3vpn-svc" in l3_struct def test_create_network_access_roles_and_layers(self): - from src.realizer.restconf.service_types.builders.create_network_access import create_network_access + from src.realizer.restconf.service_types.builders.create_network_access import ( + create_network_access, + ) sdp_sender = {"type": "sender", "sdp": {"id": "sdp-1"}} sdp_receiver = {"type": "receiver", "sdp": {"id": "sdp-2"}} @@ -388,7 +393,9 @@ class TestRestconfServiceTypesAndBuilders: create_network_access(sdp_any, {"id": "v-1"}, "point-to-point", "R1", "Eth1", "l4") def test_configure_match_criteria_variants(self): - from src.realizer.restconf.service_types.builders.configure_match_criteria import configure_match_criteria + from src.realizer.restconf.service_types.builders.configure_match_criteria import ( + configure_match_criteria, + ) net_access = {"service": {"qos": {"qos-classification-policy": {"rule": []}}}, "connection": {"tagged-interface": {"dot1q-vlan-tagged": {}}}} site = {} @@ -427,7 +434,9 @@ class TestRestconfServiceTypesAndBuilders: configure_match_criteria(net_access, site, sdp_unknown, "l3") def test_configure_slos_and_apply_metric_constraint(self): - from src.realizer.restconf.service_types.builders.configure_slos import configure_slos + from src.realizer.restconf.service_types.builders.configure_slos import ( + configure_slos, + ) net_access_l2 = { "service": { @@ -494,7 +503,9 @@ class TestRestconfConnect: """Full coverage tests for src/realizer/restconf/restconf_connect.py.""" def test_restconf_connect_l2vpn_success(self, flask_app): - from src.realizer.restconf.restconf_connect import restconf_connect, _slice_manager + from src.realizer.restconf.restconf_connect import ( + restconf_connect, + ) flask_app.config["SDN_CONTROLLER_TYPE"] = "TFS" requests_payload = { @@ -518,7 +529,10 @@ class TestRestconfConnect: assert res == mock_resp def test_restconf_connect_l3vpn_frr_branch(self, flask_app): - from src.realizer.restconf.restconf_connect import restconf_connect, _slice_manager + from src.realizer.restconf.restconf_connect import ( + _slice_manager, + restconf_connect, + ) flask_app.config["SDN_CONTROLLER_TYPE"] = "TFS" flask_app.config["DATAPLANE_SUPPORT"] = "FRR" diff --git a/src/tests/test_utils.py b/src/tests/test_utils.py index 0a975c1..8b112d7 100644 --- a/src/tests/test_utils.py +++ b/src/tests/test_utils.py @@ -15,12 +15,15 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. import json + import pytest -from src.utils.load_template import load_template +from flask import Flask + +from src.utils.build_response import build_response from src.utils.dump_templates import dump_templates +from src.utils.load_template import load_template from src.utils.send_response import send_response -from src.utils.build_response import build_response -from flask import Flask + @pytest.fixture def tmp_json_file(tmp_path): diff --git a/src/tests/test_webui.py b/src/tests/test_webui.py index 61a4b4b..1491805 100644 --- a/src/tests/test_webui.py +++ b/src/tests/test_webui.py @@ -1,6 +1,7 @@ import json +from unittest.mock import MagicMock, patch + import pytest -from unittest.mock import patch, MagicMock from flask import Flask diff --git a/src/utils/build_response.py b/src/utils/build_response.py index ccaee3c..21e20d5 100644 --- a/src/utils/build_response.py +++ b/src/utils/build_response.py @@ -1,122 +1,155 @@ -# Copyright 2022-2026 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. - -from .safe_get import safe_get - -def build_response(intent, response, controller_type = None): - """ - Build a structured response from network slice intent. - - Extracts key information from an IETF network slice intent and formats it - into a standardized response structure with slice details and QoS requirements. - - Args: - intent (dict): IETF network slice service intent containing: - - slice-service: Service configuration with SDPs and IDs - - slo-sle-templates: QoS policy templates - response (list): Existing response list to append to - controller_type (str, optional): Type of controller managing the slice. - Defaults to None - - Returns: - list: Updated response list with appended slice information containing: - - id: Slice service identifier - - source: Source service delivery point ID - - destination: Destination service delivery point ID - - vlan: VLAN identifier from match criteria - - requirements: List of QoS constraint dictionaries with: - * constraint_type: Metric type and unit (e.g., "latency[ms]") - * constraint_value: Bound value as string - - Notes: - - Extracts metric bounds from SLO policy (bandwidth, delay, jitter, etc.) - - Includes availability and MTU if specified in SLO policy - - Assumes point-to-point topology with exactly 2 SDPs - - VLAN extracted from first SDP's first match criterion - """ - - id = safe_get(intent, ["ietf-network-slice-service:network-slice-services","slice-service",0,"id"]) - - # Try to extract source/destination from connection constructs (P2MP sender/receiver) - p2mp_sender = None - p2mp_receivers = None - slice_service = safe_get(intent, ["ietf-network-slice-service:network-slice-services","slice-service",0]) or {} - connection_groups = slice_service.get("connection-groups", {}).get("connection-group", []) - for cg in connection_groups: - connectivity_constructs = cg.get("connectivity-construct", []) - for cc in connectivity_constructs: - if cc.get("p2mp-sender-sdp"): - p2mp_sender = cc.get("p2mp-sender-sdp") - if cc.get("p2mp-receiver-sdp"): - p2mp_receivers = cc.get("p2mp-receiver-sdp") - if cc.get("p2mp-sdp"): - p2mp_sender = cc.get("p2mp-sdp", {}).get("root-sdp-id") - p2mp_receivers = cc.get("p2mp-sdp", {}).get("leaf-sdp-id", []) - - if p2mp_sender: - source = p2mp_sender - else: - source = safe_get(intent, ["ietf-network-slice-service:network-slice-services","slice-service",0,"sdps","sdp",0,"id"]) or safe_get(intent, ["ietf-network-slice-service:network-slice-services","slice-service",0,"sdps","sdp",0,"node-id"]) - - if p2mp_receivers and len(p2mp_receivers) > 0: - destination = p2mp_receivers[0] - else: - destination = safe_get(intent, ["ietf-network-slice-service:network-slice-services","slice-service",0,"sdps","sdp",1,"id"]) or safe_get(intent, ["ietf-network-slice-service:network-slice-services","slice-service",0,"sdps","sdp",1,"node-id"]) - - vlan = safe_get(intent, ["ietf-network-slice-service:network-slice-services","slice-service",0,"sdps","sdp",0,"service-match-criteria","match-criterion",0,"match-type", 0, "vlan", 0]) - - if not id or not source or not destination: - return response - - qos_requirements = [] - - # Populate response with QoS requirements and VLAN from intent - slo_policy = safe_get(intent, ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "service-slo-sle-policy", "slo-policy"]) - if slo_policy is None: - slo_policy = safe_get(intent, ["ietf-network-slice-service:network-slice-services", "slo-sle-templates", "slo-sle-template", 0, "slo-policy"]) - if slo_policy is None: - slo_policy = {} - - # Process metrics - for metric in slo_policy.get("metric-bound", []): - constraint_type = f"{metric['metric-type']}[{metric['metric-unit']}]" - constraint_value = str(metric["bound"]) - qos_requirements.append({ - "constraint_type": constraint_type, - "constraint_value": constraint_value - }) - - # Availability - if "availability" in slo_policy: - qos_requirements.append({ - "constraint_type": "availability[%]", - "constraint_value": str(slo_policy["availability"]) - }) - - # MTU - if "mtu" in slo_policy: - qos_requirements.append({ - "constraint_type": "mtu[bytes]", - "constraint_value": str(slo_policy["mtu"]) - }) - response.append({ - "id": id, - "source": source, - "destination": destination, - "vlan": vlan, - "requirements": qos_requirements, - }) - return response \ No newline at end of file +# Copyright 2022-2026 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. + +from typing import Any + +from .safe_get import safe_get + + +def _extract_endpoints(intent: dict[str, Any]) -> tuple[str | None, str | None]: + """Extract source and destination endpoints from intent connection-groups or SDPs.""" + slice_service = ( + safe_get(intent, ["ietf-network-slice-service:network-slice-services", "slice-service", 0]) + or {} + ) + + # Check connection constructs (P2MP sender/receiver) + connection_groups = slice_service.get("connection-groups", {}).get("connection-group", []) + for cg in connection_groups: + connectivity_constructs = cg.get("connectivity-construct", []) + for cc in connectivity_constructs: + p2mp_sender = cc.get("p2mp-sender-sdp") or safe_get(cc, ["p2mp-sdp", "root-sdp-id"]) + p2mp_receivers = cc.get("p2mp-receiver-sdp") or safe_get(cc, ["p2mp-sdp", "leaf-sdp-id"], []) + + if p2mp_sender and p2mp_receivers: + destination = p2mp_receivers[0] if isinstance(p2mp_receivers, list) and p2mp_receivers else None + return p2mp_sender, destination + + # Fallback to standard SDP list + source = safe_get(slice_service, ["sdps", "sdp", 0, "id"]) or safe_get(slice_service, ["sdps", "sdp", 0, "node-id"]) + destination = safe_get(slice_service, ["sdps", "sdp", 1, "id"]) or safe_get(slice_service, ["sdps", "sdp", 1, "node-id"]) + return source, destination + + +def _extract_vlan(intent: dict[str, Any]) -> int | None: + """Extract VLAN ID from the first match criterion of the first SDP.""" + return safe_get( + intent, + [ + "ietf-network-slice-service:network-slice-services", + "slice-service", + 0, + "sdps", + "sdp", + 0, + "service-match-criteria", + "match-criterion", + 0, + "match-type", + 0, + "vlan", + 0, + ], + ) + + +def _extract_qos_requirements(intent: dict[str, Any]) -> list[dict[str, str]]: + """Extract QoS metric constraints, availability, and MTU from SLO policy.""" + slo_policy = safe_get( + intent, + [ + "ietf-network-slice-service:network-slice-services", + "slice-service", + 0, + "service-slo-sle-policy", + "slo-policy", + ], + ) + if slo_policy is None: + slo_policy = safe_get( + intent, + [ + "ietf-network-slice-service:network-slice-services", + "slo-sle-templates", + "slo-sle-template", + 0, + "slo-policy", + ], + ) + if not isinstance(slo_policy, dict): + return [] + + requirements: list[dict[str, str]] = [] + + for metric in slo_policy.get("metric-bound", []): + constraint_type = f"{metric.get('metric-type', '')}[{metric.get('metric-unit', '')}]" + constraint_value = str(metric.get("bound", "")) + requirements.append({ + "constraint_type": constraint_type, + "constraint_value": constraint_value, + }) + + if "availability" in slo_policy: + requirements.append({ + "constraint_type": "availability[%]", + "constraint_value": str(slo_policy["availability"]), + }) + + if "mtu" in slo_policy: + requirements.append({ + "constraint_type": "mtu[bytes]", + "constraint_value": str(slo_policy["mtu"]), + }) + + return requirements + + +def build_response( + intent: dict[str, Any], + response: list[dict[str, Any]], + controller_type: str | None = None, +) -> list[dict[str, Any]]: + """ + Build a structured response entry from network slice intent and append it to `response`. + + Args: + intent (dict[str, Any]): IETF network slice intent. + response (list[dict[str, Any]]): Existing list of response objects. + controller_type (str, optional): Controller type managing the slice. + + Returns: + list[dict[str, Any]]: Updated response list. + """ + slice_id = safe_get( + intent, + ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "id"], + ) + source, destination = _extract_endpoints(intent) + + if not slice_id or not source or not destination: + return response + + vlan = _extract_vlan(intent) + qos_requirements = _extract_qos_requirements(intent) + + response.append({ + "id": slice_id, + "source": source, + "destination": destination, + "vlan": vlan, + "requirements": qos_requirements, + }) + return response \ No newline at end of file diff --git a/src/utils/dump_templates.py b/src/utils/dump_templates.py index 2c516fd..0acfec2 100644 --- a/src/utils/dump_templates.py +++ b/src/utils/dump_templates.py @@ -1,64 +1,50 @@ -# Copyright 2022-2026 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 json, os -from src.config.constants import TEMPLATES_PATH -from flask import current_app - -def dump_templates(nbi_file, ietf_file, realizer_file): - """ - Dump multiple template files as JSON for debugging and analysis. - - This utility function saves network slice templates at different processing - stages to disk for inspection, debugging, and documentation purposes. - Only executes if DUMP_TEMPLATES configuration flag is enabled. - - Args: - nbi_file (dict): Northbound Interface template - original user/API request - ietf_file (dict): IETF-standardized network slice intent format - realizer_file (dict): Controller-specific realization template - - Returns: - None - - Notes: - - Controlled by DUMP_TEMPLATES configuration flag - - Files saved to TEMPLATES_PATH directory - - Output files: - * nbi_template.json - Original NBI request - * ietf_template.json - Standardized IETF format - * realizer_template.json - Controller-specific format - - JSON formatted with 2-space indentation for readability - - Silently returns if DUMP_TEMPLATES is False - - Raises: - IOError: If unable to write to TEMPLATES_PATH directory - """ - if not current_app.config["DUMP_TEMPLATES"]: - return - - # Map template content to output filenames - templates = { - "nbi_template.json": nbi_file, - "ietf_template.json": ietf_file, - "realizer_template.json": realizer_file, - } - - # Write each template to disk - for filename, content in templates.items(): - path = os.path.join(TEMPLATES_PATH, filename) - with open(path, "w") as f: - json.dump(content, f, indent=2) \ No newline at end of file +# Copyright 2022-2026 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 json +from pathlib import Path +from typing import Any + +from flask import current_app + +from src.config.constants import TEMPLATES_PATH + + +def dump_templates(nbi_file: Any, ietf_file: Any, realizer_file: Any) -> None: + """ + Dump multiple template files as JSON for debugging and analysis. + + Args: + nbi_file (Any): Northbound Interface template. + ietf_file (Any): Standardized IETF format. + realizer_file (Any): Controller-specific format. + """ + if not current_app.config.get("DUMP_TEMPLATES", False): + return + + templates: dict[str, Any] = { + "nbi_template.json": nbi_file, + "ietf_template.json": ietf_file, + "realizer_template.json": realizer_file, + } + + templates_dir = Path(TEMPLATES_PATH) + templates_dir.mkdir(parents=True, exist_ok=True) + + for filename, content in templates.items(): + file_path = templates_dir / filename + with file_path.open("w", encoding="utf-8") as f: + json.dump(content, f, indent=2) \ No newline at end of file diff --git a/src/utils/load_template.py b/src/utils/load_template.py index 71c16b8..a8c2582 100644 --- a/src/utils/load_template.py +++ b/src/utils/load_template.py @@ -1,42 +1,50 @@ -# Copyright 2022-2026 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, json -from .send_response import send_response - -def load_template(dir_t): - """ - Load and process JSON templates for different network slice formats. - - Args: - dir_t (str): Path to the template file - - Returns: - dict: Parsed JSON template - """ - try: - with open(dir_t, "r") as source: - template = json.loads( - source.read() - .replace("\t", "") - .replace("\n", "") - .replace("'", '"') - .strip() - ) - return template - except Exception as e: - logging.error(f"Template loading error: {e}") - return send_response(False, code=500, message=f"Template loading error: {e}") \ No newline at end of file +# Copyright 2022-2026 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 json +import logging +from pathlib import Path +from typing import Any + +from .send_response import send_response + +logger = logging.getLogger(__name__) + + +def load_template(template_path: str | Path) -> dict[str, Any] | tuple[dict[str, Any], int]: + """ + Load and parse a JSON template file for network slice configurations. + + Args: + template_path (str | Path): Filepath to the JSON template. + + Returns: + dict[str, Any] | tuple[dict[str, Any], int]: Parsed JSON dictionary or error response tuple. + """ + try: + path = Path(template_path) + with path.open("r", encoding="utf-8") as source: + raw_content = ( + source.read() + .replace("\t", "") + .replace("\n", "") + .replace("'", '"') + .strip() + ) + return json.loads(raw_content) + except (OSError, json.JSONDecodeError) as e: + logger.error("Template loading error: %s", e) + return send_response(False, code=500, message=f"Template loading error: {e}") \ No newline at end of file diff --git a/src/utils/safe_get.py b/src/utils/safe_get.py index 7fad433..92775a1 100644 --- a/src/utils/safe_get.py +++ b/src/utils/safe_get.py @@ -14,20 +14,26 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. -def safe_get(dct, keys): +from collections.abc import Sequence +from typing import Any + + +def safe_get(target: Any, keys: Sequence[Any], default: Any = None) -> Any: """ - Safely retrieves a nested value from a dictionary or list. + Safely retrieve a nested value from a dictionary or list. + Args: - dct (dict or list): The dictionary or list to traverse. - keys (list): A list of keys (for dicts) or indices (for lists) to follow. + target (Any): The dictionary or list to traverse. + keys (Sequence[Any]): Keys (for dicts) or indices (for lists) to traverse in order. + default (Any, optional): Fallback value if the path cannot be resolved. Defaults to None. + Returns: - The value found at the nested location, or None if any key/index is not found. + Any: The value found at the nested location, or `default` if any key/index is missing. """ + current = target for key in keys: - if isinstance(dct, dict) and key in dct: - dct = dct[key] - elif isinstance(dct, list) and isinstance(key, int) and key < len(dct): - dct = dct[key] + if isinstance(current, dict) and key in current or isinstance(current, list) and isinstance(key, int) and 0 <= key < len(current): + current = current[key] else: - return None - return dct + return default + return current diff --git a/src/utils/send_response.py b/src/utils/send_response.py index 35b4016..6b8d86d 100644 --- a/src/utils/send_response.py +++ b/src/utils/send_response.py @@ -1,54 +1,66 @@ -# Copyright 2022-2026 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, inspect - -def send_response(result, message=None, code=None, data=None): - """ - Generate and send a standardized API response. - - Args: - result (bool): Indicates success or failure. Defaults to None. - message (str, optional): Message (success or error). Defaults to None. - code (int, optional): HTTP code (default 200 for success, 400 for error). Defaults to None. - data (dict, optional): Additional payload. Defaults to None. - - Returns: - tuple: (response_dict, http_status_code) - """ - - frame = inspect.currentframe().f_back - filename = frame.f_code.co_filename - lineno = frame.f_lineno - - if result: - code = code or 200 - response = { - "success": True, - "data": data or {}, - "error": None, - } - else: - code = code or 400 - error_info = f"{message or 'An error occurred while processing the request.'} (File: {filename}, Line: {lineno})" - logging.warning(f"Request failed. Reason: {message}") - response = { - "success": False, - "data": None, - "error": error_info, - } - - return response, code \ No newline at end of file +# Copyright 2022-2026 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 inspect +import logging +from typing import Any + +logger = logging.getLogger(__name__) + + +def send_response( + result: bool, + message: str | None = None, + code: int | None = None, + data: Any = None, +) -> tuple[dict[str, Any], int]: + """ + Generate a standardized API response tuple (response_dict, http_status_code). + + Args: + result (bool): Indicates success (True) or failure (False). + message (str, optional): Descriptive message or error reason. + code (int, optional): HTTP status code (defaults to 200 on success, 400 on error). + data (Any, optional): Data payload to include in response. + + Returns: + tuple[dict[str, Any], int]: (response_payload, http_status_code) + """ + frame = inspect.currentframe() + caller_frame = frame.f_back if frame else None + filename = caller_frame.f_code.co_filename if caller_frame else "unknown" + lineno = caller_frame.f_lineno if caller_frame else 0 + + if result: + status_code = code or 200 + response = { + "success": True, + "data": data if data is not None else {}, + "error": None, + } + return response, status_code + + status_code = code or 400 + error_message = message or "An error occurred while processing the request." + error_info = f"{error_message} (File: {filename}, Line: {lineno})" + logger.warning("Request failed. Reason: %s", message) + + response = { + "success": False, + "data": None, + "error": error_info, + } + return response, status_code \ No newline at end of file diff --git a/src/utils/slice_manager.py b/src/utils/slice_manager.py index 9581817..5fa25c7 100644 --- a/src/utils/slice_manager.py +++ b/src/utils/slice_manager.py @@ -1,65 +1,70 @@ -# Copyright 2022-2026 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 +# Copyright 2022-2026 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. + +from typing import Final + + +class SliceManager: + """ + Manages available Policy-Based Routing (PBR) slices and their DSCP assignments. + Slices are 1-indexed. The slice with the highest index is always the default. + """ + + TOTAL_SLICES: Final[int] = 3 + DEFAULT_SLICE: Final[int] = TOTAL_SLICES # SLICE-3 is always default + + def __init__(self) -> None: + # 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: + """Check if all available assignable slots are occupied.""" + return all(dscp is not None for dscp in self._slots.values()) \ No newline at end of file diff --git a/src/webui/gui.py b/src/webui/gui.py index 933b8cf..d16a22c 100644 --- a/src/webui/gui.py +++ b/src/webui/gui.py @@ -14,107 +14,132 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. -import json, logging, uuid -import requests +import json +import logging import os +import uuid +from pathlib import Path +from typing import Any + import pandas as pd -from flask import render_template, request, jsonify, redirect, url_for, session, Blueprint -from src.config.constants import SRC_PATH, NSC_PORT, TEMPLATES_PATH +import requests +from flask import ( + Blueprint, + current_app, + jsonify, + redirect, + render_template, + request, + session, + url_for, +) + +from src.config.constants import NSC_PORT, SRC_PATH, TEMPLATES_PATH from src.realizer.ixia.helpers.NEII_V4 import NEII_controller -from flask import current_app -gui_bp = Blueprint('gui', __name__, template_folder=os.path.join(SRC_PATH, 'webui', 'templates'), static_folder=os.path.join(SRC_PATH, 'webui', 'static'), static_url_path='/webui/static') +gui_bp = Blueprint( + "gui", + __name__, + template_folder=str(Path(SRC_PATH) / "webui" / "templates"), + static_folder=str(Path(SRC_PATH) / "webui" / "static"), + static_url_path="/webui/static", +) + +USERNAME = "admin" +PASSWORD = "admin" -#Variables for dev accessing -USERNAME = 'admin' -PASSWORD = 'admin' -enter=False -def __safe_int(value): +def __safe_int(value: Any) -> int | float | None: """ Safely convert a string or numeric input to int or float. - + Args: - value (str|int|float): The input value to convert. - + value (Any): The input value to convert. + Returns: - int|float|None: The converted integer or float value, or None if conversion fails. + int | float | None: Converted numeric value or None on failure. """ try: if isinstance(value, str): - value = value.strip().replace(',', '.') + value = value.strip().replace(",", ".") number = float(value) return int(number) if number.is_integer() else number except (ValueError, TypeError, AttributeError): return None -def __build_request_ietf(src_node_ip=None, dst_node_ip=None, vlan_id=None, bandwidth=None, latency=None, tolerance=0, latency_version=None, reliability=None): - """ - Build an IETF-compliant network slice request formm from inputs. - - Args: IPs, VLAN, bandwidth, latency, reliability, etc. - - Returns: dict representing the JSON request. - """ - # Open and read the template file - with open(os.path.join(TEMPLATES_PATH, 'ietf_template_empty.json'), 'r') as source: - # Clean up the JSON template - template = source.read().replace('\t', '').replace('\n', '').replace("'", '"').strip() - request = json.loads(template) - request["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["id"] = f"qos-profile-{uuid.uuid4()}" +def __build_request_ietf( + src_node_ip: str | None = None, + dst_node_ip: str | None = None, + vlan_id: Any = None, + bandwidth: Any = None, + latency: Any = None, + tolerance: Any = 0, + latency_version: str | None = None, + reliability: Any = None, +) -> dict[str, Any]: + """Build an IETF-compliant network slice request dictionary from user inputs.""" + template_file = Path(TEMPLATES_PATH) / "ietf_template_empty.json" + with template_file.open("r", encoding="utf-8") as source: + template = source.read().replace("\t", "").replace("\n", "").replace("'", '"').strip() + request_data: dict[str, Any] = json.loads(template) + + slo_template = request_data["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0] + slo_template["id"] = f"qos-profile-{uuid.uuid4()}" + + slice_service = request_data["ietf-network-slice-service:network-slice-services"]["slice-service"][0] + slice_service["id"] = f"slice-service-{uuid.uuid4()}" + slice_service["slo-sle-template"] = slo_template["id"] - # Generate unique slice service ID and description - request["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"] = f"slice-service-{uuid.uuid4()}" - request["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["slo-sle-template"] = request["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["id"] - # Configure Source SDP - request["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][0]["node-id"] = "source-node" #Pendiente de rellenar - request["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][0]["sdp-ip-address"] = src_node_ip - request["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][0]["service-match-criteria"]["match-criterion"][0]["match-type"][0]["type"] = "vlan" - request["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][0]["service-match-criteria"]["match-criterion"][0]["match-type"][0]["vlan"] = [vlan_id] - request["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][0]["attachment-circuits"]["attachment-circuit"][0]["ac-ipv4-address"] = "" # Pendiente de rellenar - request["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][0]["attachment-circuits"]["attachment-circuit"][0]["sdp-peering"]["peer-sap-id"] = src_node_ip + sdp_src = slice_service["sdps"]["sdp"][0] + sdp_src["node-id"] = "source-node" + sdp_src["sdp-ip-address"] = src_node_ip + sdp_src["service-match-criteria"]["match-criterion"][0]["match-type"][0]["type"] = "vlan" + sdp_src["service-match-criteria"]["match-criterion"][0]["match-type"][0]["vlan"] = [vlan_id] + sdp_src["attachment-circuits"]["attachment-circuit"][0]["ac-ipv4-address"] = "" + sdp_src["attachment-circuits"]["attachment-circuit"][0]["sdp-peering"]["peer-sap-id"] = src_node_ip # Configure Destination SDP - request["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][1]["node-id"] = "destination-node" - request["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][1]["sdp-ip-address"] = dst_node_ip - request["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][1]["service-match-criteria"]["match-criterion"][0]["match-type"][0]["type"] = "vlan" - request["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][1]["service-match-criteria"]["match-criterion"][0]["match-type"][0]["vlan"] = [vlan_id] - request["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][1]["attachment-circuits"]["attachment-circuit"][0]["ac-ipv4-address"] = ""# Pendiente de rellenar - request["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][1]["attachment-circuits"]["attachment-circuit"][0]["sdp-peering"]["peer-sap-id"] = dst_node_ip - - # Configure Connection Group and match-criteria - request["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["connection-groups"]["connection-group"][0]["id"] = "source-node_destination-node" #Pendiente de rellenar - request["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][0]["service-match-criteria"]["match-criterion"][0]["target-connection-group-id"] = "" #Pendiente de rellenar - request["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][1]["service-match-criteria"]["match-criterion"][0]["target-connection-group-id"] = "" #Pendiente de rellenar - - # Populate template with SLOs (currently supporting QoS profile, latency and bandwidth) + sdp_dst = slice_service["sdps"]["sdp"][1] + sdp_dst["node-id"] = "destination-node" + sdp_dst["sdp-ip-address"] = dst_node_ip + sdp_dst["service-match-criteria"]["match-criterion"][0]["match-type"][0]["type"] = "vlan" + sdp_dst["service-match-criteria"]["match-criterion"][0]["match-type"][0]["vlan"] = [vlan_id] + sdp_dst["attachment-circuits"]["attachment-circuit"][0]["ac-ipv4-address"] = "" + sdp_dst["attachment-circuits"]["attachment-circuit"][0]["sdp-peering"]["peer-sap-id"] = dst_node_ip + + # Configure Connection Group + slice_service["connection-groups"]["connection-group"][0]["id"] = "source-node_destination-node" + sdp_src["service-match-criteria"]["match-criterion"][0]["target-connection-group-id"] = "" + sdp_dst["service-match-criteria"]["match-criterion"][0]["target-connection-group-id"] = "" + + # Populate template with SLOs if bandwidth: - request["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["slo-policy"]["metric-bound"].append({ + slo_template["slo-policy"]["metric-bound"].append({ "metric-type": "one-way-bandwidth", "metric-unit": "Mbps", - "bound": __safe_int(bandwidth) + "bound": __safe_int(bandwidth), }) if latency: - request["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["slo-policy"]["metric-bound"].append({ + slo_template["slo-policy"]["metric-bound"].append({ "metric-type": "one-way-delay-maximum", "metric-unit": "milliseconds", - "bound": __safe_int(latency) + "bound": __safe_int(latency), }) - # Configure gaussian latency or internet if specified if latency_version: - request["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["description"] = latency_version - if tolerance: - request["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["slo-policy"]["metric-bound"].append( { - "metric-type": "one-way-delay-variation-maximum", - "metric-unit": "milliseconds", - "bound": __safe_int(tolerance) - }) + slo_template["description"] = latency_version + if tolerance: + slo_template["slo-policy"]["metric-bound"].append({ + "metric-type": "one-way-delay-variation-maximum", + "metric-unit": "milliseconds", + "bound": __safe_int(tolerance), + }) if reliability: - request["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["sle-policy"]["reliability"] = __safe_int(reliability) + slo_template["sle-policy"]["reliability"] = __safe_int(reliability) + + return request_data - return request def __build_request(ip_version=None, src_node_ip=None, dst_node_ip=None, src_node_ipv6=None, dst_node_ipv6=None, vlan_id=None, bandwidth=None, latency=None, tolerance=0, latency_version=None, @@ -174,7 +199,7 @@ def __datos_json(): dataframe =pd.DataFrame(rows) except FileNotFoundError: dataframe =pd.DataFrame() - except ValueError as e: + except ValueError: dataframe =pd.DataFrame() return dataframe @@ -411,52 +436,45 @@ def search(): print(f"Error procesando slice: {e}") - import pandas as pd dataframe = pd.DataFrame(rows) - def format_attributes(attributes): - formatted_attrs = [] - for attr in attributes: - formatted_attrs.append(attr) - if formatted_attrs: - return formatted_attrs - else: - return None - - dataframe['attributes'] = dataframe['attributes'].apply([format_attributes]) - - if request.method == 'POST': - search_option = request.form.get('search_option') - search_value = request.form.get('search_value') - if search_option == 'Source IP': - results = dataframe[dataframe['Source IP'] == search_value] - elif search_option == 'Destiny IP': - results = dataframe[dataframe['Destiny IP'] == search_value] - elif search_option == 'Controller': - results = dataframe[dataframe['Controller'] == search_value] - elif search_option == 'VLAN': - results = dataframe[dataframe['VLAN'] == search_value] + def format_attributes(attributes: list[str]) -> list[str] | None: + return list(attributes) if attributes else None + + dataframe["attributes"] = dataframe["attributes"].apply([format_attributes]) + + if request.method == "POST": + search_option = request.form.get("search_option") + search_value = request.form.get("search_value") + if search_option == "Source IP": + results = dataframe[dataframe["Source IP"] == search_value] + elif search_option == "Destiny IP": + results = dataframe[dataframe["Destiny IP"] == search_value] + elif search_option == "Controller": + results = dataframe[dataframe["Controller"] == search_value] + elif search_option == "VLAN": + results = dataframe[dataframe["VLAN"] == search_value] else: results = dataframe - result_html = results.to_html(classes='table table-striped') - return jsonify({'result': result_html}) + result_html = results.to_html(classes="table table-striped") + return jsonify({"result": result_html}) + + dataframe_html = dataframe.to_html(classes="table table-striped") + return render_template("search.html", dataframe_html=dataframe_html) - dataframe_html = dataframe.to_html(classes='table table-striped') - return render_template('search.html', dataframe_html=dataframe_html) -@gui_bp.route('/webui/login', methods=['GET', 'POST']) +@gui_bp.route("/webui/login", methods=["GET", "POST"]) def login(): - global enter - if request.method == 'POST': - username = request.form['username'] - password = request.form['password'] + if request.method == "POST": + username = request.form["username"] + password = request.form["password"] if username == USERNAME and password == PASSWORD: - session['enter']=True - return redirect(url_for('gui.develop')) - else: - return render_template('login.html', error="Credenciales incorrectas") - - return render_template('login.html') + session["enter"] = True + return redirect(url_for("gui.develop")) + return render_template("login.html", error="Credenciales incorrectas") + + return render_template("login.html") + @gui_bp.route('/webui/reset', methods=['POST']) def reset(): -- GitLab From 7477ee62c66cf987c1d01bb9d7af6313cb0c6ce4 Mon Sep 17 00:00:00 2001 From: velazquez Date: Wed, 19 Aug 2026 14:18:16 +0200 Subject: [PATCH 3/7] Code refactor 2 --- pyproject.toml | 19 + src/api/__init__.py | 35 + src/api/base_handler.py | 197 ++ src/api/e2e_handler.py | 287 +++ src/api/ixia_handler.py | 29 + src/api/main.py | 1417 ++----------- src/api/restconf_handler.py | 999 +++++++++ src/api/tfs_handler.py | 29 + src/realizer/e2e/e2e_connect.py | 100 +- src/realizer/e2e/main.py | 49 +- .../e2e/service_types/del_l3ipowdm_slice.py | 328 +-- .../e2e/service_types/l3ipowdm_slice.py | 279 +-- src/realizer/get_metrics.py | 101 +- src/realizer/ixia/helpers/NEII_V4.py | 513 ++--- .../ixia/helpers/automatizacion_ne2v4.py | 717 +++---- src/realizer/ixia/ixia_connect.py | 32 +- src/realizer/ixia/main.py | 141 +- src/realizer/main.py | 98 +- src/realizer/nrp_handler.py | 96 +- .../restconf/connectors/cisco_connector.py | 144 +- .../restconf/connectors/frr_connector.py | 118 +- .../restconf/connectors/tfs_connector.py | 737 +++---- src/realizer/restconf/main.py | 48 +- src/realizer/restconf/restconf_connect.py | 144 +- .../builders/apply_metric_constraint.py | 130 +- .../builders/configure_match_criteria.py | 109 +- .../service_types/builders/configure_slos.py | 50 +- .../builders/create_site_from_sdp.py | 83 +- .../builders/initialize_structure.py | 53 +- src/realizer/restconf/service_types/l2vpn.py | 48 +- src/realizer/restconf/service_types/l3vpn.py | 47 +- src/realizer/select_way.py | 119 +- src/realizer/send_controller.py | 45 +- src/realizer/tfs/helpers/cisco_connector.py | 140 +- src/realizer/tfs/helpers/tfs_connector.py | 196 +- src/realizer/tfs/main.py | 48 +- src/realizer/tfs/service_types/tfs_l2vpn.py | 332 +-- src/realizer/tfs/service_types/tfs_l3vpn.py | 318 ++- src/realizer/tfs/tfs_connect.py | 64 +- src/tests/conftest.py | 113 +- src/tests/test_api.py | 713 +++---- src/tests/test_database.py | 354 ++-- src/tests/test_e2e.py | 552 ++--- src/tests/test_initialization.py | 10 +- src/tests/test_mapper.py | 1879 ++++++++--------- src/tests/test_namespaces.py | 204 +- src/tests/test_nbi_processor.py | 107 +- src/tests/test_planner.py | 954 ++++----- src/tests/test_realizer.py | 687 +++++- src/tests/test_utils.py | 42 +- src/tests/test_webui.py | 201 +- swagger/E2E_namespace.py | 318 +-- swagger/helpers.py | 64 + swagger/ixia_namespace.py | 132 +- swagger/models/create_models.py | 468 ++-- swagger/models/create_models_restconf.py | 1027 +++++---- swagger/restconf_namespace.py | 461 ++-- swagger/tfs_namespace.py | 164 +- 58 files changed, 9147 insertions(+), 7742 deletions(-) create mode 100644 pyproject.toml create mode 100644 src/api/__init__.py create mode 100644 src/api/base_handler.py create mode 100644 src/api/e2e_handler.py create mode 100644 src/api/ixia_handler.py create mode 100644 src/api/restconf_handler.py create mode 100644 src/api/tfs_handler.py create mode 100644 swagger/helpers.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..aefb93c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[tool.ruff] +line-length = 120 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B", "C4", "SIM"] +ignore = [ + "E501", # Line too long (managed by formatter and docstrings) + "BLE001", # Catching Exception is intentional for HTTP 500 API boundary handlers + "SIM108", # Use ternary operator + "SIM102", # Nested if statements + "SIM105", # Use contextlib.suppress + "SIM117", # Multiple with statements +] + +[tool.pytest.ini_options] +testpaths = ["src/tests"] +pythonpath = ["."] +addopts = "-v --tb=short" diff --git a/src/api/__init__.py b/src/api/__init__.py new file mode 100644 index 0000000..cbc8f2e --- /dev/null +++ b/src/api/__init__.py @@ -0,0 +1,35 @@ +# Copyright 2022-2026 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. + +"""API package for Network Slice Controller.""" + +from __future__ import annotations + +from src.api.base_handler import BaseSliceHandler +from src.api.e2e_handler import E2EHandler +from src.api.ixia_handler import IxiaHandler +from src.api.main import Api +from src.api.restconf_handler import RestconfHandler +from src.api.tfs_handler import TfsHandler + +__all__ = [ + "Api", + "BaseSliceHandler", + "E2EHandler", + "IxiaHandler", + "RestconfHandler", + "TfsHandler", +] diff --git a/src/api/base_handler.py b/src/api/base_handler.py new file mode 100644 index 0000000..e0d57c3 --- /dev/null +++ b/src/api/base_handler.py @@ -0,0 +1,197 @@ +# Copyright 2022-2026 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. + +"""Base handler for transport network slice flow operations.""" + +from __future__ import annotations + +import logging +import sys +from typing import Any + +from flask import current_app + +from src.database.db import ( + delete_all_data as _db_delete_all_data, +) +from src.database.db import ( + delete_data as _db_delete_data, +) +from src.database.db import ( + get_all_data as _db_get_all_data, +) +from src.database.db import ( + get_data as _db_get_data, +) +from src.realizer.tfs.helpers.tfs_connector import tfs_connector as _real_tfs_connector +from src.realizer.tfs.service_types.tfs_l2vpn import tfs_l2vpn_delete as _real_tfs_l2vpn_delete +from src.utils.safe_get import safe_get +from src.utils.send_response import send_response + +logger = logging.getLogger(__name__) + + +def _dep(name: str, fallback: Any) -> Any: + """Resolve dependency from src.api.main if patched, otherwise fallback.""" + main_mod = sys.modules.get("src.api.main") + if main_mod is not None and hasattr(main_mod, name): + return getattr(main_mod, name) + return fallback + + +def _extract_slice_type(slice_dict: dict[str, Any]) -> str: + """Extract slice type tag from slice intent with fallback to L2.""" + slice_type = safe_get( + slice_dict, + [ + "intent", + "ietf-network-slice-service:network-slice-services", + "slice-service", + 0, + "service-tags", + "tag-type", + 0, + "tag-type-value", + 0, + ], + ) + if not slice_type: + logger.warning("Slice type not found in slice intent. Defaulting to L2") + return "L2" + return str(slice_type) + + +def _delete_slice_from_tfs(slice_dict: dict[str, Any], slice_id: str) -> None: + """Delete a slice in TeraFlowSDN via NBI connector.""" + slice_type = _extract_slice_type(slice_dict) + connector = _dep("tfs_connector", _real_tfs_connector)() + connector.nbi_delete(current_app.config["TFS_IP"], slice_type, slice_id) + + +class BaseSliceHandler: + """Base handler providing CRUD flow operations for transport network slices.""" + + def __init__(self, slice_service: Any) -> None: + """Initialize handler with underlying slice service (e.g. NSController). + + Args: + slice_service: Service instance managing controller-specific business logic. + """ + self.slice_service = slice_service + + def add_flow(self, intent: dict[str, Any]) -> tuple[dict[str, Any], int]: + """Create a new transport network slice.""" + try: + result = self.slice_service.nsc(intent) + if not result: + return send_response(False, code=404, message="No intents found") + if isinstance(result, tuple): + return result + logger.info("Slice created successfully") + return send_response(True, code=201, data=result) + except RuntimeError as exc: + return send_response(False, code=200, message=str(exc)) + except Exception as exc: + return send_response(False, code=500, message=str(exc)) + + def get_flows(self, slice_id: str | None = None) -> tuple[dict[str, Any] | list[dict[str, Any]], int]: + """Retrieve transport network slice information.""" + try: + get_all_data_fn = _dep("get_all_data", _db_get_all_data) + content = get_all_data_fn() + if slice_id: + for slice_item in content: + if slice_item.get("slice_id") == slice_id: + return slice_item, 200 + raise ValueError("Transport network slices not found") + + if not content: + raise ValueError("Transport network slices not found") + + filtered = [s for s in content if s.get("controller") == self.slice_service.controller_type] + return filtered, 200 + + except ValueError as exc: + return send_response(False, code=404, message=str(exc)) + except Exception as exc: + return send_response(False, code=500, message=str(exc)) + + def modify_flow(self, slice_id: str, intent: dict[str, Any]) -> tuple[dict[str, Any], int]: + """Modify an existing transport network slice.""" + try: + result = self.slice_service.nsc(intent, slice_id) + if isinstance(result, tuple): + return result + if not result: + return send_response(False, code=404, message="Slice not found") + + logger.info("Slice %s modified successfully", slice_id) + return send_response( + True, + code=200, + message="Slice modified successfully", + data=result, + ) + except ValueError as exc: + return send_response(False, code=404, message=str(exc)) + except Exception as exc: + return send_response(False, code=500, message=str(exc)) + + def delete_flows(self, slice_id: str | None = None) -> tuple[dict[str, Any], int]: + """Delete transport network slice(s).""" + try: + if slice_id: + return self._delete_single_flow(slice_id) + return self._delete_all_flows() + except ValueError as exc: + return send_response(False, code=404, message=str(exc)) + except Exception as exc: + return send_response(False, code=500, message=str(exc)) + + def _delete_single_flow(self, slice_id: str) -> tuple[dict[str, Any], int]: + """Delete a single slice by slice_id.""" + get_data_fn = _dep("get_data", _db_get_data) + slice_item = get_data_fn(slice_id) + if not slice_item or slice_item.get("controller") != self.slice_service.controller_type: + raise ValueError("Transport network slice not found") + + if not current_app.config["DUMMY_MODE"] and self.slice_service.controller_type == "TFS": + _delete_slice_from_tfs(slice_item, slice_id) + + delete_data_fn = _dep("delete_data", _db_delete_data) + delete_data_fn(slice_id) + logger.info("Slice %s removed successfully", slice_id) + return {}, 204 + + def _delete_all_flows(self) -> tuple[dict[str, Any], int]: + """Delete all slices belonging to the current controller.""" + get_all_data_fn = _dep("get_all_data", _db_get_all_data) + if not current_app.config["DUMMY_MODE"] and self.slice_service.controller_type == "TFS": + content = get_all_data_fn() + for slice_item in content: + if slice_item.get("controller") == self.slice_service.controller_type: + _delete_slice_from_tfs(slice_item, slice_item.get("slice_id", "")) + if current_app.config["TFS_L2VPN_SUPPORT"]: + if hasattr(self.slice_service, "tfs_l2vpn_delete"): + self.slice_service.tfs_l2vpn_delete() + else: + l2vpn_del = _dep("tfs_l2vpn_delete", _real_tfs_l2vpn_delete) + l2vpn_del() + + delete_all_data_fn = _dep("delete_all_data", _db_delete_all_data) + delete_all_data_fn() + logger.info("All slices removed successfully") + return {}, 204 diff --git a/src/api/e2e_handler.py b/src/api/e2e_handler.py new file mode 100644 index 0000000..e6be038 --- /dev/null +++ b/src/api/e2e_handler.py @@ -0,0 +1,287 @@ +# Copyright 2022-2026 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. + +"""E2E Orchestrator slice and TAPI alert API service handler.""" + +from __future__ import annotations + +import json +import logging +import sys +from pathlib import Path +from typing import Any + +from src.api.base_handler import BaseSliceHandler +from src.database import alert_db as _real_alert_db +from src.database.db import ( + get_all_data as _db_get_all_data, +) +from src.database.db import ( + get_data as _db_get_data, +) +from src.database.db import ( + get_slice_id_by_subscription as _db_get_slice_id_by_sub, +) +from src.database.service_db import get_data as _db_get_service_db_data +from src.utils.send_response import send_response + +logger = logging.getLogger(__name__) + +FALLBACK_INTENT_PATH = Path("/home/llmserver/tfs-nsc/intent.json") + + +def _dep(name: str, fallback: Any) -> Any: + """Resolve dependency from src.api.main if patched, otherwise fallback.""" + main_mod = sys.modules.get("src.api.main") + if main_mod is not None and hasattr(main_mod, name): + return getattr(main_mod, name) + return fallback + + +def _parse_alert_notification(alert_data: dict[str, Any]) -> tuple[str | None, str | None, str | None]: + """Extract alert_id, subscription_id, and service_id from TAPI alert payload.""" + context = alert_data.get("tapi-notification:notification-context", []) + if not context or not isinstance(context, list): + return None, None, None + + notification = context[0].get("tapi-notification:notification", {}) + alert_id = notification.get("uuid") + subscription_id = alert_id + additional_info = notification.get("additional-info", {}) + service_id = additional_info.get("service-id") + return alert_id, subscription_id, service_id + + +def _find_slice_for_alert(subscription_id: str | None, service_id: str | None) -> dict[str, Any] | None: + """Resolve slice information for an alert by subscription, service_id, database, or fallback file.""" + slice_id = _resolve_slice_id(subscription_id, service_id) + slice_info = _lookup_slice_info(slice_id) + + if slice_info is not None: + return slice_info + + return _load_fallback_intent(slice_id) + + +def _resolve_slice_id(subscription_id: str | None, service_id: str | None) -> str | None: + """Resolve slice ID from subscription mapping or service database.""" + if subscription_id: + try: + get_slice_fn = _dep("get_slice_id_by_subscription", _db_get_slice_id_by_sub) + mapped_slice_id = get_slice_fn(subscription_id) + if mapped_slice_id: + logger.info("Found slice_id %s mapped to subscription_id %s", mapped_slice_id, subscription_id) + return mapped_slice_id + except Exception as exc: + logger.info("Subscription mapping lookup failed: %s", exc) + + if service_id: + try: + get_svc_fn = _dep("get_service_db_data", _db_get_service_db_data) + service_info = get_svc_fn(service_id) + resolved_slice_id = service_info.get("slice_id") + logger.info("Found slice_id %s in service_db for service_id %s", resolved_slice_id, service_id) + return resolved_slice_id + except Exception as exc: + logger.info("service_db lookup failed: %s", exc) + return service_id + + return None + + +def _lookup_slice_info(slice_id: str | None) -> dict[str, Any] | None: + """Lookup slice info in the database by ID or fall back to the first available slice.""" + if slice_id: + try: + get_data_fn = _dep("get_data", _db_get_data) + slice_info = get_data_fn(slice_id) + if slice_info: + logger.info("Found slice_info in db by slice_id %s", slice_id) + return slice_info + except Exception as exc: + logger.info("db lookup by slice_id %s failed: %s", slice_id, exc) + + try: + get_all_data_fn = _dep("get_all_data", _db_get_all_data) + slices = get_all_data_fn() + logger.info("Slices in db: %s", [s.get("slice_id") for s in slices]) + for s in slices: + if s.get("slice_id") == slice_id: + return s + if slices: + first_slice = slices[0] + logger.info("Defaulted to first slice from db: %s", first_slice.get("slice_id")) + return first_slice + except Exception as exc: + logger.info("db get_all_data lookup failed: %s", exc) + + return None + + +def _load_fallback_intent(slice_id: str | None) -> dict[str, Any] | None: + """Load fallback intent configuration from disk if available.""" + if not FALLBACK_INTENT_PATH.exists(): + return None + + try: + with FALLBACK_INTENT_PATH.open("r", encoding="utf-8") as file: + intent_data = json.load(file) + logger.info("Loaded fallback intent from intent.json") + return {"slice_id": slice_id or "slice", "intent": intent_data} + except Exception as exc: + logger.error("Failed to read fallback intent.json: %s", exc) + return None + + +def _swap_p2mp_endpoints(intent: dict[str, Any]) -> tuple[bool, str | None]: + """Modify P2MP receiver endpoints to alternate paths following an alert.""" + nss = intent.get("ietf-network-slice-service:network-slice-services", {}) + slice_services = nss.get("slice-service", []) + modified = False + old_service_id = None + + for service in slice_services: + sdp_list = service.get("sdps", {}).get("sdp", []) + sdp_ids = [sdp.get("id") for sdp in sdp_list if sdp.get("id")] + + connection_groups = service.get("connection-groups", {}).get("connection-group", []) + for cg in connection_groups: + connectivity_constructs = cg.get("connectivity-construct", []) + for cc in connectivity_constructs: + p2mp_sender = cc.get("p2mp-sender-sdp") + p2mp_receivers = cc.get("p2mp-receiver-sdp", []) + + logger.info( + "sdp_ids: %s, p2mp_sender: %s, p2mp_receivers: %s", + sdp_ids, + p2mp_sender, + p2mp_receivers, + ) + + other_endpoints = [ + sdp_id for sdp_id in sdp_ids if sdp_id != p2mp_sender and sdp_id not in p2mp_receivers + ] + + if other_endpoints and p2mp_receivers: + new_receiver = other_endpoints[0] + cc["p2mp-receiver-sdp"] = ( + [p2mp_receivers[0], new_receiver] if len(p2mp_receivers) >= 2 else [new_receiver] + ) + modified = True + logger.info("ORIGEN: %s DESTINO: %s", p2mp_sender, new_receiver) + else: + logger.warning("No alternative receiver endpoints found to swap.") + + if p2mp_receivers: + old_service_id = f"{p2mp_sender}_to_{','.join(p2mp_receivers)}" + + return modified, old_service_id + + +class E2EHandler(BaseSliceHandler): + """API handler dedicated to E2E Orchestrator slice operations and TAPI alert processing.""" + + def receive_alert(self, alert_data: dict[str, Any]) -> tuple[dict[str, Any], int]: + """Receive and process an incoming TAPI network alert.""" + try: + logger.info("Alert received: %s", alert_data) + alert_id, subscription_id, service_id = _parse_alert_notification(alert_data) + + if not alert_id: + return send_response(False, code=400, message="UUID not found in alert data") + + adb = _dep("alert_db", _real_alert_db) + adb.save_alert(alert_id, alert_data) + logger.info( + "Looking up intent for subscription_id: %s, service_id: %s", + subscription_id, + service_id, + ) + + slice_info = _find_slice_for_alert(subscription_id, service_id) + if slice_info: + intent = slice_info.get("intent") + curr_slice_id = slice_info.get("slice_id") + logger.info("Processing intent for slice %s", curr_slice_id) + + if intent: + modified, old_service_id = _swap_p2mp_endpoints(intent) + if modified: + try: + self.slice_service.nsc(intent, curr_slice_id, old_service_id=old_service_id) + logger.info("Slice %s updated successfully following alert.", curr_slice_id) + except Exception as exc: + logger.error("Failed to update slice configuration: %s", exc) + else: + logger.warning("No slice intent found to process alert.") + + return send_response( + True, + code=201, + message="Alert processed and saved successfully", + data=alert_data, + ) + except Exception as exc: + return send_response(False, code=500, message=str(exc)) + + def get_alerts(self, alert_id: str | None = None) -> tuple[dict[str, Any] | list[dict[str, Any]], int]: + """Retrieve alert(s).""" + try: + adb = _dep("alert_db", _real_alert_db) + if alert_id: + try: + data = adb.get_alert(alert_id) + return data, 200 + except ValueError as exc: + return send_response(False, code=404, message=str(exc)) + + data = adb.get_all_alerts() + return data, 200 + except Exception as exc: + return send_response(False, code=500, message=str(exc)) + + def modify_alert(self, alert_id: str, alert_data: dict[str, Any]) -> tuple[dict[str, Any], int]: + """Modify/update an alert.""" + try: + adb = _dep("alert_db", _real_alert_db) + try: + adb.update_alert(alert_id, alert_data) + return send_response( + True, + code=200, + message="Alert updated successfully", + data=alert_data, + ) + except ValueError as exc: + return send_response(False, code=404, message=str(exc)) + except Exception as exc: + return send_response(False, code=500, message=str(exc)) + + def delete_alerts(self, alert_id: str | None = None) -> tuple[dict[str, Any], int]: + """Delete alert(s).""" + try: + adb = _dep("alert_db", _real_alert_db) + if alert_id: + try: + adb.delete_alert(alert_id) + return {}, 204 + except ValueError as exc: + return send_response(False, code=404, message=str(exc)) + + adb.delete_all_alerts() + return {}, 204 + except Exception as exc: + return send_response(False, code=500, message=str(exc)) diff --git a/src/api/ixia_handler.py b/src/api/ixia_handler.py new file mode 100644 index 0000000..e875b1d --- /dev/null +++ b/src/api/ixia_handler.py @@ -0,0 +1,29 @@ +# Copyright 2022-2026 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. + +"""IXIA NEII slice API service handler.""" + +from __future__ import annotations + +import logging + +from src.api.base_handler import BaseSliceHandler + +logger = logging.getLogger(__name__) + + +class IxiaHandler(BaseSliceHandler): + """API handler dedicated to IXIA NEII transport network slice operations.""" diff --git a/src/api/main.py b/src/api/main.py index c8f7ce6..6fafcc0 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -1,1266 +1,151 @@ -# Copyright 2022-2026 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 asyncio -import json -import logging -from pathlib import Path -from typing import Any - -from flask import current_app - -from src.database import alert_db -from src.database.db import ( - delete_all_data, - delete_data, - get_all_data, - get_data, - get_slice_id_by_subscription, -) -from src.database.service_db import ( - delete_by_slice_id, - get_data_by_slice_id, -) -from src.database.service_db import ( - get_data as get_service_db_data, -) -from src.database.sysrepo_store import ( - create_data_store, - delete_data_store, - get_data_store, - normalize_libyang_data, - update_data_store, -) -from src.database.telemetry_client_db import ( - create_client, - delete_all_clients, - delete_all_subscriptions, - delete_client, - delete_subscription, - get_all_clients, - get_client, - get_client_subscriptions, - get_subscription, - upsert_subscription, -) -from src.realizer.restconf.connectors.tfs_connector import ( - tfs_connector as tfs_restconf_connector, -) -from src.realizer.tfs.helpers.tfs_connector import tfs_connector -from src.realizer.tfs.service_types.tfs_l2vpn import tfs_l2vpn_delete -from src.utils.safe_get import safe_get -from src.utils.send_response import send_response - - -def _parse_alert_notification(alert_data: dict[str, Any]) -> tuple[str | None, str | None, str | None]: - """Extract alert_id, subscription_id, and service_id from TAPI alert payload.""" - context = alert_data.get("tapi-notification:notification-context", []) - if context and isinstance(context, list): - notification = context[0].get("tapi-notification:notification", {}) - alert_id = notification.get("uuid") - subscription_id = alert_id - additional_info = notification.get("additional-info", {}) - service_id = additional_info.get("service-id") - return alert_id, subscription_id, service_id - return None, None, None - - -def _find_slice_for_alert(subscription_id: str | None, service_id: str | None) -> dict[str, Any] | None: - """Resolve slice information for an alert by subscription, service_id, database, or fallback file.""" - slice_id = None - slice_info = None - - if subscription_id: - try: - mapped_slice_id = get_slice_id_by_subscription(subscription_id) - if mapped_slice_id: - slice_id = mapped_slice_id - logging.info(f"Found slice_id {slice_id} mapped to subscription_id {subscription_id}") - except Exception as e: - logging.info(f"Subscription mapping lookup failed: {e}") - - if not slice_id and service_id: - try: - service_info = get_service_db_data(service_id) - slice_id = service_info.get("slice_id") - logging.info(f"Found slice_id {slice_id} in service_db for service_id {service_id}") - except Exception as e: - logging.info(f"service_db lookup failed: {e}") - slice_id = service_id - - if slice_id: - try: - slice_info = get_data(slice_id) - logging.info(f"Found slice_info in db by slice_id {slice_id}") - except Exception as e: - logging.info(f"db lookup by slice_id {slice_id} failed: {e}") - - if not slice_info: - try: - slices = get_all_data() - logging.info(f"Slices in db: {[s.get('slice_id') for s in slices]}") - for s in slices: - if s.get("slice_id") == slice_id: - slice_info = s - break - if not slice_info and slices: - slice_info = slices[0] - logging.info(f"Defaulted to first slice from db: {slice_info.get('slice_id')}") - except Exception as e: - logging.info(f"db get_all_data lookup failed: {e}") - - if not slice_info: - fallback_path = Path("/home/llmserver/tfs-nsc/intent.json") - if fallback_path.exists(): - try: - with fallback_path.open("r", encoding="utf-8") as f: - intent_data = json.load(f) - slice_info = {"slice_id": slice_id or "slice", "intent": intent_data} - logging.info("Loaded fallback intent from intent.json") - except Exception as e: - logging.error(f"Failed to read fallback intent.json: {e}") - - return slice_info - - -def _swap_p2mp_endpoints(intent: dict[str, Any]) -> tuple[bool, str | None]: - """Modify P2MP receiver endpoints to alternate paths following an alert.""" - nss = intent.get("ietf-network-slice-service:network-slice-services", {}) - slice_services = nss.get("slice-service", []) - modified = False - old_service_id = None - - for service in slice_services: - sdp_list = service.get("sdps", {}).get("sdp", []) - sdp_ids = [sdp.get("id") for sdp in sdp_list if sdp.get("id")] - - connection_groups = service.get("connection-groups", {}).get("connection-group", []) - for cg in connection_groups: - connectivity_constructs = cg.get("connectivity-construct", []) - for cc in connectivity_constructs: - p2mp_sender = cc.get("p2mp-sender-sdp") - p2mp_receivers = cc.get("p2mp-receiver-sdp", []) - - logging.info( - f"sdp_ids: {sdp_ids}, p2mp_sender: {p2mp_sender}, p2mp_receivers: {p2mp_receivers}" - ) - - other_endpoints = [ - sdp_id for sdp_id in sdp_ids if sdp_id != p2mp_sender and sdp_id not in p2mp_receivers - ] - - if other_endpoints and p2mp_receivers: - new_receiver = other_endpoints[0] - if len(p2mp_receivers) >= 2: - cc["p2mp-receiver-sdp"] = [p2mp_receivers[0], new_receiver] - else: - cc["p2mp-receiver-sdp"] = [new_receiver] - modified = True - logging.info(f"ORIGEN: {p2mp_sender} DESTINO: {new_receiver}") - else: - logging.warning("No alternative receiver endpoints found to swap.") - - if p2mp_receivers: - old_service_id = f"{p2mp_sender}_to_{','.join(p2mp_receivers)}" - - return modified, old_service_id - - -class Api: - """Network Slice Controller REST API service handler.""" - - def __init__(self, slice_service: Any) -> None: - self.slice_service = slice_service - - def add_flow(self, intent: dict[str, Any]) -> tuple[dict[str, Any], int]: - """Create a new transport network slice.""" - try: - result = self.slice_service.nsc(intent) - if not result: - return send_response(False, code=404, message="No intents found") - if isinstance(result, tuple): - return result - logging.info("Slice created successfully") - return send_response(True, code=201, data=result) - except RuntimeError as e: - return send_response(False, code=200, message=str(e)) - except Exception as e: - return send_response(False, code=500, message=str(e)) - - def receive_alert(self, alert_data: dict[str, Any]) -> tuple[dict[str, Any], int]: - """Receive and process an incoming TAPI network alert.""" - try: - logging.info(f"Alert received: {alert_data}") - alert_id, subscription_id, service_id = _parse_alert_notification(alert_data) - - if not alert_id: - return send_response(False, code=400, message="UUID not found in alert data") - - alert_db.save_alert(alert_id, alert_data) - logging.info(f"Looking up intent for subscription_id: {subscription_id}, service_id: {service_id}") - - slice_info = _find_slice_for_alert(subscription_id, service_id) - if slice_info: - intent = slice_info.get("intent") - curr_slice_id = slice_info.get("slice_id") - logging.info(f"Processing intent for slice {curr_slice_id}") - - if intent: - modified, old_service_id = _swap_p2mp_endpoints(intent) - if modified: - try: - self.slice_service.nsc(intent, curr_slice_id, old_service_id=old_service_id) - logging.info(f"Slice {curr_slice_id} updated successfully following alert.") - except Exception as e: - logging.error(f"Failed to update slice configuration: {e}") - else: - logging.warning("No slice intent found to process alert.") - - return send_response( - True, - code=201, - message="Alert processed and saved successfully", - data=alert_data, - ) - except Exception as e: - return send_response(False, code=500, message=str(e)) - - - def get_alerts(self, alert_id=None): - """ - Retrieve alert(s). - """ - try: - if alert_id: - try: - data = alert_db.get_alert(alert_id) - return data, 200 - except ValueError as e: - return send_response(False, code=404, message=str(e)) - else: - data = alert_db.get_all_alerts() - return data, 200 - except Exception as e: - return send_response(False, code=500, message=str(e)) - - def modify_alert(self, alert_id, alert_data): - """ - Modify/update an alert. - """ - try: - try: - alert_db.update_alert(alert_id, alert_data) - return send_response( - True, - code=200, - message="Alert updated successfully", - data=alert_data - ) - except ValueError as e: - return send_response(False, code=404, message=str(e)) - except Exception as e: - return send_response(False, code=500, message=str(e)) - - def delete_alerts(self, alert_id=None): - """ - Delete alert(s). - """ - try: - if alert_id: - try: - alert_db.delete_alert(alert_id) - return {}, 204 - except ValueError as e: - return send_response(False, code=404, message=str(e)) - else: - alert_db.delete_all_alerts() - return {}, 204 - except Exception as e: - return send_response(False, code=500, message=str(e)) - - def get_flows(self,slice_id=None): - """ - Retrieve transport network slice information. - - This method allows retrieving: - - All transport network slices - - A specific slice by its ID - - Args: - slice_id (str, optional): Unique identifier of a specific slice. - Defaults to None. - - Returns: - dict or list: - - If slice_id is provided: Returns the specific slice details - - If slice_id is None: Returns a list of all slices - - Returns an error response if no slices are found - - API Endpoint: - GET /slice/{id} - - Raises: - ValueError: If no transport network slices are found - Exception: For unexpected errors - """ - try: - # Read slice database from JSON file - content = get_all_data() - # If specific slice ID is provided, find and return matching slice - if slice_id: - for slice in content: - if slice["slice_id"] == slice_id: - return slice, 200 - raise ValueError("Transport network slices not found") - # If no slices exist, raise an error - if len(content) == 0: - raise ValueError("Transport network slices not found") - - # Return all slices if no specific ID is given - return [slice for slice in content if slice.get("controller") == self.slice_service.controller_type], 200 - - except ValueError as e: - # Handle case where no slices are found - return send_response(False, code=404, message=str(e)) - except Exception as e: - # Handle unexpected errors - return send_response(False, code=500, message=str(e)) - - def modify_flow(self,slice_id, intent): - """ - Modify an existing transport network slice. - - Args: - slice_id (str): Unique identifier of the slice to modify - intent (dict): New intent configuration for the slice - - Returns: - Result of the Network Slice Controller (NSC) operation - - API Endpoint: - PUT /slice/{id} - Raises: - Exception: For unexpected errors - """ - try: - result = self.slice_service.nsc(intent, slice_id) - if isinstance(result, tuple): - return result - if not result: - return send_response(False, code=404, message="Slice not found") - logging.info(f"Slice {slice_id} modified successfully") - return send_response( - True, - code=200, - message="Slice modified successfully", - data=result - ) - except ValueError as e: - # Handle case where no slices are found - return send_response(False, code=404, message=str(e)) - except Exception as e: - # Handle unexpected errors - return send_response(False, code=500, message=str(e)) - - def delete_flows(self, slice_id=None): - """ - Delete transport network slice(s). - - This method supports: - - Deleting a specific slice by ID - - Deleting all slices - - Optional cleanup of L2VPN configurations - - Args: - slice_id (str, optional): Unique identifier of slice to delete. - Defaults to None. - - Returns: - dict: {} indicating successful deletion or error details - - API Endpoint: - DELETE /slice/{id} - - Raises: - ValueError: If no slices are found to delete - Exception: For unexpected errors - - Notes: - - If controller_type is TFS, attempts to delete from Teraflow - - If need_l2vpn_support is True, performs additional L2VPN cleanup - """ - try: - # Delete specific slice if slice_id is provided - if slice_id: - slice = get_data(slice_id) - # Raise error if slice not found - if not slice or slice.get("controller") != self.slice_service.controller_type: - raise ValueError("Transport network slice not found") - # Delete in Teraflow - if not current_app.config["DUMMY_MODE"]: - if self.slice_service.controller_type == "TFS": - slice_type = safe_get(slice, ['intent', 'ietf-network-slice-service:network-slice-services', 'slice-service', 0, 'service-tags', 'tag-type', 0, 'tag-type-value', 0]) - if not slice_type: - slice_type = "L2" - logging.warning("Slice type not found in slice intent. Defaulting to L2") - tfs_connector().nbi_delete(current_app.config["TFS_IP"],slice_type, slice_id) - # Update slice database - delete_data(slice_id) - logging.info(f"Slice {slice_id} removed successfully") - return {}, 204 - - # Delete all slices - else: - # Optional: Delete in Teraflow if configured - if not current_app.config["DUMMY_MODE"]: - if self.slice_service.controller_type == "TFS": - content = get_all_data() - for slice in content: - if slice.get("controller") == self.slice_service.controller_type: - slice_type = safe_get(slice, ['intent', 'ietf-network-slice-service:network-slice-services', 'slice-service', 0, 'service-tags', 'tag-type', 0, 'tag-type-value', 0]) - if not slice_type: - slice_type = "L2" - logging.warning("Slice type not found in slice intent. Defaulting to L2") - tfs_connector().nbi_delete(current_app.config["TFS_IP"],slice_type, slice.get("slice_id")) - if current_app.config["TFS_L2VPN_SUPPORT"]: - tfs_l2vpn_delete() - - # Clear slice database - delete_all_data() - - logging.info("All slices removed successfully") - return {}, 204 - - except ValueError as e: - return send_response(False, code=404, message=str(e)) - except Exception as e: - return send_response(False, code=500, message=str(e)) - - # RESCONF calls - - ### GET - - def get_network_slice_services(self): - try: - data = get_data_store("/ietf-network-slice-service:network-slice-services") - if not data: - raise ValueError("Nothing found") - return data, 200 - except ValueError as e: - return send_response(False, code=404, message=str(e)) - except Exception as e: - return send_response(False, code=500, message=str(e)) - - def get_slo_sle_templates(self, template_id=None): - try: - if template_id: - xpath = f"/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template[id='{template_id}']" - data = get_data_store(xpath) - if not data: - raise ValueError("Template not found") - return data, 200 - - data = get_data_store("/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template") - if not data: - raise ValueError("No templates found") - - return data, 200 - - except ValueError as e: - # Handle case where no slices are found - return send_response(False, code=404, message=str(e)) - except Exception as e: - # Handle unexpected errors - return send_response(False, code=500, message=str(e)) - - def get_slice_services(self, slice_id=None): - try: - if slice_id: - data = get_data_store(f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']") - if not data: - raise ValueError("Slice not found") - return data, 200 - - data = get_data_store("/ietf-network-slice-service:network-slice-services/slice-service") - if not data: - raise ValueError("No slices found") - - return data, 200 - - except ValueError as e: - # Handle case where no slices are found - return send_response(False, code=404, message=str(e)) - except Exception as e: - # Handle unexpected errors - return send_response(False, code=500, message=str(e)) - - def get_slice_topology(self, slice_id): - """ - Retrieve Network Slice Topology for a given slice_id according to draft-ietf-teas-network-slice-topology-yang-04. - """ - try: - tfs_ip = current_app.config.get("RESTCONF_IP", "127.0.0.1") - connector = tfs_restconf_connector() - return connector.get_slice_topology(tfs_ip, slice_id) - except Exception as e: - logging.exception(f"Error retrieving slice topology for slice '{slice_id}': {e}") - return send_response(False, code=500, message=str(e)) - - def get_sdps(self, slice_id, sdp_id=None): - try: - if sdp_id: - data = get_data_store(f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']/sdps/sdp[id='{sdp_id}']") - if not data: - raise ValueError("SDP not found") - return data, 200 - - data = get_data_store(f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']/sdps") - if not data: - raise ValueError("No SDPs found") - - return data, 200 - - except ValueError as e: - # Handle case where no slices are found - return send_response(False, code=404, message=str(e)) - except Exception as e: - # Handle unexpected errors - return send_response(False, code=500, message=str(e)) - - ### POST - - def add_network_slice_service(self, intent): - try: - result = self.slice_service.nsc(intent) - if isinstance(result, tuple): - return result - if result: - try: - create_data_store(intent) - except Exception as ds_err: - logging.warning(f"Could not store intent in sysrepo datastore: {ds_err}") - logging.info("Network Slice created successfully") - return send_response( - True, - code=201, - message="Network Slice created successfully", - data=result - ) - except RuntimeError as e: - # Handle case where there is no content to process - return send_response(False, code=200, message=str(e)) - except Exception as e: - # Handle unexpected errors - return send_response(False, code=500, message=str(e)) - - def add_slo_sle_template(self, template): - try: - template_id = template.pop("id", None) - xpath = f"/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template[id='{template_id}']" - existing_template = get_data_store(xpath) - if existing_template: - return send_response(False, code=409, message="Template already exists") - create_data_store(template, xpath) - logging.info("Template created successfully") - return send_response( - True, - code=201, - message="Template created successfully" - ) - except Exception as e: - # Handle unexpected errors - return send_response(False, code=500, message=str(e)) - - def add_slice_service(self, intent): - try: - xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{intent.get('id')}']" - existing_slice = get_data_store(xpath) - if existing_slice: - return send_response(False, code=409, message="Slice already exists") - - if "slo-sle-template" in intent: - template_ref = intent.get("slo-sle-template") - xpath_template = f"/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template[id='{template_ref}']" - existing_template = get_data_store(xpath_template) - if not existing_template: - return send_response(False, code=404, message="Referenced SLO/SLE template not found") - full_intent = { - "ietf-network-slice-service:network-slice-services": { - "slo-sle-templates": { - "slo-sle-template": [existing_template["network-slice-services"]["slo-sle-templates"]["slo-sle-template"][template_ref]] - }, - "slice-service": [intent] - } - } - elif "service-slo-sle-policy" in intent: - full_intent = { - "ietf-network-slice-service:network-slice-services": { - "slo-sle-templates": { - "slo-sle-template": intent.get("service-slo-sle-policy") - }, - "slice-service": [intent] - } - } - else: - return send_response(False, code=400, message="No SLO/SLE template or policy provided in intent") - - full_intent = normalize_libyang_data(full_intent) - result = self.slice_service.nsc(full_intent) - if result: - intent.pop("id", None) - create_data_store(intent, xpath) - logging.info("Slice created successfully") - return send_response( - True, - code=201, - message="Slice created successfully", - data=result - ) - except RuntimeError as e: - # Handle case where there is no content to process - return send_response(False, code=200, message=str(e)) - except Exception as e: - # Handle unexpected errors - return send_response(False, code=500, message=str(e)) - - def add_sdp(self, slice_id, sdp): - try: - sdp_id = sdp.pop("id", None) - xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']/sdps/sdp[id='{sdp_id}']" - existing_sdp = get_data_store(xpath) - if existing_sdp: - return send_response(False, code=409, message="SDP already exists") - create_data_store(sdp, xpath) - logging.info("SDP created successfully") - return send_response( - True, - code=201, - message="SDP created successfully" - ) - except Exception as e: - # Handle unexpected errors - return send_response(False, code=500, message=str(e)) - - ### PUT - - def update_network_slice_service(self, intent): - """ - Modify (replace) all network-slice-services configuration - """ - try: - xpath = "/ietf-network-slice-service:network-slice-services" - - # Verify if there is something to modify - existing_data = get_data_store(xpath) - if not existing_data: - return send_response(False, code=404, message="Network slice services not found") - - # If not in DUMMY mode, process with TFS - result = self.slice_service.nsc(intent) - if not result: - return send_response(False, code=500, message="Failed to process slice in TFS") - - # Replace completely the resource - update_data_store(intent) - logging.info("Network slice services modified successfully") - - return send_response( - True, - code=200, - message="Network slice services updated successfully", - data=result - ) - - except ValueError as e: - return send_response(False, code=404, message=str(e)) - except Exception as e: - return send_response(False, code=500, message=str(e)) - - def update_slo_sle_template(self, template_id, template): - """ - Modify (replace) an specific SLO/SLE template - """ - try: - xpath = f"/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template[id='{template_id}']" - - # Verify the template exists - existing_template = get_data_store(xpath) - if not existing_template: - return send_response(False, code=404, message="Template not found") - - # Assure that the body ID matches the URL - if "id" in template and template["id"] != template_id: - return send_response(False, code=400, message="Template ID in body does not match URL") - - slices = get_data_store("/ietf-network-slice-service:network-slice-services/slice-service") - - for slice in slices["network-slice-services"]["slice-service"]: - if "slo-sle-template" in slice: - if slice.get("slo-sle-template") == template_id: - full_intent = { - "ietf-network-slice-service:network-slice-services": { - "slo-sle-templates": { - "slo-sle-template": [existing_template["network-slice-services"]["slo-sle-templates"]["slo-sle-template"][template_id]] - }, - "slice-service": [slice] - } - } - full_intent = normalize_libyang_data(full_intent) - result = self.slice_service.nsc(full_intent, slice.get("id")) - if not result: - return send_response(False, code=500, message="Slice not updated") - - # Remove the ID from the body if it exists (it's already in the predicate) - template_data = template.copy() - template_data.pop("id", None) - - # Replace the template - update_data_store(template_data, xpath) - logging.info(f"Template {template_id} modified successfully") - - return send_response( - True, - code=200, - message="Template updated successfully", - data=result - ) - - except ValueError as e: - return send_response(False, code=404, message=str(e)) - except Exception as e: - return send_response(False, code=500, message=str(e)) - - def update_slice_service(self, slice_id, intent): - """ - Modifica (reemplaza) un slice service específico - """ - try: - xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']" - - # Verify that the slice exists - existing_slice = get_data_store(xpath) - if not existing_slice: - return send_response(False, code=404, message="Slice not found") - - # Assure that the body ID matches the URL - if "id" in intent and intent["id"] != slice_id: - return send_response(False, code=400, message="Slice ID in body does not match URL") - - # Validate that the referenced SLO/SLE template exists - if "slo-sle-template" in intent: - template_ref = intent.get("slo-sle-template") - xpath_template = f"/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template[id='{template_ref}']" - existing_template = get_data_store(xpath_template) - if not existing_template: - return send_response(False, code=404, message="Referenced SLO/SLE template not found") - - # Build the full intent - full_intent = { - "ietf-network-slice-service:network-slice-services": { - "slo-sle-templates": { - "slo-sle-template": [existing_template["network-slice-services"]["slo-sle-templates"]["slo-sle-template"][template_ref]] - }, - "slice-service": [intent] - } - } - elif "service-slo-sle-policy" in intent: - full_intent = { - "ietf-network-slice-service:network-slice-services": { - "slo-sle-templates": { - "slo-sle-template": intent.get("service-slo-sle-policy") - }, - "slice-service": [intent] - } - } - else: - return send_response(False, code=400, message="No SLO/SLE template or policy provided in intent") - - full_intent = normalize_libyang_data(full_intent) - result = self.slice_service.nsc(full_intent) - if not result: - return send_response(False, code=500, message="Slice not updated") - - # Remove the ID from the body (it's already in the predicate) - intent_data = intent.copy() - intent_data.pop("id", None) - - # Replace the slice - update_data_store(intent_data, xpath) - logging.info(f"Slice {slice_id} modified successfully") - - return send_response( - True, - code=200, - message="Slice updated successfully", - data=result - ) - - except ValueError as e: - return send_response(False, code=404, message=str(e)) - except RuntimeError as e: - return send_response(False, code=200, message=str(e)) - except Exception as e: - return send_response(False, code=500, message=str(e)) - - def update_sdp(self, slice_id, sdp_id, sdp): - """ - Modify (replace) an specific SDP in the slice - """ - try: - # Verify the template exists - slice_xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']" - existing_slice = get_data_store(slice_xpath) - if not existing_slice: - return send_response(False, code=404, message="Slice not found") - - # Verify the SDP exists - sdp_xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']/sdps/sdp[id='{sdp_id}']" - existing_sdp = get_data_store(sdp_xpath) - if not existing_sdp: - return send_response(False, code=404, message="SDP not found") - - # Assure that the body ID matches the URL - if "id" in sdp and sdp["id"] != sdp_id: - return send_response(False, code=400, message="SDP ID in body does not match URL") - - # Remove the ID from the body (it's already in the predicate) - sdp_data = sdp.copy() - sdp_data.pop("id", None) - - # Replace the SDP - update_data_store(sdp_data, sdp_xpath) - logging.info(f"SDP {sdp_id} in slice {slice_id} modified successfully") - - return send_response( - True, - code=200, - message="SDP updated successfully" - ) - - except ValueError as e: - return send_response(False, code=404, message=str(e)) - except Exception as e: - return send_response(False, code=500, message=str(e)) - - ### DELETE - - def delete_network_slice_services(self): - try: - xpath = "/ietf-network-slice-service:network-slice-services" - if not current_app.config["DUMMY_MODE"]: - content = get_data_store(xpath) - slice_services = safe_get(content, ["network-slice-services", "slice-service"]) - if not slice_services: - raise ValueError("Network slice services not found") - for slice in slice_services: - slice_type = list(slice["service-tags"]["tag-type"]["ietf-network-slice-service:service"]["tag-type-value"])[0] - if not slice_type: - slice_type = "L2" - logging.warning("Slice type not found in slice intent. Defaulting to L2") - logging.debug(f"Send slice to delete in TFS with slice_type {slice_type}") - services = get_data_by_slice_id(slice.get("id")) - if services: - for service in services: - id = service.get("service_id") - tfs_connector().nbi_delete(current_app.config["RESTCONF_IP"], slice_type, id) - delete_by_slice_id(slice.get("id")) - if current_app.config["TFS_L2VPN_SUPPORT"]: - self.slice_service.tfs_l2vpn_delete() - - delete_data_store(xpath) - logging.info("All slices removed successfully") - - return {}, 204 - - except ValueError as e: - return send_response(False, code=404, message=str(e)) - except Exception as e: - return send_response(False, code=500, message=str(e)) - - def delete_slo_sle_templates(self, template_id=None): - try: - # Delete specific template if template_id is provided - if template_id: - xpath = f"/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template[id='{template_id}']" - existing_template = get_data_store(xpath) - if not existing_template: - raise ValueError("Template not found") - delete_data_store(xpath) - logging.info(f"Template {template_id} removed successfully") - return {}, 204 - - # Delete all templates - else: - xpath = "/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template" - delete_data_store(xpath) - logging.info("All templates removed successfully") - return {}, 204 - - except ValueError as e: - return send_response(False, code=404, message=str(e)) - except Exception as e: - return send_response(False, code=500, message=str(e)) - - def delete_slice_services(self, slice_id=None): - try: - # Delete specific slice if slice_id is provided - if slice_id: - xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']" - existing_slice = get_data_store(xpath) - if not existing_slice: - raise ValueError("Slice not found") - if not current_app.config["DUMMY_MODE"]: - slice_type = safe_get(existing_slice, ["network-slice-services", "slice-service", slice_id, "service-tags", "tag-type", "ietf-network-slice-service:service", "tag-type-value", 0]) or "L2" - logging.debug(f"Send slice to delete in TFS with slice_type {slice_type}") - services = get_data_by_slice_id(slice_id) - if services: - for service in services: - id = service.get("service_id") - tfs_connector().nbi_delete(current_app.config["RESTCONF_IP"], slice_type, id) - delete_by_slice_id(slice_id) - if current_app.config["TFS_L2VPN_SUPPORT"]: - tfs_l2vpn_delete() - - delete_data_store(xpath) - logging.info(f"Slice {slice_id} removed successfully") - return {}, 204 - - # Delete all slices - else: - xpath = "/ietf-network-slice-service:network-slice-services/slice-service" - if not current_app.config["DUMMY_MODE"]: - content = get_data_store(xpath) - slice_services = safe_get(content, ["network-slice-services", "slice-service"]) - if not slice_services: - raise ValueError("Slice services not found") - for slice in slice_services: - slice_type = safe_get(slice, ["service-tags", "tag-type", "ietf-network-slice-service:service", "tag-type-value", 0]) or "L2" - logging.debug(f"Send slice to delete in TFS with slice_type {slice_type}") - services = get_data_by_slice_id(slice.get("id")) - if services: - for service in services: - id = service.get("service_id") - tfs_connector().nbi_delete(current_app.config["RESTCONF_IP"], slice_type, id) - delete_by_slice_id(slice.get("id")) - if current_app.config["TFS_L2VPN_SUPPORT"]: - tfs_l2vpn_delete() - delete_data_store(xpath) - logging.info("All slices removed successfully") - return {}, 204 - - - except ValueError as e: - return send_response(False, code=404, message=str(e)) - except Exception as e: - return send_response(False, code=500, message=str(e)) - - def delete_sdps(self, slice_id, sdp_id=None): - try: - # Delete specific SDP if sdp_id is provided - if sdp_id: - xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']" - existing_slice = get_data_store(xpath) - if not existing_slice: - raise ValueError("Slice not found") - xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']/sdps/sdp[id='{sdp_id}']" - existing_sdp = get_data_store(xpath) - if not existing_sdp: - raise ValueError("SDP not found") - delete_data_store(xpath) - logging.info(f"SDP {sdp_id} removed successfully") - return {}, 204 - - # Delete all SDPs - else: - xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']" - existing_slice = get_data_store(xpath) - if not existing_slice: - raise ValueError("Slice not found") - xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']/sdps" - delete_data_store(xpath) - logging.info("All SDPs removed successfully") - return {}, 204 - - except ValueError as e: - return send_response(False, code=404, message=str(e)) - except Exception as e: - return send_response(False, code=500, message=str(e)) - - # --- CLIENTS --- - - def get_clients(self, client_id=None): - try: - if client_id: - return get_client(client_id), 200 - clients = get_all_clients() - if not clients: - raise ValueError("No clients found") - return clients, 200 - - except ValueError as e: - return send_response(False, code=404, message=str(e)) - except Exception as e: - return send_response(False, code=500, message=str(e)) - - def add_client(self, client_id): - try: - create_client(client_id) - logging.info(f"Client '{client_id}' created successfully") - return send_response( - True, - code=201, - message=f"Client '{client_id}' created successfully", - data={"client_id": client_id} - ) - - except ValueError as e: - return send_response(False, code=409, message=str(e)) - except Exception as e: - return send_response(False, code=500, message=str(e)) - - def delete_clients(self, client_id=None): - try: - if client_id: - delete_client(client_id) - logging.info(f"Client '{client_id}' removed successfully") - else: - delete_all_clients() - logging.info("All clients removed successfully") - return {}, 204 - except ValueError as e: - return send_response(False, code=404, message=str(e)) - except Exception as e: - return send_response(False, code=500, message=str(e)) - - # --- SUBSCRIPTIONS --- - - def get_subscriptions(self, client_id, slice_id = None): - try: - if slice_id is not None: - try: - subscription = get_subscription(client_id, slice_id) - except ValueError: - subscription = None - if not subscription: - raise ValueError(f"Client '{client_id}' has no subscription for slice '{slice_id}'") - - telemetry = self.get_telemetry(slice_id) - - return { - **subscription, - "telemetry": telemetry - }, 200 - - subscriptions = get_client_subscriptions(client_id) - - result = [] - - for sub in subscriptions: - slice_id = sub["slice_id"] - - telemetry = self.get_telemetry(slice_id) - - result.append({ - **sub, - "telemetry": telemetry - }) - - return { - "client_id": client_id, - "subscriptions": result - }, 200 - - except ValueError as e: - return send_response(False, code=404, message=str(e)) - except Exception as e: - return send_response(False, code=500, message=str(e)) - - def add_subscription(self, client_id, slice_id, frequency): - try: - try: - subscription = get_subscription(client_id, slice_id) - except ValueError: - subscription = None - if subscription: - raise ValueError(f"Client '{client_id}' already has a subscription for slice '{slice_id}'") - if not frequency: - raise KeyError("Field 'frequency' is required") - - upsert_subscription(client_id, slice_id, frequency) - logging.info(f"Subscription for slice '{slice_id}' and client '{client_id}' created successfully") - return send_response( - True, - code=201, - message="Subscription successfully created", - data={ - "sliceId": slice_id, - "frequency": frequency - } - ) - except KeyError as e: - return send_response(False, code=400, message=str(e)) - except ValueError as e: - return send_response(False, code=404, message=str(e)) - except Exception as e: - return send_response(False, code=500, message=str(e)) - - def update_subscription(self, client_id, slice_id, frequency): - try: - try: - subscription = get_subscription(client_id, slice_id) - except ValueError: - subscription = None - if not subscription: - raise ValueError(f"Client '{client_id}' has no subscription for slice '{slice_id}'") - if not frequency: - raise KeyError("Field 'frequency' is required") - - upsert_subscription(client_id, slice_id, frequency) - logging.info(f"Subscription for slice '{slice_id}' and client '{client_id}' modified successfully") - return send_response( - True, - code=201, - message="Subscription successfully modified", - data={ - "sliceId": slice_id, - "frequency": frequency - } - ) - except KeyError as e: - return send_response(False, code=400, message=str(e)) - except ValueError as e: - return send_response(False, code=404, message=str(e)) - except Exception as e: - return send_response(False, code=500, message=str(e)) - - def delete_subscriptions(self, client_id, slice_id = None): - try: - subscriptions = get_client_subscriptions(client_id) - if slice_id: - if slice_id not in subscriptions: - raise ValueError(f"Client '{client_id}' has no subscription for slice '{slice_id}'") - delete_subscription(client_id, slice_id) - logging.info(f"Subscription for slice '{slice_id}' and client '{client_id}' removed successfully") - return {}, 204 - delete_all_subscriptions(client_id) - logging.info(f"All subscriptions for client '{client_id}' removed successfully") - return {}, 204 - - except ValueError as e: - return send_response(False, code=404, message=str(e)) - except Exception as e: - return send_response(False, code=500, message=str(e)) - - # --- TELEMETRY --- - - def get_telemetry(self, slice_id = None): - logging.debug(f"Getting telemetry for slice_id: {slice_id}") - try: - if slice_id is not None: - xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']" - existing_slice = get_data_store(xpath) - if not existing_slice: - raise ValueError(f"There is no slice with id '{slice_id}' registered") - template_id = safe_get(existing_slice, ["network-slice-services", "slice-service", slice_id, "slo-sle-template"]) - slo_sle_template = self.get_slo_sle_templates(template_id)[0] - slo_sle_template = safe_get(slo_sle_template, ["network-slice-services", "slo-sle-templates", "slo-sle-template"]) - slo_sle_template = next(iter(slo_sle_template), None) - if not slo_sle_template: - raise ValueError(f"SLO/SLE template '{template_id}' not found for slice '{slice_id}'") - metrics = self.slice_service.monitoring(slice_id, slo_sle_template) - return metrics, 200 - - telemetry_data = {} - slices_data = self.get_slice_services()[0] - slice_service_list = slices_data["network-slice-services"]["slice-service"] - if isinstance(slice_service_list, dict): - slice_service_list = list(slice_service_list.values()) - - for slice in slice_service_list: - selected_template_id = slice.get("slo-sle-template") - slo_sle_template = self.get_slo_sle_templates(selected_template_id)[0] - slo_sle_template = safe_get(slo_sle_template, ["network-slice-services", "slo-sle-templates", "slo-sle-template"]) - slo_sle_template = next(iter(slo_sle_template), None) - if not slo_sle_template: - raise ValueError(f"SLO/SLE template '{selected_template_id}' not found for slice '{slice['id']}'") - slice_id = slice["id"] - telemetry_data[slice_id] = self.slice_service.monitoring(slice_id, slo_sle_template) - return telemetry_data, 200 - - except ValueError as e: - return send_response(False, code=404, message=str(e)) - except Exception as e: - return send_response(False, code=500, message=str(e)) - - def sync_stream(self, async_gen_func, *args, **kwargs): - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - agen = async_gen_func(*args, **kwargs) - - try: - while True: yield loop.run_until_complete(agen.__anext__()) - except StopAsyncIteration: pass - finally: loop.close() - - async def stream_client_subscriptions(self, client_id): - while True: - try: - data, code = self.get_subscriptions(client_id) - if code == 200: - yield f"data: {json.dumps(data)}\n\n" - subs = data.get("subscriptions", []) - freq = max([s["frequency"] for s in subs]) if subs else 5 - await asyncio.sleep(freq) - else: - yield f"event: error\ndata: {json.dumps(data)}\n\n" - break - except Exception as e: - yield f"event: error\ndata: {json.dumps({'error': str(e)})}\n\n" - break - - async def stream_slice_subscription(self, client_id, slice_id): - while True: - try: - data, code = self.get_subscriptions(client_id, slice_id) - if code == 200: - yield f"data: {json.dumps(data)}\n\n" - freq = data.get("frequency", 5) - await asyncio.sleep(freq) - else: - yield f"event: error\ndata: {json.dumps(data)}\n\n" - break - except Exception as e: - yield f"event: error\ndata: {json.dumps({'error': str(e)})}\n\n" - break - - def reconfig_slice(self, slice_id): - """ - Trigger slice reconfiguration via Change Scheduler Planner. - - Args: - slice_id (str): The identifier of the slice to reconfigure. - - Returns: - Tuple[dict, int]: Response payload and HTTP status code. - """ - try: - result = self.slice_service.reconfig_slice(slice_id) - return send_response(True, code=200, data=result) - except ValueError as e: - return send_response(False, code=404, message=str(e)) - except Exception as e: - return send_response(False, code=500, message=str(e)) - \ No newline at end of file +# Copyright 2022-2026 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. + +"""Network Slice Controller REST and RESTCONF API facade and module exports.""" + +from __future__ import annotations + +from typing import Any + +from src.api.base_handler import ( + BaseSliceHandler, + _delete_slice_from_tfs, + _extract_slice_type, +) +from src.api.e2e_handler import ( + E2EHandler, + _find_slice_for_alert, + _load_fallback_intent, + _lookup_slice_info, + _parse_alert_notification, + _resolve_slice_id, + _swap_p2mp_endpoints, +) +from src.api.ixia_handler import IxiaHandler +from src.api.restconf_handler import ( + RestconfHandler, + _build_full_slice_intent, +) +from src.api.tfs_handler import TfsHandler +from src.database import alert_db +from src.database.db import ( + delete_all_data, + delete_data, + get_all_data, + get_data, + get_slice_id_by_subscription, +) +from src.database.service_db import ( + delete_by_slice_id, + get_data_by_slice_id, +) +from src.database.service_db import ( + get_data as get_service_db_data, +) +from src.database.sysrepo_store import ( + create_data_store, + delete_data_store, + get_data_store, + normalize_libyang_data, + update_data_store, +) +from src.database.telemetry_client_db import ( + create_client, + delete_all_clients, + delete_all_subscriptions, + delete_client, + delete_subscription, + get_all_clients, + get_client, + get_client_subscriptions, + get_subscription, + upsert_subscription, +) +from src.realizer.restconf.connectors.tfs_connector import ( + tfs_connector as tfs_restconf_connector, +) +from src.realizer.tfs.helpers.tfs_connector import tfs_connector +from src.realizer.tfs.service_types.tfs_l2vpn import tfs_l2vpn_delete +from src.utils.safe_get import safe_get +from src.utils.send_response import send_response + +# Public alias exports for backwards compatibility +parse_alert_notification = _parse_alert_notification +find_slice_for_alert = _find_slice_for_alert +swap_p2mp_endpoints = _swap_p2mp_endpoints + + +class Api(E2EHandler, TfsHandler, IxiaHandler, RestconfHandler): + """Unified Facade API handler combining E2E, TFS, IXIA, and RESTCONF operations.""" + + def __init__(self, slice_service: Any) -> None: + """Initialize API facade with underlying slice service. + + Args: + slice_service: Service instance managing controller-specific business logic. + """ + super().__init__(slice_service) + + +__all__ = [ + "Api", + "BaseSliceHandler", + "E2EHandler", + "IxiaHandler", + "RestconfHandler", + "TfsHandler", + "_build_full_slice_intent", + "_delete_slice_from_tfs", + "_extract_slice_type", + "_find_slice_for_alert", + "_load_fallback_intent", + "_lookup_slice_info", + "_parse_alert_notification", + "_resolve_slice_id", + "_swap_p2mp_endpoints", + "alert_db", + "create_client", + "create_data_store", + "delete_all_clients", + "delete_all_data", + "delete_all_subscriptions", + "delete_by_slice_id", + "delete_client", + "delete_data", + "delete_data_store", + "delete_subscription", + "find_slice_for_alert", + "get_all_clients", + "get_all_data", + "get_client", + "get_client_subscriptions", + "get_data", + "get_data_by_slice_id", + "get_data_store", + "get_service_db_data", + "get_slice_id_by_subscription", + "get_subscription", + "normalize_libyang_data", + "parse_alert_notification", + "safe_get", + "send_response", + "swap_p2mp_endpoints", + "tfs_connector", + "tfs_l2vpn_delete", + "tfs_restconf_connector", + "update_data_store", + "upsert_subscription", +] diff --git a/src/api/restconf_handler.py b/src/api/restconf_handler.py new file mode 100644 index 0000000..6de678a --- /dev/null +++ b/src/api/restconf_handler.py @@ -0,0 +1,999 @@ +# Copyright 2022-2026 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. + +"""RESTCONF API service handler for IETF Network Slice Service datastore, telemetry, and reconfiguration.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import sys +from collections.abc import AsyncGenerator, Callable, Generator +from typing import Any + +from flask import current_app + +from src.database.service_db import ( + delete_by_slice_id as _db_delete_by_slice_id, +) +from src.database.service_db import ( + get_data_by_slice_id as _db_get_data_by_slice_id, +) +from src.database.sysrepo_store import ( + create_data_store as _real_create_data_store, +) +from src.database.sysrepo_store import ( + delete_data_store as _real_delete_data_store, +) +from src.database.sysrepo_store import ( + get_data_store as _real_get_data_store, +) +from src.database.sysrepo_store import ( + normalize_libyang_data as _real_normalize_libyang_data, +) +from src.database.sysrepo_store import ( + update_data_store as _real_update_data_store, +) +from src.database.telemetry_client_db import ( + create_client as _real_create_client, +) +from src.database.telemetry_client_db import ( + delete_all_clients as _real_delete_all_clients, +) +from src.database.telemetry_client_db import ( + delete_all_subscriptions as _real_delete_all_subscriptions, +) +from src.database.telemetry_client_db import ( + delete_client as _real_delete_client, +) +from src.database.telemetry_client_db import ( + delete_subscription as _real_delete_subscription, +) +from src.database.telemetry_client_db import ( + get_all_clients as _real_get_all_clients, +) +from src.database.telemetry_client_db import ( + get_client as _real_get_client, +) +from src.database.telemetry_client_db import ( + get_client_subscriptions as _real_get_client_subscriptions, +) +from src.database.telemetry_client_db import ( + get_subscription as _real_get_subscription, +) +from src.database.telemetry_client_db import ( + upsert_subscription as _real_upsert_subscription, +) +from src.realizer.restconf.connectors.tfs_connector import ( + tfs_connector as _real_tfs_restconf_connector, +) +from src.realizer.tfs.helpers.tfs_connector import tfs_connector as _real_tfs_connector +from src.realizer.tfs.service_types.tfs_l2vpn import ( + tfs_l2vpn_delete as _real_tfs_l2vpn_delete, +) +from src.utils.safe_get import safe_get +from src.utils.send_response import send_response + +logger = logging.getLogger(__name__) + + +def _dep(name: str, fallback: Any) -> Any: + """Resolve dependency from src.api.main if patched, otherwise fallback.""" + main_mod = sys.modules.get("src.api.main") + if main_mod is not None and hasattr(main_mod, name): + return getattr(main_mod, name) + return fallback + + +def _build_full_slice_intent( + intent: dict[str, Any], +) -> tuple[dict[str, Any] | None, tuple[dict[str, Any], int] | None]: + """Construct full IETF network slice intent from template reference or inline policy.""" + get_data_store_fn = _dep("get_data_store", _real_get_data_store) + normalize_fn = _dep("normalize_libyang_data", _real_normalize_libyang_data) + + if "slo-sle-template" in intent: + template_ref = intent.get("slo-sle-template") + xpath_template = f"/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template[id='{template_ref}']" + existing_template = get_data_store_fn(xpath_template) + if not existing_template: + return None, send_response(False, code=404, message="Referenced SLO/SLE template not found") + + template_body = existing_template["network-slice-services"]["slo-sle-templates"]["slo-sle-template"][ + template_ref + ] + full_intent = { + "ietf-network-slice-service:network-slice-services": { + "slo-sle-templates": {"slo-sle-template": [template_body]}, + "slice-service": [intent], + } + } + elif "service-slo-sle-policy" in intent: + full_intent = { + "ietf-network-slice-service:network-slice-services": { + "slo-sle-templates": {"slo-sle-template": intent.get("service-slo-sle-policy")}, + "slice-service": [intent], + } + } + else: + return None, send_response(False, code=400, message="No SLO/SLE template or policy provided in intent") + + return normalize_fn(full_intent), None + + +class RestconfHandler: + """API handler dedicated to RESTCONF IETF Network Slice Service operations.""" + + def __init__(self, slice_service: Any) -> None: + """Initialize RESTCONF handler with underlying slice service (e.g. NSController). + + Args: + slice_service: Service instance managing controller-specific business logic. + """ + self.slice_service = slice_service + + # ------------------------------------------------------------------------- + # RESTCONF Data: Network Slice Services Root + # ------------------------------------------------------------------------- + + def get_network_slice_services(self) -> tuple[dict[str, Any], int]: + """Retrieve root network slice services container from datastore.""" + try: + get_ds_fn = _dep("get_data_store", _real_get_data_store) + data = get_ds_fn("/ietf-network-slice-service:network-slice-services") + if not data: + raise ValueError("Nothing found") + return data, 200 + except ValueError as exc: + return send_response(False, code=404, message=str(exc)) + except Exception as exc: + return send_response(False, code=500, message=str(exc)) + + def add_network_slice_service(self, intent: dict[str, Any]) -> tuple[dict[str, Any], int]: + """Create network slice services in datastore and realize via controller.""" + try: + result = self.slice_service.nsc(intent) + if isinstance(result, tuple): + return result + if result: + try: + create_ds_fn = _dep("create_data_store", _real_create_data_store) + create_ds_fn(intent) + except Exception as ds_err: + logger.warning("Could not store intent in sysrepo datastore: %s", ds_err) + logger.info("Network Slice created successfully") + return send_response( + True, + code=201, + message="Network Slice created successfully", + data=result, + ) + except RuntimeError as exc: + return send_response(False, code=200, message=str(exc)) + except Exception as exc: + return send_response(False, code=500, message=str(exc)) + + def update_network_slice_service(self, intent: dict[str, Any]) -> tuple[dict[str, Any], int]: + """Modify (replace) all network-slice-services configuration.""" + try: + xpath = "/ietf-network-slice-service:network-slice-services" + get_ds_fn = _dep("get_data_store", _real_get_data_store) + existing_data = get_ds_fn(xpath) + if not existing_data: + return send_response(False, code=404, message="Network slice services not found") + + result = self.slice_service.nsc(intent) + if not result: + return send_response(False, code=500, message="Failed to process slice in TFS") + + update_ds_fn = _dep("update_data_store", _real_update_data_store) + update_ds_fn(intent) + logger.info("Network slice services modified successfully") + return send_response( + True, + code=200, + message="Network slice services updated successfully", + data=result, + ) + except ValueError as exc: + return send_response(False, code=404, message=str(exc)) + except Exception as exc: + return send_response(False, code=500, message=str(exc)) + + def delete_network_slice_services(self) -> tuple[dict[str, Any], int]: + """Delete all network-slice-services from datastore and controller.""" + try: + xpath = "/ietf-network-slice-service:network-slice-services" + get_ds_fn = _dep("get_data_store", _real_get_data_store) + if not current_app.config["DUMMY_MODE"]: + content = get_ds_fn(xpath) + slice_services = safe_get(content, ["network-slice-services", "slice-service"]) + if not slice_services: + raise ValueError("Network slice services not found") + + for slice_item in slice_services: + slice_type_val = safe_get( + slice_item, + ["service-tags", "tag-type", "ietf-network-slice-service:service", "tag-type-value"], + ) + slice_type = list(slice_type_val)[0] if slice_type_val else "L2" + logger.debug("Send slice to delete in TFS with slice_type %s", slice_type) + + try: + get_svc_by_slice = _dep("get_data_by_slice_id", _db_get_data_by_slice_id) + del_svc_by_slice = _dep("delete_by_slice_id", _db_delete_by_slice_id) + services = get_svc_by_slice(slice_item.get("id")) + if services: + for service in services: + svc_id = service.get("service_id") + tfs_conn = _dep("tfs_connector", _real_tfs_connector)() + tfs_conn.nbi_delete(current_app.config["RESTCONF_IP"], slice_type, svc_id) + del_svc_by_slice(slice_item.get("id")) + except Exception as exc: + logger.debug("No service_db entries to delete for slice %s: %s", slice_item.get("id"), exc) + + if current_app.config["TFS_L2VPN_SUPPORT"]: + if hasattr(self.slice_service, "tfs_l2vpn_delete"): + self.slice_service.tfs_l2vpn_delete() + else: + l2vpn_del = _dep("tfs_l2vpn_delete", _real_tfs_l2vpn_delete) + l2vpn_del() + + delete_ds_fn = _dep("delete_data_store", _real_delete_data_store) + delete_ds_fn(xpath) + logger.info("All slices removed successfully") + return {}, 204 + except ValueError as exc: + return send_response(False, code=404, message=str(exc)) + except Exception as exc: + return send_response(False, code=500, message=str(exc)) + + # ------------------------------------------------------------------------- + # RESTCONF Data: SLO/SLE Templates + # ------------------------------------------------------------------------- + + def get_slo_sle_templates(self, template_id: str | None = None) -> tuple[dict[str, Any], int]: + """Retrieve one or all SLO/SLE templates from datastore.""" + try: + get_ds_fn = _dep("get_data_store", _real_get_data_store) + if template_id: + xpath = f"/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template[id='{template_id}']" + data = get_ds_fn(xpath) + if not data: + raise ValueError("Template not found") + return data, 200 + + data = get_ds_fn("/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template") + if not data: + raise ValueError("No templates found") + return data, 200 + except ValueError as exc: + return send_response(False, code=404, message=str(exc)) + except Exception as exc: + return send_response(False, code=500, message=str(exc)) + + def add_slo_sle_template(self, template: dict[str, Any]) -> tuple[dict[str, Any], int]: + """Create a new SLO/SLE template in the datastore.""" + try: + template_id = template.pop("id", None) + xpath = f"/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template[id='{template_id}']" + get_ds_fn = _dep("get_data_store", _real_get_data_store) + existing_template = get_ds_fn(xpath) + if existing_template: + return send_response(False, code=409, message="Template already exists") + + create_ds_fn = _dep("create_data_store", _real_create_data_store) + create_ds_fn(template, xpath) + logger.info("Template created successfully") + return send_response(True, code=201, message="Template created successfully") + except Exception as exc: + return send_response(False, code=500, message=str(exc)) + + def update_slo_sle_template(self, template_id: str, template: dict[str, Any]) -> tuple[dict[str, Any], int]: + """Modify (replace) a specific SLO/SLE template.""" + try: + xpath = f"/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template[id='{template_id}']" + get_ds_fn = _dep("get_data_store", _real_get_data_store) + existing_template = get_ds_fn(xpath) + if not existing_template: + return send_response(False, code=404, message="Template not found") + + if "id" in template and template["id"] != template_id: + return send_response(False, code=400, message="Template ID in body does not match URL") + + slices = get_ds_fn("/ietf-network-slice-service:network-slice-services/slice-service") + result = None + if slices and "network-slice-services" in slices: + slice_list = slices["network-slice-services"].get("slice-service", []) + for slice_item in slice_list: + if slice_item.get("slo-sle-template") == template_id: + full_intent = { + "ietf-network-slice-service:network-slice-services": { + "slo-sle-templates": { + "slo-sle-template": [ + existing_template["network-slice-services"]["slo-sle-templates"][ + "slo-sle-template" + ][template_id] + ] + }, + "slice-service": [slice_item], + } + } + normalize_fn = _dep("normalize_libyang_data", _real_normalize_libyang_data) + normalized_intent = normalize_fn(full_intent) + result = self.slice_service.nsc(normalized_intent, slice_item.get("id")) + if not result: + return send_response(False, code=500, message="Slice not updated") + + template_data = template.copy() + template_data.pop("id", None) + update_ds_fn = _dep("update_data_store", _real_update_data_store) + update_ds_fn(template_data, xpath) + logger.info("Template %s modified successfully", template_id) + + return send_response( + True, + code=200, + message="Template updated successfully", + data=result, + ) + except ValueError as exc: + return send_response(False, code=404, message=str(exc)) + except Exception as exc: + return send_response(False, code=500, message=str(exc)) + + def delete_slo_sle_templates(self, template_id: str | None = None) -> tuple[dict[str, Any], int]: + """Delete specific or all SLO/SLE templates.""" + try: + get_ds_fn = _dep("get_data_store", _real_get_data_store) + delete_ds_fn = _dep("delete_data_store", _real_delete_data_store) + if template_id: + xpath = f"/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template[id='{template_id}']" + existing_template = get_ds_fn(xpath) + if not existing_template: + raise ValueError("Template not found") + delete_ds_fn(xpath) + logger.info("Template %s removed successfully", template_id) + return {}, 204 + + xpath = "/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template" + delete_ds_fn(xpath) + logger.info("All templates removed successfully") + return {}, 204 + except ValueError as exc: + return send_response(False, code=404, message=str(exc)) + except Exception as exc: + return send_response(False, code=500, message=str(exc)) + + # ------------------------------------------------------------------------- + # RESTCONF Data: Slice Services + # ------------------------------------------------------------------------- + + def get_slice_services(self, slice_id: str | None = None) -> tuple[dict[str, Any], int]: + """Retrieve specific or all slice services from datastore.""" + try: + get_ds_fn = _dep("get_data_store", _real_get_data_store) + if slice_id: + xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']" + data = get_ds_fn(xpath) + if not data: + raise ValueError("Slice not found") + return data, 200 + + data = get_ds_fn("/ietf-network-slice-service:network-slice-services/slice-service") + if not data: + raise ValueError("No slices found") + return data, 200 + except ValueError as exc: + return send_response(False, code=404, message=str(exc)) + except Exception as exc: + return send_response(False, code=500, message=str(exc)) + + def add_slice_service(self, intent: dict[str, Any]) -> tuple[dict[str, Any], int]: + """Add a new slice service to the datastore.""" + try: + slice_id = intent.get("id") + xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']" + get_ds_fn = _dep("get_data_store", _real_get_data_store) + existing_slice = get_ds_fn(xpath) + if existing_slice: + return send_response(False, code=409, message="Slice already exists") + + full_intent, err_resp = _build_full_slice_intent(intent) + if err_resp is not None: + return err_resp + + result = self.slice_service.nsc(full_intent) + if result: + intent_copy = intent.copy() + intent_copy.pop("id", None) + create_ds_fn = _dep("create_data_store", _real_create_data_store) + create_ds_fn(intent_copy, xpath) + logger.info("Slice created successfully") + + return send_response( + True, + code=201, + message="Slice created successfully", + data=result, + ) + except RuntimeError as exc: + return send_response(False, code=200, message=str(exc)) + except Exception as exc: + return send_response(False, code=500, message=str(exc)) + + def update_slice_service(self, slice_id: str, intent: dict[str, Any]) -> tuple[dict[str, Any], int]: + """Modify (replace) a specific slice service.""" + try: + xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']" + get_ds_fn = _dep("get_data_store", _real_get_data_store) + existing_slice = get_ds_fn(xpath) + if not existing_slice: + return send_response(False, code=404, message="Slice not found") + + if "id" in intent and intent["id"] != slice_id: + return send_response(False, code=400, message="Slice ID in body does not match URL") + + full_intent, err_resp = _build_full_slice_intent(intent) + if err_resp is not None: + return err_resp + + result = self.slice_service.nsc(full_intent) + if not result: + return send_response(False, code=500, message="Slice not updated") + + intent_data = intent.copy() + intent_data.pop("id", None) + update_ds_fn = _dep("update_data_store", _real_update_data_store) + update_ds_fn(intent_data, xpath) + logger.info("Slice %s modified successfully", slice_id) + + return send_response( + True, + code=200, + message="Slice updated successfully", + data=result, + ) + except ValueError as exc: + return send_response(False, code=404, message=str(exc)) + except RuntimeError as exc: + return send_response(False, code=200, message=str(exc)) + except Exception as exc: + return send_response(False, code=500, message=str(exc)) + + def delete_slice_services(self, slice_id: str | None = None) -> tuple[dict[str, Any], int]: + """Delete specific or all slice services.""" + try: + if slice_id: + return self._delete_single_slice_service(slice_id) + return self._delete_all_slice_services() + except ValueError as exc: + return send_response(False, code=404, message=str(exc)) + except Exception as exc: + return send_response(False, code=500, message=str(exc)) + + def _delete_single_slice_service(self, slice_id: str) -> tuple[dict[str, Any], int]: + """Delete a single slice service by slice_id.""" + xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']" + get_ds_fn = _dep("get_data_store", _real_get_data_store) + existing_slice = get_ds_fn(xpath) + if not existing_slice: + raise ValueError("Slice not found") + + if not current_app.config["DUMMY_MODE"]: + slice_type = ( + safe_get( + existing_slice, + [ + "network-slice-services", + "slice-service", + slice_id, + "service-tags", + "tag-type", + "ietf-network-slice-service:service", + "tag-type-value", + 0, + ], + ) + or "L2" + ) + logger.debug("Send slice to delete in TFS with slice_type %s", slice_type) + try: + get_svc_by_slice = _dep("get_data_by_slice_id", _db_get_data_by_slice_id) + del_svc_by_slice = _dep("delete_by_slice_id", _db_delete_by_slice_id) + services = get_svc_by_slice(slice_id) + if services: + for service in services: + svc_id = service.get("service_id") + tfs_conn = _dep("tfs_connector", _real_tfs_connector)() + tfs_conn.nbi_delete(current_app.config["RESTCONF_IP"], slice_type, svc_id) + del_svc_by_slice(slice_id) + except Exception as exc: + logger.debug("No service_db entries for slice %s: %s", slice_id, exc) + + if current_app.config["TFS_L2VPN_SUPPORT"]: + if hasattr(self.slice_service, "tfs_l2vpn_delete"): + self.slice_service.tfs_l2vpn_delete() + else: + l2vpn_del = _dep("tfs_l2vpn_delete", _real_tfs_l2vpn_delete) + l2vpn_del() + + delete_ds_fn = _dep("delete_data_store", _real_delete_data_store) + delete_ds_fn(xpath) + logger.info("Slice %s removed successfully", slice_id) + return {}, 204 + + def _delete_all_slice_services(self) -> tuple[dict[str, Any], int]: + """Delete all slice services from datastore and controller.""" + xpath = "/ietf-network-slice-service:network-slice-services/slice-service" + get_ds_fn = _dep("get_data_store", _real_get_data_store) + if not current_app.config["DUMMY_MODE"]: + content = get_ds_fn(xpath) + slice_services = safe_get(content, ["network-slice-services", "slice-service"]) + if not slice_services: + raise ValueError("Slice services not found") + + for slice_item in slice_services: + slice_type = ( + safe_get( + slice_item, + ["service-tags", "tag-type", "ietf-network-slice-service:service", "tag-type-value", 0], + ) + or "L2" + ) + logger.debug("Send slice to delete in TFS with slice_type %s", slice_type) + try: + get_svc_by_slice = _dep("get_data_by_slice_id", _db_get_data_by_slice_id) + del_svc_by_slice = _dep("delete_by_slice_id", _db_delete_by_slice_id) + services = get_svc_by_slice(slice_item.get("id")) + if services: + for service in services: + svc_id = service.get("service_id") + tfs_conn = _dep("tfs_connector", _real_tfs_connector)() + tfs_conn.nbi_delete(current_app.config["RESTCONF_IP"], slice_type, svc_id) + del_svc_by_slice(slice_item.get("id")) + except Exception as exc: + logger.debug("No service_db entries for slice %s: %s", slice_item.get("id"), exc) + + if current_app.config["TFS_L2VPN_SUPPORT"]: + if hasattr(self.slice_service, "tfs_l2vpn_delete"): + self.slice_service.tfs_l2vpn_delete() + else: + l2vpn_del = _dep("tfs_l2vpn_delete", _real_tfs_l2vpn_delete) + l2vpn_del() + + delete_ds_fn = _dep("delete_data_store", _real_delete_data_store) + delete_ds_fn(xpath) + logger.info("All slices removed successfully") + return {}, 204 + + # ------------------------------------------------------------------------- + # RESTCONF Data: SDPs + # ------------------------------------------------------------------------- + + def get_sdps(self, slice_id: str, sdp_id: str | None = None) -> tuple[dict[str, Any], int]: + """Retrieve SDP(s) for a given slice.""" + try: + get_ds_fn = _dep("get_data_store", _real_get_data_store) + if sdp_id: + xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']/sdps/sdp[id='{sdp_id}']" + data = get_ds_fn(xpath) + if not data: + raise ValueError("SDP not found") + return data, 200 + + xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']/sdps" + data = get_ds_fn(xpath) + if not data: + raise ValueError("No SDPs found") + return data, 200 + except ValueError as exc: + return send_response(False, code=404, message=str(exc)) + except Exception as exc: + return send_response(False, code=500, message=str(exc)) + + def add_sdp(self, slice_id: str, sdp: dict[str, Any]) -> tuple[dict[str, Any], int]: + """Add an SDP to a slice service.""" + try: + sdp_id = sdp.pop("id", None) + xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']/sdps/sdp[id='{sdp_id}']" + get_ds_fn = _dep("get_data_store", _real_get_data_store) + existing_sdp = get_ds_fn(xpath) + if existing_sdp: + return send_response(False, code=409, message="SDP already exists") + + create_ds_fn = _dep("create_data_store", _real_create_data_store) + create_ds_fn(sdp, xpath) + logger.info("SDP created successfully") + return send_response(True, code=201, message="SDP created successfully") + except Exception as exc: + return send_response(False, code=500, message=str(exc)) + + def update_sdp(self, slice_id: str, sdp_id: str, sdp: dict[str, Any]) -> tuple[dict[str, Any], int]: + """Modify (replace) a specific SDP in the slice.""" + try: + get_ds_fn = _dep("get_data_store", _real_get_data_store) + slice_xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']" + if not get_ds_fn(slice_xpath): + return send_response(False, code=404, message="Slice not found") + + sdp_xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']/sdps/sdp[id='{sdp_id}']" + if not get_ds_fn(sdp_xpath): + return send_response(False, code=404, message="SDP not found") + + if "id" in sdp and sdp["id"] != sdp_id: + return send_response(False, code=400, message="SDP ID in body does not match URL") + + sdp_data = sdp.copy() + sdp_data.pop("id", None) + update_ds_fn = _dep("update_data_store", _real_update_data_store) + update_ds_fn(sdp_data, sdp_xpath) + logger.info("SDP %s in slice %s modified successfully", sdp_id, slice_id) + + return send_response(True, code=200, message="SDP updated successfully") + except ValueError as exc: + return send_response(False, code=404, message=str(exc)) + except Exception as exc: + return send_response(False, code=500, message=str(exc)) + + def delete_sdps(self, slice_id: str, sdp_id: str | None = None) -> tuple[dict[str, Any], int]: + """Delete specific or all SDPs for a slice.""" + try: + get_ds_fn = _dep("get_data_store", _real_get_data_store) + delete_ds_fn = _dep("delete_data_store", _real_delete_data_store) + + slice_xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']" + if not get_ds_fn(slice_xpath): + raise ValueError("Slice not found") + + if sdp_id: + sdp_xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']/sdps/sdp[id='{sdp_id}']" + if not get_ds_fn(sdp_xpath): + raise ValueError("SDP not found") + delete_ds_fn(sdp_xpath) + logger.info("SDP %s removed successfully", sdp_id) + return {}, 204 + + all_sdps_xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']/sdps" + delete_ds_fn(all_sdps_xpath) + logger.info("All SDPs removed successfully") + return {}, 204 + except ValueError as exc: + return send_response(False, code=404, message=str(exc)) + except Exception as exc: + return send_response(False, code=500, message=str(exc)) + + # ------------------------------------------------------------------------- + # RESTCONF Data: Topology + # ------------------------------------------------------------------------- + + def get_slice_topology(self, slice_id: str) -> tuple[dict[str, Any], int]: + """Retrieve Network Slice Topology for a given slice_id.""" + try: + tfs_ip = current_app.config.get("RESTCONF_IP", "127.0.0.1") + conn_fn = _dep("tfs_restconf_connector", _real_tfs_restconf_connector) + connector = conn_fn() + return connector.get_slice_topology(tfs_ip, slice_id) + except Exception as exc: + logger.exception("Error retrieving slice topology for slice '%s'", slice_id) + return send_response(False, code=500, message=str(exc)) + + # ------------------------------------------------------------------------- + # RESTCONF Telemetry Operations: Clients + # ------------------------------------------------------------------------- + + def get_clients(self, client_id: str | None = None) -> tuple[dict[str, Any] | list[dict[str, Any]], int]: + """Retrieve one or all registered telemetry clients.""" + try: + if client_id: + get_cli_fn = _dep("get_client", _real_get_client) + return get_cli_fn(client_id), 200 + get_all_cli_fn = _dep("get_all_clients", _real_get_all_clients) + clients = get_all_cli_fn() + if not clients: + raise ValueError("No clients found") + return clients, 200 + except ValueError as exc: + return send_response(False, code=404, message=str(exc)) + except Exception as exc: + return send_response(False, code=500, message=str(exc)) + + def add_client(self, client_id: str) -> tuple[dict[str, Any], int]: + """Register a new telemetry client.""" + try: + create_cli_fn = _dep("create_client", _real_create_client) + create_cli_fn(client_id) + logger.info("Client '%s' created successfully", client_id) + return send_response( + True, + code=201, + message=f"Client '{client_id}' created successfully", + data={"client_id": client_id}, + ) + except ValueError as exc: + return send_response(False, code=409, message=str(exc)) + except Exception as exc: + return send_response(False, code=500, message=str(exc)) + + def delete_clients(self, client_id: str | None = None) -> tuple[dict[str, Any], int]: + """Delete one or all telemetry clients.""" + try: + if client_id: + del_cli_fn = _dep("delete_client", _real_delete_client) + del_cli_fn(client_id) + logger.info("Client '%s' removed successfully", client_id) + else: + del_all_cli_fn = _dep("delete_all_clients", _real_delete_all_clients) + del_all_cli_fn() + logger.info("All clients removed successfully") + return {}, 204 + except ValueError as exc: + return send_response(False, code=404, message=str(exc)) + except Exception as exc: + return send_response(False, code=500, message=str(exc)) + + # ------------------------------------------------------------------------- + # RESTCONF Telemetry Operations: Subscriptions + # ------------------------------------------------------------------------- + + def get_subscriptions(self, client_id: str, slice_id: str | None = None) -> tuple[dict[str, Any], int]: + """Retrieve telemetry subscriptions for a client.""" + try: + if slice_id is not None: + return self._get_single_client_subscription(client_id, slice_id) + return self._get_all_client_subscriptions(client_id) + except ValueError as exc: + return send_response(False, code=404, message=str(exc)) + except Exception as exc: + return send_response(False, code=500, message=str(exc)) + + def _get_single_client_subscription(self, client_id: str, slice_id: str) -> tuple[dict[str, Any], int]: + """Get a single subscription with telemetry data.""" + try: + get_sub_fn = _dep("get_subscription", _real_get_subscription) + subscription = get_sub_fn(client_id, slice_id) + except ValueError: + subscription = None + + if not subscription: + raise ValueError(f"Client '{client_id}' has no subscription for slice '{slice_id}'") + + telemetry, _ = self.get_telemetry(slice_id) + return {**subscription, "telemetry": telemetry}, 200 + + def _get_all_client_subscriptions(self, client_id: str) -> tuple[dict[str, Any], int]: + """Get all subscriptions for a client with telemetry data.""" + get_cli_subs_fn = _dep("get_client_subscriptions", _real_get_client_subscriptions) + subscriptions = get_cli_subs_fn(client_id) + result = [] + for sub in subscriptions: + sub_slice_id = sub["slice_id"] + telemetry, _ = self.get_telemetry(sub_slice_id) + result.append({**sub, "telemetry": telemetry}) + + return {"client_id": client_id, "subscriptions": result}, 200 + + def add_subscription(self, client_id: str, slice_id: str, frequency: int) -> tuple[dict[str, Any], int]: + """Create a telemetry subscription.""" + try: + try: + get_sub_fn = _dep("get_subscription", _real_get_subscription) + subscription = get_sub_fn(client_id, slice_id) + except ValueError: + subscription = None + + if subscription: + raise ValueError(f"Client '{client_id}' already has a subscription for slice '{slice_id}'") + if not frequency: + raise KeyError("Field 'frequency' is required") + + upsert_sub_fn = _dep("upsert_subscription", _real_upsert_subscription) + upsert_sub_fn(client_id, slice_id, frequency) + logger.info("Subscription for slice '%s' and client '%s' created successfully", slice_id, client_id) + return send_response( + True, + code=201, + message="Subscription successfully created", + data={"sliceId": slice_id, "frequency": frequency}, + ) + except KeyError as exc: + return send_response(False, code=400, message=str(exc)) + except ValueError as exc: + return send_response(False, code=404, message=str(exc)) + except Exception as exc: + return send_response(False, code=500, message=str(exc)) + + def update_subscription(self, client_id: str, slice_id: str, frequency: int) -> tuple[dict[str, Any], int]: + """Modify an existing telemetry subscription.""" + try: + try: + get_sub_fn = _dep("get_subscription", _real_get_subscription) + subscription = get_sub_fn(client_id, slice_id) + except ValueError: + subscription = None + + if not subscription: + raise ValueError(f"Client '{client_id}' has no subscription for slice '{slice_id}'") + if not frequency: + raise KeyError("Field 'frequency' is required") + + upsert_sub_fn = _dep("upsert_subscription", _real_upsert_subscription) + upsert_sub_fn(client_id, slice_id, frequency) + logger.info("Subscription for slice '%s' and client '%s' modified successfully", slice_id, client_id) + return send_response( + True, + code=201, + message="Subscription successfully modified", + data={"sliceId": slice_id, "frequency": frequency}, + ) + except KeyError as exc: + return send_response(False, code=400, message=str(exc)) + except ValueError as exc: + return send_response(False, code=404, message=str(exc)) + except Exception as exc: + return send_response(False, code=500, message=str(exc)) + + def delete_subscriptions(self, client_id: str, slice_id: str | None = None) -> tuple[dict[str, Any], int]: + """Delete one or all subscriptions for a client.""" + try: + get_cli_subs_fn = _dep("get_client_subscriptions", _real_get_client_subscriptions) + subscriptions = get_cli_subs_fn(client_id) + if slice_id: + if slice_id not in subscriptions: + raise ValueError(f"Client '{client_id}' has no subscription for slice '{slice_id}'") + del_sub_fn = _dep("delete_subscription", _real_delete_subscription) + del_sub_fn(client_id, slice_id) + logger.info("Subscription for slice '%s' and client '%s' removed successfully", slice_id, client_id) + return {}, 204 + + del_all_subs_fn = _dep("delete_all_subscriptions", _real_delete_all_subscriptions) + del_all_subs_fn(client_id) + logger.info("All subscriptions for client '%s' removed successfully", client_id) + return {}, 204 + except ValueError as exc: + return send_response(False, code=404, message=str(exc)) + except Exception as exc: + return send_response(False, code=500, message=str(exc)) + + # ------------------------------------------------------------------------- + # RESTCONF Telemetry Operations: Metrics & SSE Streaming + # ------------------------------------------------------------------------- + + def get_telemetry(self, slice_id: str | None = None) -> tuple[dict[str, Any], int]: + """Retrieve monitoring telemetry metrics for one or all slices.""" + logger.debug("Getting telemetry for slice_id: %s", slice_id) + try: + if slice_id is not None: + return self._get_single_slice_telemetry(slice_id) + return self._get_all_slices_telemetry() + except ValueError as exc: + return send_response(False, code=404, message=str(exc)) + except Exception as exc: + return send_response(False, code=500, message=str(exc)) + + def _get_single_slice_telemetry(self, slice_id: str) -> tuple[dict[str, Any], int]: + """Fetch telemetry for a single slice.""" + xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']" + get_ds_fn = _dep("get_data_store", _real_get_data_store) + existing_slice = get_ds_fn(xpath) + if not existing_slice: + raise ValueError(f"There is no slice with id '{slice_id}' registered") + + template_id = safe_get( + existing_slice, ["network-slice-services", "slice-service", slice_id, "slo-sle-template"] + ) + slo_sle_template = self.get_slo_sle_templates(template_id)[0] + slo_sle_template = safe_get( + slo_sle_template, ["network-slice-services", "slo-sle-templates", "slo-sle-template"] + ) + slo_sle_template = next(iter(slo_sle_template), None) + + if not slo_sle_template: + raise ValueError(f"SLO/SLE template '{template_id}' not found for slice '{slice_id}'") + + metrics = self.slice_service.monitoring(slice_id, slo_sle_template) + return metrics, 200 + + def _get_all_slices_telemetry(self) -> tuple[dict[str, Any], int]: + """Fetch telemetry for all registered slices.""" + telemetry_data: dict[str, Any] = {} + slices_data = self.get_slice_services()[0] + slice_service_list = slices_data["network-slice-services"]["slice-service"] + if isinstance(slice_service_list, dict): + slice_service_list = list(slice_service_list.values()) + + for slice_item in slice_service_list: + selected_template_id = slice_item.get("slo-sle-template") + slo_sle_template = self.get_slo_sle_templates(selected_template_id)[0] + slo_sle_template = safe_get( + slo_sle_template, ["network-slice-services", "slo-sle-templates", "slo-sle-template"] + ) + slo_sle_template = next(iter(slo_sle_template), None) + + if not slo_sle_template: + raise ValueError(f"SLO/SLE template '{selected_template_id}' not found for slice '{slice_item['id']}'") + + curr_id = slice_item["id"] + telemetry_data[curr_id] = self.slice_service.monitoring(curr_id, slo_sle_template) + + return telemetry_data, 200 + + def sync_stream( + self, + async_gen_func: Callable[..., AsyncGenerator[str, None]], + *args: Any, + **kwargs: Any, + ) -> Generator[str, None, None]: + """Synchronous wrapper for consuming an async generator in Flask streaming responses.""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + agen = async_gen_func(*args, **kwargs) + + try: + while True: + yield loop.run_until_complete(agen.__anext__()) + except StopAsyncIteration: + pass + finally: + loop.close() + + async def stream_client_subscriptions(self, client_id: str) -> AsyncGenerator[str, None]: + """Async SSE event stream for all client subscriptions.""" + while True: + try: + data, code = self.get_subscriptions(client_id) + if code == 200: + yield f"data: {json.dumps(data)}\n\n" + subs = data.get("subscriptions", []) + freq = max([s["frequency"] for s in subs]) if subs else 5 + await asyncio.sleep(freq) + else: + yield f"event: error\ndata: {json.dumps(data)}\n\n" + break + except Exception as exc: + yield f"event: error\ndata: {json.dumps({'error': str(exc)})}\n\n" + break + + async def stream_slice_subscription(self, client_id: str, slice_id: str) -> AsyncGenerator[str, None]: + """Async SSE event stream for a specific slice subscription.""" + while True: + try: + data, code = self.get_subscriptions(client_id, slice_id) + if code == 200: + yield f"data: {json.dumps(data)}\n\n" + freq = data.get("frequency", 5) + await asyncio.sleep(freq) + else: + yield f"event: error\ndata: {json.dumps(data)}\n\n" + break + except Exception as exc: + yield f"event: error\ndata: {json.dumps({'error': str(exc)})}\n\n" + break + + # ------------------------------------------------------------------------- + # RESTCONF Reconfiguration Operations + # ------------------------------------------------------------------------- + + def reconfig_slice(self, slice_id: str) -> tuple[dict[str, Any], int]: + """Trigger slice reconfiguration via Change Scheduler Planner.""" + try: + result = self.slice_service.reconfig_slice(slice_id) + return send_response(True, code=200, data=result) + except ValueError as exc: + return send_response(False, code=404, message=str(exc)) + except Exception as exc: + return send_response(False, code=500, message=str(exc)) diff --git a/src/api/tfs_handler.py b/src/api/tfs_handler.py new file mode 100644 index 0000000..13b2866 --- /dev/null +++ b/src/api/tfs_handler.py @@ -0,0 +1,29 @@ +# Copyright 2022-2026 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. + +"""TFS (TeraFlowSDN) slice API service handler.""" + +from __future__ import annotations + +import logging + +from src.api.base_handler import BaseSliceHandler + +logger = logging.getLogger(__name__) + + +class TfsHandler(BaseSliceHandler): + """API handler dedicated to TFS (TeraFlowSDN) transport network slice operations.""" diff --git a/src/realizer/e2e/e2e_connect.py b/src/realizer/e2e/e2e_connect.py index 54236d4..a3b81f4 100644 --- a/src/realizer/e2e/e2e_connect.py +++ b/src/realizer/e2e/e2e_connect.py @@ -1,11 +1,11 @@ # Copyright 2022-2026 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. @@ -14,58 +14,80 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. +"""Connector for sending end-to-end slice requests to TeraFlowSDN E2E controller.""" + +from __future__ import annotations + import logging import os +from typing import Any from ..tfs.helpers.tfs_connector import tfs_connector +logger = logging.getLogger(__name__) -def e2e_connect(requests, controller_ip, is_update=False, old_service_id=None): - """ - Function to connect end-to-end services in TeraFlowSDN (TFS) controller. - - Args: - requests (list): List of requests to be sent to the TFS e2e controller. - controller_ip (str): IP address of the TFS e2e controller. - is_update (bool): If True, updates the service instead of creating it. - old_service_id (str): Old service ID to delete (optional). - """ - def _extract_slice_id(payload): - if isinstance(payload, dict): - services = payload.get("services") - else: - services = payload +def _extract_slice_id(payload: Any) -> str | None: + """Extract slice identifier from E2E service payload.""" + if isinstance(payload, dict): + services = payload.get("services") + else: + services = payload + + if not services or not isinstance(services, list): + return None + + first_service = services[0] + if isinstance(first_service, list) and first_service: + first_service = first_service[0] - if not services or not isinstance(services, list): - return None + if not isinstance(first_service, dict): + return None - first_service = services[0] - if isinstance(first_service, list) and first_service: - first_service = first_service[0] + slice_id = first_service.get("service_id", {}).get("service_uuid", {}).get("uuid") + if slice_id: + return str(slice_id) - if not isinstance(first_service, dict): - return None + rule_set = first_service.get("rule_set", {}) + l3vpn = rule_set.get("l3vpn", {}) + slice_id = l3vpn.get("tunnel-uuid") or l3vpn.get("uuid") + if slice_id: + return str(slice_id) - slice_id = first_service.get("service_id", {}).get("service_uuid", {}).get("uuid") - if slice_id: - return slice_id + rule_set_uuid = rule_set.get("uuid") + return str(rule_set_uuid) if rule_set_uuid else None - rule_set = first_service.get("rule_set", {}) - l3vpn = rule_set.get("l3vpn", {}) - slice_id = l3vpn.get("tunnel-uuid") or l3vpn.get("uuid") - if slice_id: - return slice_id - return rule_set.get("uuid") +def e2e_connect( + requests: Any, + controller_ip: str, + is_update: bool = False, + old_service_id: str | None = None, +) -> Any: + """Connect end-to-end services in TeraFlowSDN (TFS) controller. + + Args: + requests: List or dict of requests to be sent to the TFS E2E controller. + controller_ip: IP address of the TFS E2E controller. + is_update: If True, updates the service instead of creating it. + old_service_id: Old service ID to delete (optional). + Returns: + Response from the TFS E2E controller. + """ slice_id = _extract_slice_id(requests) or "TEST-SLICE" - logging.info("Connecting end-to-end services in TFS controller at %s with slice ID %s", controller_ip, slice_id) + logger.info( + "Connecting end-to-end services in TFS controller at %s with slice ID %s", + controller_ip, + slice_id, + ) + + target_ip = os.getenv("E2E_OPTICAL_IP") or controller_ip + connector = tfs_connector() if is_update: payload = dict(requests) if isinstance(requests, dict) else {"services": requests} payload["old_service_id"] = old_service_id - response = tfs_connector().ipowdm_put(os.getenv("E2E_OPTICAL_IP"), slice_id, payload) - else: - response = tfs_connector().ipowdm_post(os.getenv("E2E_OPTICAL_IP"), slice_id, requests) - return response + return connector.ipowdm_put(target_ip, slice_id, payload) + + return connector.ipowdm_post(target_ip, slice_id, requests) diff --git a/src/realizer/e2e/main.py b/src/realizer/e2e/main.py index 28ddd62..a8e4390 100644 --- a/src/realizer/e2e/main.py +++ b/src/realizer/e2e/main.py @@ -1,11 +1,11 @@ # Copyright 2022-2026 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. @@ -14,17 +14,42 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. +"""E2E Orchestrator realization entry point.""" + +from __future__ import annotations + import logging +from typing import Any from .service_types.del_l3ipowdm_slice import del_l3ipowdm_slice from .service_types.l3ipowdm_slice import l3ipowdm_slice - -def e2e(ietf_intent, way=None, response=None, rules = None): - logging.debug(f"E2E Realizer selected: {way}") - if way == "L3oWDM": realizing_request = l3ipowdm_slice(rules) - elif way == "DEL_L3oWDM": realizing_request = del_l3ipowdm_slice(rules, response) - else: - logging.warning(f"Unsupported way: {way}.") - realizing_request = None - return realizing_request \ No newline at end of file +logger = logging.getLogger(__name__) + + +def e2e( + ietf_intent: Any, + way: str | None = None, + response: Any = None, + rules: Any = None, +) -> Any: + """Generate E2E orchestrator realization request based on technology way. + + Args: + ietf_intent: IETF intent dictionary. + way: Realization technology way ('L3oWDM', 'DEL_L3oWDM', etc.). + response: Response dictionary or context. + rules: Dynamic rules dictionary. + + Returns: + Realization payload or None if way is unsupported. + """ + logger.debug("E2E Realizer selected: %s", way) + match way: + case "L3oWDM": + return l3ipowdm_slice(rules) + case "DEL_L3oWDM": + return del_l3ipowdm_slice(rules, response) + case _: + logger.warning("Unsupported way: %s.", way) + return None diff --git a/src/realizer/e2e/service_types/del_l3ipowdm_slice.py b/src/realizer/e2e/service_types/del_l3ipowdm_slice.py index ca412c0..0786bfa 100644 --- a/src/realizer/e2e/service_types/del_l3ipowdm_slice.py +++ b/src/realizer/e2e/service_types/del_l3ipowdm_slice.py @@ -1,11 +1,11 @@ # Copyright 2022-2026 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. @@ -14,168 +14,226 @@ # This file includes original contributions from Telefonica Innovación Digital S.L. +"""E2E service template builders for deletion and deprovisioning of L3oWDM slices.""" + +from __future__ import annotations + import logging -import os +from pathlib import Path +from typing import Any from flask import current_app from src.config.constants import NBI_L2_PATH, TEMPLATES_PATH +from src.realizer.tfs.helpers.cisco_connector import cisco_connector from src.utils.load_template import load_template +from src.utils.safe_get import safe_get + +logger = logging.getLogger(__name__) + + +def _build_webui_del_request( + ietf_intent: dict[str, Any], + slice_data: dict[str, Any] | None, + origin_router_id: str, + destination_router_id: str, +) -> dict[str, Any]: + """Build WEBUI deletion descriptor structure.""" + origin_router_if = "0/0/0-GigabitEthernet0/0/0/0" + destination_router_if = "0/0/0-GigabitEthernet0/0/0/0" + + tfs_request = load_template(str(Path(TEMPLATES_PATH) / "L2-VPN_template_empty.json"))["services"][0] + slice_id = safe_get( + ietf_intent, + ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "id"], + ) + tfs_request["service_id"]["service_uuid"]["uuid"] = slice_id + + for endpoint in tfs_request["service_endpoint_ids"]: + is_first = endpoint is tfs_request["service_endpoint_ids"][0] + endpoint["device_id"]["device_uuid"]["uuid"] = origin_router_id if is_first else destination_router_id + endpoint["endpoint_uuid"]["uuid"] = origin_router_if if is_first else destination_router_if + + if slice_data: + for constraint in slice_data.get("requirements", []): + tfs_request["service_constraints"].append({"custom": constraint}) + for i, config_rule in enumerate(tfs_request["service_config"]["config_rules"][1:], start=1): + router_id = origin_router_id if i == 1 else destination_router_id + router_if = origin_router_if if i == 1 else destination_router_if + resource_value = config_rule["custom"]["resource_value"] + + sdp_index = i - 1 + vlan_value = safe_get( + ietf_intent, + [ + "ietf-network-slice-service:network-slice-services", + "slice-service", + 0, + "sdps", + "sdp", + sdp_index, + "service-match-criteria", + "match-criterion", + 0, + "match-type", + 0, + "vlan", + 0, + ], + ) + if vlan_value: + resource_value["vlan_id"] = int(vlan_value) + resource_value["circuit_id"] = vlan_value + resource_value["remote_router"] = destination_router_id if i == 1 else origin_router_id + resource_value["ni_name"] = f"ELAN{vlan_value!s}" + config_rule["custom"]["resource_key"] = f"/device[{router_id}]/endpoint[{router_if}]/settings" -def del_l3ipowdm_slice(ietf_intent, response): - """ - Translate slice intent into a TeraFlow service request. + return tfs_request + + +def _build_nbi_del_request( + ietf_intent: dict[str, Any], + origin_router_id: str, + destination_router_id: str, +) -> dict[str, Any]: + """Build NBI deletion descriptor structure.""" + tfs_request = load_template(str(Path(TEMPLATES_PATH) / "ietfL2VPN_template_empty.json")) + tfs_request["path"] = NBI_L2_PATH + + full_id = ( + safe_get( + ietf_intent, + ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "id"], + ) + or "" + ) + uuid_only = full_id.split("slice-service-")[-1] + tfs_request["ietf-l2vpn-svc:vpn-service"][0]["vpn-id"] = uuid_only + + sites = tfs_request["ietf-l2vpn-svc:vpn-service"][0]["site"] + sdps = ( + safe_get( + ietf_intent, + ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "sdps", "sdp"], + ) + or [] + ) + + for i, site in enumerate(sites): + is_origin = i == 0 + router_id = origin_router_id if is_origin else destination_router_id + sdp = sdps[0] if is_origin and len(sdps) > 0 else (sdps[1] if len(sdps) > 1 else {}) + site["site-id"] = router_id + site["site-location"] = sdp.get("node-id") + site["site-network-access"]["interface"]["ip-address"] = sdp.get("sdp-ip-address") + + return tfs_request - This method prepares a L2VPN service request by: - 1. Defining endpoint routers - 2. Loading a service template - 3. Generating a unique service UUID - 4. Configuring service endpoints - 5. Adding QoS constraints - 6. Preparing configuration rules for network interfaces + +def del_l3ipowdm_slice(ietf_intent: dict[str, Any], response: list[dict[str, Any]] | None) -> dict[str, Any] | None: + """Translate slice intent into a deprovisioning TeraFlow service request. Args: - ietf_intent (dict): IETF-formatted network slice intent. + ietf_intent: IETF formatted network slice intent. + response: Existing slice records. Returns: - dict: A TeraFlow service request for L2VPN configuration. - + Prepared TFS service deprovisioning request. """ - # Hardcoded router endpoints - # TODO (should be dynamically determined) - origin_router_id = 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"] - origin_router_if = '0/0/0-GigabitEthernet0/0/0/0' - destination_router_id = ietf_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][1]["attachment-circuits"]["attachment-circuit"][0]["sdp-peering"]["peer-sap-id"] - destination_router_if = '0/0/0-GigabitEthernet0/0/0/0' - id = ietf_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"] - slice = next((d for d in response if d.get("id") == id), None) - - if current_app.config["UPLOAD_TYPE"] == "WEBUI": - # Load L2VPN service template - tfs_request = load_template(os.path.join(TEMPLATES_PATH, "L2-VPN_template_empty.json"))["services"][0] - - # Configure service UUID - tfs_request["service_id"]["service_uuid"]["uuid"] = ietf_intent['ietf-network-slice-service:network-slice-services']['slice-service'][0]["id"] - - # Configure service endpoints - for endpoint in tfs_request["service_endpoint_ids"]: - endpoint["device_id"]["device_uuid"]["uuid"] = origin_router_id if endpoint is tfs_request["service_endpoint_ids"][0] else destination_router_id - endpoint["endpoint_uuid"]["uuid"] = origin_router_if if endpoint is tfs_request["service_endpoint_ids"][0] else destination_router_if - - # Add service constraints - for constraint in slice.get("requirements", []): - tfs_request["service_constraints"].append({"custom": constraint}) - - # Add configuration rules - for i, config_rule in enumerate(tfs_request["service_config"]["config_rules"][1:], start=1): - router_id = origin_router_id if i == 1 else destination_router_id - router_if = origin_router_if if i == 1 else destination_router_if - resource_value = config_rule["custom"]["resource_value"] - - sdp_index = i - 1 - vlan_value = ietf_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][sdp_index]["service-match-criteria"]["match-criterion"][0]["match-type"][0]["vlan"][0] - if vlan_value: - resource_value["vlan_id"] = int(vlan_value) - resource_value["circuit_id"] = vlan_value - resource_value["remote_router"] = destination_router_id if i == 1 else origin_router_id - resource_value["ni_name"] = f'ELAN{vlan_value!s:s}' - config_rule["custom"]["resource_key"] = f"/device[{router_id}]/endpoint[{router_if}]/settings" - - elif current_app.config["UPLOAD_TYPE"] == "NBI": - #self.path = NBI_L2_PATH - # Load IETF L2VPN service template - tfs_request = load_template(os.path.join(TEMPLATES_PATH, "ietfL2VPN_template_empty.json")) - - # Add path to the request - tfs_request["path"] = NBI_L2_PATH - - # Generate service UUID - full_id = ietf_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"] - uuid_only = full_id.split("slice-service-")[-1] - tfs_request["ietf-l2vpn-svc:vpn-service"][0]["vpn-id"] = uuid_only - - # Configure service endpoints - sites = tfs_request["ietf-l2vpn-svc:vpn-service"][0]["site"] - sdps = ietf_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"] - - for i, site in enumerate(sites): - is_origin = (i == 0) - router_id = origin_router_id if is_origin else destination_router_id - sdp = sdps[0] if is_origin else sdps[1] - site["site-id"] = router_id - site["site-location"] = sdp["node-id"] - site["site-network-access"]["interface"]["ip-address"] = sdp["sdp-ip-address"] - - logging.info("L2VPN Intent realized\n") + 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", + ], + ) + destination_router_id = safe_get( + ietf_intent, + [ + "ietf-network-slice-service:network-slice-services", + "slice-service", + 0, + "sdps", + "sdp", + 1, + "attachment-circuits", + "attachment-circuit", + 0, + "sdp-peering", + "peer-sap-id", + ], + ) + slice_id = safe_get( + ietf_intent, + ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "id"], + ) + slice_data = next((d for d in (response or []) if d.get("id") == slice_id), None) + + upload_type = current_app.config.get("UPLOAD_TYPE", "WEBUI") + if upload_type == "WEBUI": + tfs_request = _build_webui_del_request(ietf_intent, slice_data, origin_router_id, destination_router_id) + elif upload_type == "NBI": + tfs_request = _build_nbi_del_request(ietf_intent, origin_router_id, destination_router_id) + else: + logger.warning("Unsupported upload type: %s", upload_type) + return None + + logger.info("L2VPN Intent realized for deletion") return tfs_request -def tfs_l2vpn_support(requests): - """ - Configuration support for L2VPN with path selection based on MPLS traffic-engineering tunnels - Args: - requests (list): A list of configuration parameters. +def tfs_l2vpn_support(requests: list[dict[str, Any]]) -> None: + """Configure Cisco routers for L2VPN with MPLS traffic-engineering tunnels.""" + sources: dict[str, Any] = {"source": "10.60.125.44", "config": []} + destinations: dict[str, Any] = {"destination": "10.60.125.45", "config": []} - """ - sources={ - "source": "10.60.125.44", - "config":[] - } - destinations={ - "destination": "10.60.125.45", - "config":[] - } for request in requests: - # Configure Source Endpoint temp_source = request["service_config"]["config_rules"][1]["custom"]["resource_value"] endpoints = request["service_endpoint_ids"] - config = { - "ni_name": temp_source["ni_name"], - "remote_router": temp_source["remote_router"], - "interface": endpoints[0]["endpoint_uuid"]["uuid"].replace("0/0/0-", ""), - "vlan" : temp_source["vlan_id"], - "number" : temp_source["vlan_id"] % 10 + 1 - } - sources["config"].append(config) - - # Configure Destination Endpoint + sources["config"].append( + { + "ni_name": temp_source["ni_name"], + "remote_router": temp_source["remote_router"], + "interface": endpoints[0]["endpoint_uuid"]["uuid"].replace("0/0/0-", ""), + "vlan": temp_source["vlan_id"], + "number": temp_source["vlan_id"] % 10 + 1, + } + ) + temp_destiny = request["service_config"]["config_rules"][2]["custom"]["resource_value"] - config = { - "ni_name": temp_destiny["ni_name"], - "remote_router": temp_destiny["remote_router"], - "interface": endpoints[1]["endpoint_uuid"]["uuid"].replace("0/0/3-", ""), - "vlan" : temp_destiny["vlan_id"], - "number" : temp_destiny["vlan_id"] % 10 + 1 - } - destinations["config"].append(config) - - #cisco_source = cisco_connector(source_address, ni_name, remote_router, vlan, vlan % 10 + 1) + destinations["config"].append( + { + "ni_name": temp_destiny["ni_name"], + "remote_router": temp_destiny["remote_router"], + "interface": endpoints[1]["endpoint_uuid"]["uuid"].replace("0/0/3-", ""), + "vlan": temp_destiny["vlan_id"], + "number": temp_destiny["vlan_id"] % 10 + 1, + } + ) + cisco_source = cisco_connector(sources["source"], sources["config"]) - commands = cisco_source.full_create_command_template() - cisco_source.execute_commands(commands) + cisco_source.execute_commands(cisco_source.full_create_command_template()) - #cisco_destiny = cisco_connector(destination_address, ni_name, remote_router, vlan, vlan % 10 + 1) cisco_destiny = cisco_connector(destinations["destination"], destinations["config"]) - commands = cisco_destiny.full_create_command_template() - cisco_destiny.execute_commands(commands) + cisco_destiny.execute_commands(cisco_destiny.full_create_command_template()) -def tfs_l2vpn_delete(): - """ - Delete L2VPN configurations from Cisco devices. - - This method removes L2VPN configurations from Cisco routers - Notes: - - Uses cisco_connector to generate and execute deletion commands - - Clears Network Interface (NI) settings - """ - # Delete Source Endpoint Configuration +def tfs_l2vpn_delete() -> None: + """Delete L2VPN configurations from Cisco devices.""" source_address = "10.60.125.44" cisco_source = cisco_connector(source_address) cisco_source.execute_commands(cisco_source.create_command_template_delete()) - # Delete Destination Endpoint Configuration destination_address = "10.60.125.45" cisco_destiny = cisco_connector(destination_address) - cisco_destiny.execute_commands(cisco_destiny.create_command_template_delete()) \ No newline at end of file + cisco_destiny.execute_commands(cisco_destiny.create_command_template_delete()) diff --git a/src/realizer/e2e/service_types/l3ipowdm_slice.py b/src/realizer/e2e/service_types/l3ipowdm_slice.py index a1ce59a..51e7074 100644 --- a/src/realizer/e2e/service_types/l3ipowdm_slice.py +++ b/src/realizer/e2e/service_types/l3ipowdm_slice.py @@ -1,11 +1,11 @@ # Copyright 2022-2026 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. @@ -14,158 +14,173 @@ # This file includes original contributions from Telefonica Innovación Digital S.L. +"""E2E service template builders for L3 over WDM optical slices.""" + +from __future__ import annotations + import logging -import os +from pathlib import Path +from typing import Any from src.config.constants import TEMPLATES_PATH from src.utils.load_template import load_template +logger = logging.getLogger(__name__) -def l3ipowdm_slice(rules): - """ - Prepare a Optical service request for an optical slice. - This method prepares a TeraFlow service request for an optical slice by: - 1. Defining endpoint routers - 2. Loading a service template - 3. Generating a unique service UUID - 4. Configuring service endpoints - 5. Adding QoS constraints +def optical_slice_template(template: dict[str, Any], rule: dict[str, Any]) -> dict[str, Any]: + """Complete the optical slice template with topology, SIP, and context metadata. Args: - ietf_intent (dict): IETF-formatted network slice intent. - rules (dict, optional): Configuration rules for the optical slice. + template: Base optical slice template dictionary. + rule: Rules dictionary containing action configurations. Returns: - dict: A TeraFlow service request for optical slice configuration. + Populated template dictionary. """ - transceiver_params = [] - bandwidth = 0 + for action in rule.get("actions", []): + content = action.get("content", {}) + nodes = content.get("node", []) + for node in nodes: + for onp in node.get("owned-node-edge-point", []): + if "media-channel-node-edge-point-spec" in onp: + onp["tapi-photonic-media:media-channel-node-edge-point-spec"] = onp.pop( + "media-channel-node-edge-point-spec" + ) + + actions = rule.get("actions", []) + first_content = actions[0].get("content", {}) if actions else {} + sips_data = first_content.get("service-interface-point", []) + + template_sips = template.get("tapi-common:context", {}).get("service-interface-point", []) + for i, sip in enumerate(template_sips): + if i < len(sips_data): + sip["uuid"] = sips_data[i]["uuid"] + + topo_context = template["tapi-common:context"]["tapi-topology:topology-context"]["topology"][0] + nodes_template = topo_context["node"] + for new_node in first_content.get("node", []): + nodes_template.append(new_node) - logging.debug(f"Preparing L3oWDM slice with rules: {rules}") - tfs_requests = [] - for rule in rules["actions"]: - logging.debug(f"Processing rule: {rule['type']}") - if rule["type"] == "CREATE_OPTICAL_SLICE": - tfs_request = load_template(os.path.join(TEMPLATES_PATH, "Optical_slice.json")) - request = optical_slice_template(tfs_request, rules) - logging.debug(f"Sending Optical Slice to Optical Controller {request}") - tfs_requests.append(request) - - elif rule["type"] == "PROVISION_MEDIA_CHANNEL_OLS_PATH": - - origin_router_id = rule["content"]["src-sip-uuid"] - destination_router_id = rule["content"]["dest-sip-uuid"] - direction = rule["content"]["direction"] - bandwidth = rule["content"]["bandwidth-ghz"] - service_uuid = rule["content"]["ols-path-uuid"] - tenant_uuid = rule["tenant-uuid"] - layer_protocol_name = rule["content"]["layer-protocol-name"] - layer_protocol_qualifier = rule["content"]["layer-protocol-qualifier"] - lower_frequency_mhz = rule["content"]["lower-frequency-mhz"] - upper_frequency_mhz = rule["content"]["upper-frequency-mhz"] - link_uuid_path = rule["content"]["link-uuid-path"] - granularity = rule["content"]["adjustment-granularity"] - grid = rule["content"]["grid-type"] - - tfs_request = load_template(os.path.join(TEMPLATES_PATH, "TAPI_service.json")) - - tfs_request["services"][0]["service_id"]["service_uuid"]["uuid"] = service_uuid - config_rules = tfs_request["services"][0]["service_config"]["config_rules"][0] - - config_rules["tapi_lsp"]["rule_set"]["src"] = origin_router_id - config_rules["tapi_lsp"]["rule_set"]["dst"] = destination_router_id - config_rules["tapi_lsp"]["rule_set"]["uuid"] = service_uuid - config_rules["tapi_lsp"]["rule_set"]["bw"] = str(bandwidth) - config_rules["tapi_lsp"]["rule_set"]["tenant_uuid"] = tenant_uuid - config_rules["tapi_lsp"]["rule_set"]["direction"] = direction - config_rules["tapi_lsp"]["rule_set"]["layer_protocol_name"] = layer_protocol_name - config_rules["tapi_lsp"]["rule_set"]["layer_protocol_qualifier"] = layer_protocol_qualifier - config_rules["tapi_lsp"]["rule_set"]["lower_frequency_mhz"] = str(lower_frequency_mhz) - config_rules["tapi_lsp"]["rule_set"]["upper_frequency_mhz"] = str(upper_frequency_mhz) - config_rules["tapi_lsp"]["rule_set"]["link_uuid_path"] = link_uuid_path - config_rules["tapi_lsp"]["rule_set"]["granularity"] = granularity - config_rules["tapi_lsp"]["rule_set"]["grid_type"] = grid - - logging.debug(f"Sending Media Channel Service to Orchestrator: {tfs_request}") - tfs_requests.append(tfs_request) - - elif rule["type"] == "XR_AGENT_ACTIVATE_TRANSCEIVER": - transceiver_params = rule["content"]["components"] - - elif rule["type"] == "CONFIG_VPNL3": - service_uuid = rule["content"]["tunnel-uuid"] - - src = [{ - 'uuid': rule["content"]["src-node-uuid"], - 'ip_address': rule["content"]["src-ip-address"], - 'ip_mask': rule["content"]["src-ip-mask"], - 'vlan_id': rule["content"]["src-vlan-id"] - }] - - dst = [] - i = 1 - while f"dest{i}-node-uuid" in rule["content"]: - dst.append({ - 'uuid': rule["content"][f"dest{i}-node-uuid"], - 'ip_address': rule["content"][f"dest{i}-ip-address"], - 'ip_mask': rule["content"][f"dest{i}-ip-mask"], - 'vlan_id': rule["content"][f"dest{i}-vlan-id"] - }) - i += 1 - - tfs_request = load_template(os.path.join(TEMPLATES_PATH, "IPoWDM_orchestrator.json")) - - config_rules = tfs_request - config_rules["endpoint_id"]["device_id"]["device_uuid"]["uuid"] = rule["controller_uuid"] - config_rules["rule_set"]["uuid"] = rule["controller_uuid"] - config_rules["rule_set"]["src"] = src - config_rules["rule_set"]["dst"] = dst - config_rules["rule_set"]["transceiver"] = { - "components": transceiver_params + links_template = topo_context["link"] + for link_t in first_content.get("link", []): + links_template.append(link_t) + + template["tapi-common:context"]["uuid"] = first_content.get("tenant-uuid") + template["tapi-common:context"]["name"][0]["value"] = rule.get("network-slice-uuid") + + return template + + +def _build_media_channel_request(rule: dict[str, Any]) -> dict[str, Any]: + """Build TAPI Media Channel request from provision rule.""" + content = rule.get("content", {}) + tfs_request = load_template(str(Path(TEMPLATES_PATH) / "TAPI_service.json")) + + service_uuid = content.get("ols-path-uuid") + tfs_request["services"][0]["service_id"]["service_uuid"]["uuid"] = service_uuid + config_rules = tfs_request["services"][0]["service_config"]["config_rules"][0] + rule_set = config_rules["tapi_lsp"]["rule_set"] + + rule_set["src"] = content.get("src-sip-uuid") + rule_set["dst"] = content.get("dest-sip-uuid") + rule_set["uuid"] = service_uuid + rule_set["bw"] = str(content.get("bandwidth-ghz", 0)) + rule_set["tenant_uuid"] = rule.get("tenant-uuid") + rule_set["direction"] = content.get("direction") + rule_set["layer_protocol_name"] = content.get("layer-protocol-name") + rule_set["layer_protocol_qualifier"] = content.get("layer-protocol-qualifier") + rule_set["lower_frequency_mhz"] = str(content.get("lower-frequency-mhz")) + rule_set["upper_frequency_mhz"] = str(content.get("upper-frequency-mhz")) + rule_set["link_uuid_path"] = content.get("link-uuid-path") + rule_set["granularity"] = content.get("adjustment-granularity") + rule_set["grid_type"] = content.get("grid-type") + + return tfs_request + + +def _build_vpnl3_request( + rule: dict[str, Any], + transceiver_params: list[Any], +) -> dict[str, Any]: + """Build IPoWDM orchestrator request from VPN L3 rule and transceiver parameters.""" + content = rule.get("content", {}) + src = [ + { + "uuid": content.get("src-node-uuid"), + "ip_address": content.get("src-ip-address"), + "ip_mask": content.get("src-ip-mask"), + "vlan_id": content.get("src-vlan-id"), + } + ] + + dst = [] + i = 1 + while f"dest{i}-node-uuid" in content: + dst.append( + { + "uuid": content[f"dest{i}-node-uuid"], + "ip_address": content[f"dest{i}-ip-address"], + "ip_mask": content[f"dest{i}-ip-mask"], + "vlan_id": content[f"dest{i}-vlan-id"], } - config_rules["rule_set"]["l3vpn"] = rule["content"] + ) + i += 1 - logging.debug(f"Sending IPoWDM Service to Orchestrator: {tfs_request}") - tfs_requests.append(tfs_request) + tfs_request = load_template(str(Path(TEMPLATES_PATH) / "IPoWDM_orchestrator.json")) + controller_uuid = rule.get("controller_uuid") - else: - logging.debug("Unsupported rule type for optical slice: %s", rule["type"]) - return tfs_requests + tfs_request["endpoint_id"]["device_id"]["device_uuid"]["uuid"] = controller_uuid + tfs_request["rule_set"]["uuid"] = controller_uuid + tfs_request["rule_set"]["src"] = src + tfs_request["rule_set"]["dst"] = dst + tfs_request["rule_set"]["transceiver"] = {"components": transceiver_params} + tfs_request["rule_set"]["l3vpn"] = content + + return tfs_request + + +def l3ipowdm_slice(rules: dict[str, Any] | None) -> list[dict[str, Any]]: + """Prepare Optical service requests for an optical slice from rules. -def optical_slice_template(template, rule): - """ - Complete the optical slice template with the data provided. Args: - template (dict): optical slice template. - data (dict): Data to complete the template. + rules: Configuration rules for the optical slice. + Returns: - dict: Template completed. + List of TFS/IPoWDM service requests. """ + if not rules or "actions" not in rules: + return [] - for action in rule.get('actions', []): - content = action.get('content', {}) - nodes = content.get('node', []) - for node in nodes: - for onp in node.get('owned-node-edge-point', []): - if 'media-channel-node-edge-point-spec' in onp: - onp['tapi-photonic-media:media-channel-node-edge-point-spec'] = onp.pop('media-channel-node-edge-point-spec') + transceiver_params: list[Any] = [] + tfs_requests: list[dict[str, Any]] = [] - for i, sip in enumerate(template['tapi-common:context']['service-interface-point']): - if i < len(rule['actions'][0]['content']['service-interface-point']): - sip['uuid'] = rule['actions'][0]['content']['service-interface-point'][i]['uuid'] + logger.debug("Preparing L3oWDM slice with rules: %s", rules) + for rule in rules["actions"]: + rule_type = rule.get("type") + logger.debug("Processing rule: %s", rule_type) - nodes_template = template['tapi-common:context']['tapi-topology:topology-context']['topology'][0]['node'] - nodes_data = rule['actions'][0]['content']['node'] - for new_node in nodes_data: - nodes_template.append(new_node) + match rule_type: + case "CREATE_OPTICAL_SLICE": + tfs_request = load_template(str(Path(TEMPLATES_PATH) / "Optical_slice.json")) + request = optical_slice_template(tfs_request, rules) + logger.debug("Sending Optical Slice to Optical Controller: %s", request) + tfs_requests.append(request) - links_template = template['tapi-common:context']['tapi-topology:topology-context']['topology'][0]['link'] - links_rule = rule['actions'][0]['content']['link'] - for link_t in links_rule: - links_template.append(link_t) + case "PROVISION_MEDIA_CHANNEL_OLS_PATH": + tfs_request = _build_media_channel_request(rule) + logger.debug("Sending Media Channel Service to Orchestrator: %s", tfs_request) + tfs_requests.append(tfs_request) - template['tapi-common:context']['uuid'] = rule['actions'][0]['content']['tenant-uuid'] - template['tapi-common:context']['name'][0]['value'] = rule['network-slice-uuid'] + case "XR_AGENT_ACTIVATE_TRANSCEIVER": + transceiver_params = rule.get("content", {}).get("components", []) - return template + case "CONFIG_VPNL3": + tfs_request = _build_vpnl3_request(rule, transceiver_params) + logger.debug("Sending IPoWDM Service to Orchestrator: %s", tfs_request) + tfs_requests.append(tfs_request) + + case _: + logger.debug("Unsupported rule type for optical slice: %s", rule_type) + + return tfs_requests diff --git a/src/realizer/get_metrics.py b/src/realizer/get_metrics.py index 2a4f3df..16f89b4 100644 --- a/src/realizer/get_metrics.py +++ b/src/realizer/get_metrics.py @@ -1,38 +1,79 @@ +# Copyright 2022-2026 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. + +"""Telemetry metric gathering and stream initialization for network slices.""" + +from __future__ import annotations + import asyncio import logging +from typing import Any from flask import current_app from .restconf.connectors.tfs_connector import tfs_connector +logger = logging.getLogger(__name__) + + +def _build_link_cache(path: list[str]) -> tuple[list[str], dict[str, dict[str, Any]]]: + """Build telemetry cache structures for consecutive path nodes.""" + links: list[str] = [] + link_entries: dict[str, dict[str, Any]] = {} + for i in range(len(path) - 1): + link_id = f"{path[i]}-{path[i + 1]}" + links.append(link_id) + link_entries[link_id] = { + "timestamp": None, + "bandwidth": 0, + "latency": 0, + "services": [], + } + return links, link_entries + + +def get_metrics(path: list[str], slice_id: str, controller_type: str | None) -> None: + """Initialize and start telemetry stream monitoring for links along a service path. + + Args: + path: Ordered list of node names in the slice service path. + slice_id: Unique network slice identifier. + controller_type: Controller type name (e.g., 'RESTCONF'). + """ + if controller_type != "RESTCONF": + return + + telemetry_cache = current_app.config.setdefault("TELEMETRY_CACHE", {}) + if slice_id in telemetry_cache: + logger.debug("Telemetry for slice '%s' already in cache. Skipping.", slice_id) + return + + logger.debug("Telemetry for slice '%s' not in cache. Initializing.", slice_id) + links, link_entries = _build_link_cache(path) + telemetry_cache[slice_id] = link_entries -def get_metrics(path, slice_id, controller_type): - if controller_type == "RESTCONF": - links = [] - if slice_id not in current_app.config["TELEMETRY_CACHE"]: - logging.debug(f"Telemetry for slice '{slice_id}' not in cache. Initializing.") - current_app.config["TELEMETRY_CACHE"][slice_id] = {} - for i in range(len(path)-1): - link_id = f"{path[i]}-{path[i+1]}" - links.append(link_id) - current_app.config["TELEMETRY_CACHE"][slice_id][link_id] = { - "timestamp": None, #time.time(), - "bandwidth": 0, - "latency": 0, - "services": [] - } - # Get telemetry only of the links in the shortest path: - logging.debug(f"Starting telemetry streams for slice '{slice_id}'") - connector = tfs_connector() - bg_loop = connector.get_background_loop() - coro = connector.startStreams( - current_app.config["RESTCONF_IP"], - current_app.config["SDN_SUBSCRIPTION_PERIOD"], - links, - slice_id, - current_app.config["TELEMETRY_CACHE"] - ) - future = asyncio.run_coroutine_threadsafe(coro, bg_loop) - future.result() - else: - logging.debug(f"Telemetry for slice '{slice_id}' already in cache. Skipping.") \ No newline at end of file + logger.debug("Starting telemetry streams for slice '%s'", slice_id) + connector = tfs_connector() + bg_loop = connector.get_background_loop() + coro = connector.startStreams( + current_app.config["RESTCONF_IP"], + current_app.config["SDN_SUBSCRIPTION_PERIOD"], + links, + slice_id, + telemetry_cache, + ) + future = asyncio.run_coroutine_threadsafe(coro, bg_loop) + future.result() diff --git a/src/realizer/ixia/helpers/NEII_V4.py b/src/realizer/ixia/helpers/NEII_V4.py index 137639a..5103c8e 100644 --- a/src/realizer/ixia/helpers/NEII_V4.py +++ b/src/realizer/ixia/helpers/NEII_V4.py @@ -1,11 +1,11 @@ # Copyright 2022-2026 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. @@ -14,363 +14,206 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. +"""IXIA NEII Controller management and profile orchestration.""" + +from __future__ import annotations + import ipaddress import logging +from typing import Any from .automatizacion_ne2v4 import automatizacion +logger = logging.getLogger(__name__) + class NEII_controller: - def __init__(self, ixia_ip): - self.ixia_ip = ixia_ip - - def menu_principal(self, ip): - ''' - Inputs: - Outputs: - Work: The main menu of the application. - Notes: If the file is executed from the terminal, ensure that the import - of "automatizacion_ne2v4" does not include a dot at the beggining. - ''' - ip=input("¿Cuál es la IP del network emulator?: ") - accion=input("¿Qué deseas hacer?: \n(1) Ver información de IP\n(2) Consultar información del Hardware\n(3) Configurar un perfil nuevo\n(4) Consultar perfiles existentes\nSelecciona una opción: ") - if accion=="1": - self.ver_info(ip) - if accion=="2": - self.hardware(ip) - if accion=="3": - self.nuevo_perfil(ip) - if accion=="4": - self.existentes(ip) - - ## MAIN MENU FUNCTIONS ## - - def ver_info(self,ip): - ''' - Inputs: -ip: the ip where the Axia API is located. - Outputs: - Work: It gives the information of the API. - - ''' - informacion_ip=automatizacion.obtener_informacion_ip(ip) - if informacion_ip: - print(informacion_ip) - - def hardware(self,ip): - ''' - Inputs: -ip: the ip where the Axia API is located. - Outputs: - Work: It gives the information of the hardware. - - ''' - informacion_hardware=automatizacion.obtener_informacion_hardware(ip) - if informacion_hardware: - print(informacion_hardware) - - def nuevo_perfil(self,ip): - ''' - Inputs: -ip: the ip where the Axia API is located. - Outputs: - Work: Creates and configures the profiles requested in the Axia API on the specified port. - Notes: It is NOT required to fill all the information requested. - - ''' - puerto=input("¿Qué puerto quieres configurar?: ") - num_perfiles=int(input("¿Cuantos perfiles quieres añadir?: ")) - configuraciones_totales=[] - for i in range(num_perfiles): - nombre=f"perfil{i+1}" - accion=input("¿Qué deseas configurar?\n1)IPv4\t2)IPv6\n3)VLAN\t4)Delay\n5)Packet Drop\t6)Policer (Rx Bandwidth)\n7)Shaper (Rx Bandwidth)\nPor favor, separa las opciones con comas: ") - opciones=accion.split(',') - configuraciones={} - for opcion in opciones: - opcion=opcion.strip() - if opcion=="1": - source_ip=input("Introduce la IP de origen (IPv4): ") - destination_ip=input("Introduce la IP destino (IPv4): ") - prt=input("¿Qué protocolo quieres usar, IP o TCP? ") - configuraciones['ipv4']=self.ipv4(source_ip,destination_ip,prt) - elif opcion=="2": - source=input("Introduce la IP de origen (IPv6): ") - destination=input("Introduce la IP destino (IPv6): ") - configuraciones['ipv6']=self.ipv6(source, destination) - elif opcion=="3": - vlan_id=int(input("Introduce identificador de VLAN: ")) - configuraciones['vlan']=self.vlan(vlan_id) - elif opcion=="4": - delay_perfil=input("Introduce el delay que quieres introducir en el perfil test_api: ") - configuraciones['ethernetDelay']=self.delay(delay_perfil) - elif opcion=="5": - configuraciones['packetDrop']=self.packetDrop() - elif opcion=="6": - bandwidth=int(input('Introduzca el ancho de banda (Kbps): ')) - configuraciones['policer']=self.policer(bandwidth) - elif opcion=="7": - configuraciones['shaper']=self.shaper() - else: - print(f"Opción '{opcion}' no es válida.") - configuracion_perfil=self.configuracion_total(configuraciones, nombre) - configuraciones_totales.append(configuracion_perfil) - perfil_final = {'profiles': configuraciones_totales} - configuracion_puerto=automatizacion.envio_peticion(ip, puerto, perfil_final) - if configuracion_puerto: - print(configuracion_puerto) - - def existentes(self,ip): - ''' - Inputs: -ip: the ip where the Axia API is located. - Outputs: - Work: Shows the information of a given port- - - ''' - puerto=input("¿Qué puerto quieres consultar?: ") - informacion_puerto=automatizacion.obtener_informacion_puerto(ip, puerto) - if informacion_puerto: - print(informacion_puerto) - - def existentes_auto(self,ip,puerto): - ''' - Inputs: -ip: the ip where the Axia API is located. - - puerto: the port we want to get the information. - Outputs: - Work: Creates and configures the profiles requested in the Axia API on the specified port. - - ''' - informacion_puerto=automatizacion.obtener_informacion_puerto(ip, puerto) - if informacion_puerto: - print(f'info puerto\n{informacion_puerto}') - return informacion_puerto - - ## FUNCION PARA LA GUI DEL NSC ## - - import ipaddress - - def nscNEII(self, json_data): - configuraciones = {} + """Controller interface for configuring and interacting with IXIA Network Emulator II.""" + + def __init__(self, ixia_ip: str | None = None) -> None: + self.ixia_ip = ixia_ip or "" + + def nscNEII(self, json_data: dict[str, Any]) -> Any: + """Configure NEII profiles from slice intent JSON data. + + Args: + json_data: Intent dictionary containing QoS, L2/L3 addressing, and impairment params. + + Returns: + Status of the configured port. + """ + configuraciones: dict[str, Any] = {} ip = self.ixia_ip puerto = "5" - dataProfile = self.existentes_auto(ip, puerto) - - print(f'\n\n{json_data}\n') - - ip_version = json_data.get("ip_version", None) - src_node_ip = json_data.get("src_node_ip", None) - dst_node_ip = json_data.get("dst_node_ip", None) - src_node_ipv6 = json_data.get("src_node_ipv6", None) - dst_node_ipv6 = json_data.get("dst_node_ipv6", None) - vlan_id = json_data.get("vlan_id", None) - bandwidth = json_data.get("bandwidth", None) - latency = json_data.get("latency", None) - latency_version = json_data.get("latency_version", None) - reliability = json_data.get("reliability", None) - tolerance = json_data.get("tolerance", None) - packet_reorder = json_data.get("packet_reorder", None) - num_pack = json_data.get("num_pack", None) - pack_reorder = json_data.get("pack_reorder", None) - num_reorder = json_data.get("num_reorder", None) - max_reorder = json_data.get("max_reorder", None) - drop_version = json_data.get("drop_version", None) - desv_reorder = json_data.get("desv_reorder", None) - packets_drop = json_data.get("packets_drop", None) - drops = json_data.get("drops", None) - desv_drop = json_data.get("desv_drop", None) - - # --- Configuration variables --- + data_profile = self.existentes_auto(ip, puerto) or {"profiles": []} + + src_node_ip = json_data.get("src_node_ip") + dst_node_ip = json_data.get("dst_node_ip") + src_node_ipv6 = json_data.get("src_node_ipv6") + dst_node_ipv6 = json_data.get("dst_node_ipv6") + vlan_id = json_data.get("vlan_id") + bandwidth = json_data.get("bandwidth") + latency = json_data.get("latency") + latency_version = json_data.get("latency_version") + reliability = json_data.get("reliability") + tolerance = json_data.get("tolerance") + packet_reorder = json_data.get("packet_reorder") + num_pack = json_data.get("num_pack") + pack_reorder = json_data.get("pack_reorder") + num_reorder = json_data.get("num_reorder") + max_reorder = json_data.get("max_reorder") + drop_version = json_data.get("drop_version") + desv_reorder = json_data.get("desv_reorder") + packets_drop = json_data.get("packets_drop") + drops = json_data.get("drops") + desv_drop = json_data.get("desv_drop") # IPv4 / IPv6 configuration if src_node_ip and dst_node_ip: - if isinstance(ipaddress.ip_address(src_node_ip), ipaddress.IPv4Address) and isinstance(ipaddress.ip_address(dst_node_ip), ipaddress.IPv4Address): - configuraciones['ipv4'] = self.ipv4(src_node_ip, dst_node_ip, 5) + try: + src_obj = ipaddress.ip_address(src_node_ip) + dst_obj = ipaddress.ip_address(dst_node_ip) + if isinstance(src_obj, ipaddress.IPv4Address) and isinstance(dst_obj, ipaddress.IPv4Address): + configuraciones["ipv4"] = self.ipv4(src_node_ip, dst_node_ip, "5") + except ValueError: + pass + if src_node_ipv6 and dst_node_ipv6: - if isinstance(ipaddress.ip_address(src_node_ipv6), ipaddress.IPv6Address) and isinstance(ipaddress.ip_address(dst_node_ipv6), ipaddress.IPv6Address): - configuraciones['ipv6'] = self.ipv6(src_node_ipv6, dst_node_ipv6) + try: + src6_obj = ipaddress.ip_address(src_node_ipv6) + dst6_obj = ipaddress.ip_address(dst_node_ipv6) + if isinstance(src6_obj, ipaddress.IPv6Address) and isinstance(dst6_obj, ipaddress.IPv6Address): + configuraciones["ipv6"] = self.ipv6(src_node_ipv6, dst_node_ipv6) + except ValueError: + pass # VLAN - if vlan_id: - configuraciones['vlan'] = self.vlan(int(vlan_id)) + if vlan_id is not None: + configuraciones["vlan"] = self.vlan(int(vlan_id)) # Policer - if bandwidth: - configuraciones['policer'] = self.policer(bandwidth) + if bandwidth is not None: + configuraciones["policer"] = self.policer(bandwidth) - # Latencia - if latency: - if float(latency) > 0: - configuraciones['ethernetDelay'] = self.delay_gui(float(latency), latency_version, float(tolerance)) + # Latency + if latency is not None and float(latency) > 0: + max_lat = float(tolerance) if tolerance is not None else 0.0 + configuraciones["ethernetDelay"] = self.delay_gui(float(latency), latency_version, max_lat) # Packet Reorder - if packet_reorder: - configuraciones['reorder'] = self.packetReorder(num_reorder, pack_reorder, num_pack, max_reorder, packet_reorder, desv_reorder) + if packet_reorder is not None: + configuraciones["reorder"] = self.packetReorder( + num_reorder, pack_reorder, num_pack, max_reorder, packet_reorder, desv_reorder + ) - # Packet Reorder when reliability - if reliability: - configuraciones['reorder'] = self.packetReorder(int(reliability)) + # Reliability + if reliability is not None: + configuraciones["reorder"] = self.packetReorder(int(reliability)) - #Dropper + # Dropper if drop_version: - configuraciones['packetDrop'] = self.packetDrop(drops, packets_drop, drop_version, desv_drop) + configuraciones["packetDrop"] = self.packetDrop(drops, packets_drop, drop_version, desv_drop) - # Agregar perfil - num_profiles = len(dataProfile.get("profiles", [])) + # Append new profile + num_profiles = len(data_profile.get("profiles", [])) configuracion_perfil = self.configuracion_total(configuraciones, f"profile{num_profiles + 1}") - dataProfile['profiles'].append(configuracion_perfil) - logging.info(f"Configuración del perfil: {configuracion_perfil}") - - # Send the configuration - automatizacion.envio_peticion(ip, puerto, dataProfile) + data_profile.setdefault("profiles", []).append(configuracion_perfil) + logger.info("Configuración del perfil: %s", configuracion_perfil) + + automatizacion.envio_peticion(ip, puerto, data_profile) return automatizacion.obtener_informacion_puerto(ip, puerto) - - - ## PORT CONFIGURATION FUNCTIONS ## - - def delay(self,delay_perfil): - ''' - Inputs: -delay_perfil: the delay we want to configurate. - Outputs: the information of the delay for the controller. - Work: Creates the configuration JSON for delay of the controller. - - ''' - delay_perfil = input("Enter the delay you want to set in the test_api profile: ") - print(f"delay en main: {delay_perfil}") - delay_type = input("Select one option of stadistics:\n1)None\t2)Gaussian\n3)Internet\n(Type the number)") - configuracion_delay = automatizacion.añadir_configuracion_puerto_delay(delay_perfil,delay_type) - print(f"Config delay:\n{configuracion_delay}") - return configuracion_delay - - def delay_gui(self,delay_perfil, latency_version, max_latency): - ''' - Inputs: -delay_perfil: the delay we want to configurate. - Outputs: the information of the delay for the controller. - Work: Creates the configuration JSON for delay of the controller. - - ''' - print(f'\nPero: {max_latency}\n') - configuracion_delay = automatizacion.añadir_configuracion_puerto_delay(delay_perfil,latency_version,max_latency) - return configuracion_delay - - def ipv4(self,source_ip,destination_ip,prt): - ''' - Inputs: -source_ip: the source IPv4 we want to configurate. - -destination_ip: the destination IPv4 we want to configurate. - -prt: the protocol we want to configurate - Outputs: the information of the IPv4 for the controller. - Work: Creates the configuration JSON for IPv4 of the controller. - Notes: by default, the protocol is TCP (6). - - ''' - source_hx=None - destination_hx=None - protocolo=None - if source_ip: - source_hx=hex(int(ipaddress.IPv4Address(source_ip)))[2:] - if destination_ip: - destination_hx=hex(int(ipaddress.IPv4Address(destination_ip)))[2:] - if prt: - protocolo="4" if prt=="IP" else "6" - configuracion_puerto=automatizacion.añadir_configuracion_puerto_ipv4(source_hx, destination_hx, protocolo) - return configuracion_puerto - - def ipv6(self,source, destination): - ''' - Inputs: -source: the source IPv6 we want to configurate. - -destination: the destination IPv6 we want to configurate. - Outputs: the information of the IPv6 for the controller. - Work: Creates the configuration JSON for IPv6 of the controller. - - ''' - if source: - source=ipaddress.IPv6Address(source).exploded.replace(":", "") - elif not source: source=None - if destination: - destination=ipaddress.IPv6Address(destination).exploded.replace(":", "") - elif not destination: destination=None - configuracion_puerto=automatizacion.añadir_configuracion_puerto_ipv6(source, destination) - return configuracion_puerto - - def vlan(self,vlan_id): - ''' - Inputs: -vlan_id: the VLAN we want to configurate. - Outputs: the information of the VLAN for the controller. - Work: Creates the configuration JSON for VLAN of the controller. - - ''' - configuracion_vlan=automatizacion.añadir_configuracion_VLAN(vlan_id) - return configuracion_vlan - - def packetDrop(self, drop, total,version,dev): - ''' - Inputs: - Outputs: the information of the packet drop configuration for the controller. - Work: Creates the configuration JSON for packet drop of the controller. - - ''' - configuracionPD=automatizacion.añadir_configuración_packetDrop(drop,total,version,dev) - return configuracionPD - - def policer(self,bandwidth): - ''' - Inputs: -bandwidth: the TX bandwdth we want to configurate. - Outputs: the information of the TX bandwidth for the controller. - Work: Creates the configuration JSON for the TX bandwidth of the controller. - - ''' - configuracion_policer=automatizacion.añadir_configuracion_policer(bandwidth) - return configuracion_policer - - def shaper(self): - ''' - Inputs: - Outputs: the information of the RX bandwidth for the controller. - Work: Creates the configuration JSON for the RX bandwidth of the controller. - - ''' - bandwidth=int(input('Introduzca el ancho de banda (Kbps): ')) - configuracion_policer=automatizacion.añadir_configuracion_shaper(bandwidth) - return configuracion_policer - - def packetReorder(self,reorder,packages=None, npackagesreorder=None, maxreord=None, version=None, stev=None): - print(f"\n\n\nNEII ANTES DEL PASO\nPACKAGES:{packages}\nREORDER:{reorder}\nMAXREORD:{maxreord}\nNPACKAGESORDER:{npackagesreorder}\n\n\n") - configuracion_reorder=automatizacion.añadir_configuracion_reorder(packages,reorder,npackagesreorder, maxreord, version, stev) - return configuracion_reorder - - def configuracion_total(self,configuraciones, nombre_perfil): - ''' - Inputs: -configuraciones: the informtion of all the configurations for the controller. - -nombre_perfil: the name of the profile where the information is going to be allocated. - Outputs: the information of a configurated profile. - Work: Creates the configuration JSON for a profile for the controller. - - ''' - perfil = { - 'tag': nombre_perfil, - 'dramAllocation': {}, - 'rules': [], - 'ethernetDelay': {'enabled': False}, - 'packetDrop': {'enabled': False}, - 'enabled':True + + def existentes_auto(self, ip: str, puerto: str) -> dict[str, Any] | None: + """Fetch port profile information.""" + info = automatizacion.obtener_informacion_puerto(ip, puerto) + if info: + logger.debug("Port info retrieved for %s:%s", ip, puerto) + return info + + def delay_gui( + self, + delay_perfil: float, + latency_version: str | None, + max_latency: float | None = None, + ) -> dict[str, Any]: + """Generate delay profile configuration dictionary.""" + return automatizacion.añadir_configuracion_puerto_delay(delay_perfil, latency_version, max_latency) + + def ipv4(self, source_ip: str | None, destination_ip: str | None, prt: str | None) -> dict[str, Any]: + """Generate IPv4 configuration dictionary.""" + source_hx = hex(int(ipaddress.IPv4Address(source_ip)))[2:] if source_ip else None + destination_hx = hex(int(ipaddress.IPv4Address(destination_ip)))[2:] if destination_ip else None + protocolo = "4" if prt == "IP" else ("6" if prt else None) + return automatizacion.añadir_configuracion_puerto_ipv4(source_hx, destination_hx, protocolo) + + def ipv6(self, source: str | None, destination: str | None) -> dict[str, Any]: + """Generate IPv6 configuration dictionary.""" + src_hx = ipaddress.IPv6Address(source).exploded.replace(":", "") if source else None + dst_hx = ipaddress.IPv6Address(destination).exploded.replace(":", "") if destination else None + return automatizacion.añadir_configuracion_puerto_ipv6(src_hx, dst_hx) + + def vlan(self, vlan_id: int) -> dict[str, Any]: + """Generate VLAN configuration dictionary.""" + return automatizacion.añadir_configuracion_VLAN(vlan_id) + + def packetDrop( + self, + drop: int | None = 0, + total: int | None = 100, + version: str = "PERIODIC", + dev: float = 10.0, + ) -> dict[str, Any]: + """Generate packet drop configuration dictionary.""" + return automatizacion.añadir_configuración_packetDrop(drop, total, version, dev) + + def policer(self, bandwidth: int) -> dict[str, Any]: + """Generate policer configuration dictionary.""" + return automatizacion.añadir_configuracion_policer(bandwidth) + + def shaper(self, bandwidth: int = 1000) -> dict[str, Any]: + """Generate shaper configuration dictionary.""" + return automatizacion.añadir_configuracion_shaper(bandwidth) + + def packetReorder( + self, + reorder: Any, + packages: Any = None, + npackagesreorder: Any = None, + maxreord: Any = None, + version: Any = None, + stev: Any = None, + ) -> dict[str, Any]: + """Generate packet reorder configuration dictionary.""" + return automatizacion.añadir_configuracion_reorder(packages, reorder, npackagesreorder, maxreord, version, stev) + + def configuracion_total(self, configuraciones: dict[str, Any], nombre_perfil: str) -> dict[str, Any]: + """Combine all individual feature configurations into a single NEII profile.""" + perfil: dict[str, Any] = { + "tag": nombre_perfil, + "dramAllocation": {}, + "rules": [], + "ethernetDelay": {"enabled": False}, + "packetDrop": {"enabled": False}, + "enabled": True, } - if 'ipv4' in configuraciones: - ipv4_config = configuraciones['ipv4'] - perfil['dramAllocation'] = ipv4_config['profiles'][0]['dramAllocation'] - perfil['rules'].extend(ipv4_config['profiles'][0]['rules']) - if 'ipv6' in configuraciones: - ipv6_config = configuraciones['ipv6'] - perfil['rules'].extend(ipv6_config['profiles'][0]['rules']) - if 'vlan' in configuraciones: - vlan_config = configuraciones['vlan'] - perfil['rules'].extend(vlan_config['profiles'][0]['rules']) - if 'ethernetDelay' in configuraciones: - perfil['ethernetDelay'] = configuraciones['ethernetDelay']['ethernetDelay'] - if 'packetDrop' in configuraciones: - perfil['packetDrop'] = configuraciones['packetDrop']['packetDrop'] - if 'policer' in configuraciones: - perfil['policer']=configuraciones['policer']['policer'] - if 'shaper' in configuraciones: - perfil['shaper']=configuraciones['shaper']['shaper'] - if 'reorder' in configuraciones: - perfil['reorder']=configuraciones['reorder']['reorder'] + if "ipv4" in configuraciones: + ipv4_config = configuraciones["ipv4"] + perfil["dramAllocation"] = ipv4_config["profiles"][0]["dramAllocation"] + perfil["rules"].extend(ipv4_config["profiles"][0]["rules"]) + if "ipv6" in configuraciones: + ipv6_config = configuraciones["ipv6"] + perfil["rules"].extend(ipv6_config["profiles"][0]["rules"]) + if "vlan" in configuraciones: + vlan_config = configuraciones["vlan"] + perfil["rules"].extend(vlan_config["profiles"][0]["rules"]) + if "ethernetDelay" in configuraciones: + perfil["ethernetDelay"] = configuraciones["ethernetDelay"]["ethernetDelay"] + if "packetDrop" in configuraciones: + perfil["packetDrop"] = configuraciones["packetDrop"]["packetDrop"] + if "policer" in configuraciones: + perfil["policer"] = configuraciones["policer"]["policer"] + if "shaper" in configuraciones: + perfil["shaper"] = configuraciones["shaper"]["shaper"] + if "reorder" in configuraciones: + perfil["reorder"] = configuraciones["reorder"]["reorder"] return perfil - -if __name__=="__main__": - controller=NEII_controller() - controller.menu_principal() \ No newline at end of file diff --git a/src/realizer/ixia/helpers/automatizacion_ne2v4.py b/src/realizer/ixia/helpers/automatizacion_ne2v4.py index 183e37f..1e683c1 100644 --- a/src/realizer/ixia/helpers/automatizacion_ne2v4.py +++ b/src/realizer/ixia/helpers/automatizacion_ne2v4.py @@ -1,11 +1,11 @@ # Copyright 2022-2026 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. @@ -14,455 +14,314 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. -import requests - - -class automatizacion: - def obtener_informacion_ip(ip): - ''' - Gets the information IP of the NE2 - Args: - ip: IP. - Returns: - A dictionary with the IP infotmationx. - ''' - url= "http://"+ip+"/api/actions/ipInfo" - body= {"ip": ip} - response = requests.get(url, json=body, auth=('admin', 'admin')) - if response.status_code==200: - return response.json() - else: - print(f"error al obtener la informacion de la IP; {response.status_code}") - return None - - def obtener_informacion_hardware(ip): - """ - Obtiene información de una dirección IP. - - Args: - ip: La dirección IP del NE2. - - Returns: - Un diccionario con la información de IP. - """ - url = "http://"+ip+"/api/actions/hwInfo" - body = {"ip": ip} - response = requests.get(url, json=body, auth=('admin', 'admin')) - if response.status_code == 200: - return response.json() - else: - print(f"Error al obtener la información de la IP: {response.status_code}") - return None - - def obtener_informacion_puerto(ip,puerto): - """ - Obtiene información de una dirección IP. - - Args: - ip: La dirección IP del NE2. - - Returns: - Un diccionario con la información de IP. - """ - url = "http://"+ip+"/api/hw/Port/"+puerto - body = {"ip": ip} - response = requests.get(url, json=body, auth=('admin', 'admin')) - if response.status_code == 200: - return response.json() - else: - print(f"Error al obtener la información de la IP: {response.status_code}") - return None - - def añadir_configuracion_puerto_delay(delay, latency_type, max_latency): - """ - Añade una configuración de puerto según su delay a un NE2. - - Args: - delay (int): Cantidad de delay en la simulación. - - Returns: - La respuesta de la API (texto). - """ - configuracion=None - print(f'\n\nTipo de latencia: {latency_type}\n latencia: {delay}\n') - if latency_type=='1' or latency_type==None: - configuracion={'ethernetDelay': {'delay': delay, 'delayMax': 15.0, 'isUncorrelated': False, 'maxNegDelta': 0.1, 'pdvMode': 'NONE', 'delayMin': 5.0, 'units': 'MS', 'maxPosDelta': 0.1, 'enabled': True, 'spread': 1.0}} - if latency_type=='2' or latency_type=='gauss': - delay_f=float(delay) - ancho=float(max_latency) - max_delay=delay_f+ancho - min_delay=delay_f-ancho - configuracion={'ethernetDelay': {'delay': delay, 'delayMax': max_delay, 'isUncorrelated': False,'maxNegDelta': ancho/3, 'pdvMode': 'GAUSSIAN', 'delayMin':min_delay, 'units': 'MS', 'maxPosDelta':ancho/3, 'enabled': True, 'spread': 1.58}} - if latency_type =='3' or latency_type=='internet': - ancho=float(max_latency) - max_delay = float(delay)+0.9*float(ancho) - min_delay = float(delay)-0.1*float(ancho) - configuracion={'ethernetDelay': {'delay': delay, 'delayMax': max_delay, 'isUncorrelated': False, 'maxNegDelta': 0.4, 'pdvMode': 'INTERNET', 'delayMin': min_delay, 'units': 'MS', 'maxPosDelta': 0.5, 'enabled': True, 'spread': 100.0}} - print(f"\n\nConf Delay: {configuracion}\n") - return configuracion - - def añadir_configuracion_puerto_ipv4(source_hx,destination_hx,protocolo): - """ - Añade una configuración de puerto según las IPv4s de origen y destino de la comunicación. - - Args: - source_hx (int): Dirección IPv4 de origen en hexadecimal. - destination_hx (int): Dirección IPv4 de destino en hexadecimal. - protocolo (string): Numero asociado al protocolo elegido. - - Returns: - La respuesta de la API (texto). - """ - reglas = [{"field": "Common::IPv4::Version", "value": "4", "mask": "f"}] - if source_hx: - reglas.append({"field": "Common::IPv4::Source Address", "value": source_hx, "mask": "ffffffff"}) - if destination_hx: - reglas.append({"field": "Common::IPv4::Destination Address", "value": destination_hx, "mask": "ffffffff"}) - if protocolo: - reglas.append({"field": "Common::IPv4::Protocol", "value": protocolo, "mask": "ff"}) - configuracion = {"profiles": [{"dramAllocation": {"mode": "AUTO","fixedSize": 1700352},"rules": reglas,"tag": "test_api"}],"defaultProfile": {"dramAllocation": {"mode": "AUTO","fixedSize": 1700352},"tag": "defaultProfile"}} - return configuracion - - def añadir_configuracion_puerto_ipv6(source,destination): - """ - Añade una configuración de puerto según las IPv6s de origen y destino de la comunicación. - - Args: - source (int): Dirección IPv6 de origen en hexadecimal. - destination (int): Dirección IPv6 de destino en hexadecimal. - - Returns: - La respuesta de la API (texto). - """ - reglas=[{'field': 'Common::IPv6::Version', 'bitRange': 'L3@0[7]+3', 'value': '6', 'mask': 'f'}] - if source: - reglas.append({'field': 'Common::IPv6::Source Address', 'bitRange': 'L3@8[7]+127', 'value': source, 'mask': 'ffffffffffffffffffffffffffffffff'}) - if destination: - reglas.append({'field': 'Common::IPv6::Destination Address', 'bitRange': 'L3@24[7]+127', 'value': destination, 'mask': 'ffffffffffffffffffffffffffffffff'}) - configuracion = {"profiles": [{"dramAllocation": {"mode": "AUTO","fixedSize": 1700352},"rules": reglas,"tag": "test_api"}],"defaultProfile": {"dramAllocation": {"mode": "AUTO","fixedSize": 1700352},"tag": "defaultProfile"}} - return configuracion - - def añadir_configuracion_VLAN(vlan): - """ - Añade una configuración de puerto según su VLAN. - - Args: - vlan (int): Número de identificación de la VLAN. - - Returns: - La respuesta de la API (texto). - """ - vlan=hex(vlan)[2:] - configuracion={'profiles': [{'dramAllocation': {'mode': 'AUTO', 'fixedSize': 1700352}, 'rules': [{'field': 'Common::Second Tag::VLAN ID', 'bitRange': 'L2@18[3]+11', 'value': vlan, 'mask': 'fff'}]}]} - return configuracion - - def añadir_configuración_packetDrop(drops,total): - """ - Añade una configuración de puerto según su configuracion de packet Drop un NE2. - - Args: - drops (int): cantidad de paquetes dropeados - total (int): cantidad total de paquetes - - Returns: - La respuesta de la API (texto). - """ - configuracion={'packetDrop': {'rdmSel': {'dist': 'PERIODIC', 'burstlen': drops, 'interval': total, 'stddev': 10.0}, 'enabled': True}} - return configuracion - - def añadir_configuracion_policer(bitrate): - """ - Añade una configuración de puerto según su TX bandwidth a un NE2. +"""Automation helper for interacting with IXIA NEII emulator API.""" - Args: - bitrate (int): bandwith en Tx +from __future__ import annotations - Returns: - La respuesta de la API (texto). - """ - configuracion={'policer':{'excessBitRate': bitrate, 'excessBurstTolerance': 64000, 'commitedBurstTolerance': 64000, 'commitedBitRate': bitrate, 'enabled': True, 'enableRateCoupling': False}} - return configuracion - - def añadir_configuracion_shaper(bitrate): - """ - Añade una configuración de puerto según su RX bandwidth a un NE2. +import logging +from typing import Any - Args: - bitrate (int): bandwith en Rx - - Returns: - La respuesta de la API (texto). - """ - configuracion={'shaper': {'burstTolerance': 64000, 'bitRate': bitrate, 'enabled': True}} - return configuracion - - def añadir_configuracion_reorder(reorder): - """ - Adds reorder configuration. - - Args: - reorder (int): reorder - - Returns: - response of the API (texto). - """ - reorder=100-reorder - print(f"\nReorder:{reorder}\n") - configuracion={"reorder": {"rdmSel": {"dist": "PERIODIC", "burstlen": reorder, "interval": 100, "stddev": 10.0 }, "reorderByMin": 1, "reorderByMax": 5, "enabled": True}} #Los demas valores los he dejado como defecto por no poder configurarlos - return configuracion - - def envio_peticion(ip,puerto, configuracion): - """ - Envía una configuración de puerto a un NE2. +import requests - Args: - ip (int): IP del NE2 - puerto: número del puerto a configurar - configuración: la información que se quiere configurar en el puerto +logger = logging.getLogger(__name__) - Returns: - La respuesta de la API (texto). - """ class automatizacion: - def obtener_informacion_ip(ip): - ''' - obtiene informacion ip del NE2 - Args: - ip: la direccion IP del NE2. + """Helper methods for constructing IXIA NEII API payloads and sending HTTP requests.""" - Returns: - un diccionario con la informacion de IP. - ''' - url= "http://"+ip+"/api/actions/ipInfo" - body= {"ip": ip} - response = requests.get(url, json=body, auth=('admin', 'admin')) - if response.status_code==200: - return response.json() - else: - print(f"error al obtener la informacion de la IP; {response.status_code}") + @staticmethod + def obtener_informacion_ip(ip: str) -> dict[str, Any] | None: + """Get IP information from the NEII emulator.""" + url = f"http://{ip}/api/actions/ipInfo" + body = {"ip": ip} + try: + response = requests.get(url, json=body, auth=("admin", "admin"), timeout=30) + if response.status_code == 200: + return response.json() + logger.error("Error retrieving IP info from %s: status %s", ip, response.status_code) + return None + except Exception as e: + logger.error("Failed to get IP info from %s: %s", ip, e) return None - - def obtener_informacion_hardware(ip): - """ - Obtiene información de una dirección IP. - - Args: - ip: La dirección IP del NE2. - Returns: - Un diccionario con la información de IP. - """ - url = "http://"+ip+"/api/actions/hwInfo" + @staticmethod + def obtener_informacion_hardware(ip: str) -> dict[str, Any] | None: + """Get hardware info from the NEII emulator.""" + url = f"http://{ip}/api/actions/hwInfo" body = {"ip": ip} - response = requests.get(url, json=body, auth=('admin', 'admin')) - if response.status_code == 200: - return response.json() - else: - print(f"Error al obtener la información de la IP: {response.status_code}") + try: + response = requests.get(url, json=body, auth=("admin", "admin"), timeout=30) + if response.status_code == 200: + return response.json() + logger.error("Error retrieving HW info from %s: status %s", ip, response.status_code) + return None + except Exception as e: + logger.error("Failed to get HW info from %s: %s", ip, e) return None - - def obtener_informacion_puerto(ip,puerto): - """ - Obtiene información de una dirección IP. - - Args: - ip: La dirección IP del NE2. - Returns: - Un diccionario con la información de IP. - """ - url = "http://"+ip+"/api/hw/Port/"+puerto + @staticmethod + def obtener_informacion_puerto(ip: str, puerto: str) -> dict[str, Any] | None: + """Get port configuration and status from the NEII emulator.""" + url = f"http://{ip}/api/hw/Port/{puerto}" body = {"ip": ip} - response = requests.get(url, json=body, auth=('admin', 'admin')) - if response.status_code == 200: - return response.json() - else: - print(f"Error al obtener la información de la IP: {response.status_code}") + try: + response = requests.get(url, json=body, auth=("admin", "admin"), timeout=30) + if response.status_code == 200: + return response.json() + logger.error("Error retrieving Port %s info from %s: status %s", puerto, ip, response.status_code) + return None + except Exception as e: + logger.error("Failed to get Port info from %s: %s", ip, e) return None - - def añadir_configuracion_puerto_delay(delay, latency_type, max_latency): - """ - Añade una configuración de puerto según su delay a un NE2. - - Args: - delay (int): Cantidad de delay en la simulación. - - Returns: - La respuesta de la API (texto). - """ - configuracion=None - print(f'\n\nTipo de latencia: {latency_type}\n latencia: {delay}\n') - if latency_type=='1' or latency_type==None: - configuracion={'ethernetDelay': {'delay': delay, 'delayMax': 15.0, 'isUncorrelated': False, 'maxNegDelta': 0.1, 'pdvMode': 'NONE', 'delayMin': 5.0, 'units': 'MS', 'maxPosDelta': 0.1, 'enabled': True, 'spread': 1.0}} - if latency_type=='2' or latency_type=='gauss': - delay_f=float(delay) - ancho=float(max_latency) - max_delay=delay_f+ancho - min_delay=delay_f-ancho - configuracion={'ethernetDelay': {'delay': delay, 'delayMax': max_delay, 'isUncorrelated': False,'maxNegDelta': ancho/3, 'pdvMode': 'GAUSSIAN', 'delayMin':min_delay, 'units': 'MS', 'maxPosDelta':ancho/3, 'enabled': True, 'spread': 1.58}} - if latency_type =='3' or latency_type=='internet': - ancho=float(max_latency) - max_delay = float(delay)+0.9*float(ancho) - min_delay = float(delay)-0.1*float(ancho) - configuracion={'ethernetDelay': {'delay': delay, 'delayMax': max_delay, 'isUncorrelated': False, 'maxNegDelta': 0.4, 'pdvMode': 'INTERNET', 'delayMin': min_delay, 'units': 'MS', 'maxPosDelta': 0.5, 'enabled': True, 'spread': 100.0}} - print(f"\n\nConf Delay: {configuracion}\n") - return configuracion - - def añadir_configuracion_puerto_ipv4(source_hx,destination_hx,protocolo): - """ - Añade una configuración de puerto según las IPv4s de origen y destino de la comunicación. - - Args: - source_hx (int): Dirección IPv4 de origen en hexadecimal. - destination_hx (int): Dirección IPv4 de destino en hexadecimal. - protocolo (string): Numero asociado al protocolo elegido. - Returns: - La respuesta de la API (texto). - """ - reglas = [{"field": "Common::IPv4::Version", "value": "4", "mask": "f"}] + @staticmethod + def añadir_configuracion_puerto_delay( + delay: float | int | str, + latency_type: str | None, + max_latency: float | int | str | None = None, + ) -> dict[str, Any]: + """Generate delay profile configuration dictionary.""" + delay_val = float(delay) + if latency_type in ("2", "gauss") and max_latency is not None: + spread = float(max_latency) + return { + "ethernetDelay": { + "delay": delay_val, + "delayMax": delay_val + spread, + "isUncorrelated": False, + "maxNegDelta": spread / 3, + "pdvMode": "GAUSSIAN", + "delayMin": delay_val - spread, + "units": "MS", + "maxPosDelta": spread / 3, + "enabled": True, + "spread": 1.58, + } + } + if latency_type in ("3", "internet") and max_latency is not None: + spread = float(max_latency) + return { + "ethernetDelay": { + "delay": delay_val, + "delayMax": delay_val + 0.9 * spread, + "isUncorrelated": False, + "maxNegDelta": 0.4, + "pdvMode": "INTERNET", + "delayMin": delay_val - 0.1 * spread, + "units": "MS", + "maxPosDelta": 0.5, + "enabled": True, + "spread": 100.0, + } + } + return { + "ethernetDelay": { + "delay": delay_val, + "delayMax": 15.0, + "isUncorrelated": False, + "maxNegDelta": 0.1, + "pdvMode": "NONE", + "delayMin": 5.0, + "units": "MS", + "maxPosDelta": 0.1, + "enabled": True, + "spread": 1.0, + } + } + + @staticmethod + def añadir_configuracion_puerto_ipv4( + source_hx: str | None, + destination_hx: str | None, + protocolo: str | None, + ) -> dict[str, Any]: + """Generate IPv4 matching rule configuration dictionary.""" + reglas: list[dict[str, Any]] = [{"field": "Common::IPv4::Version", "value": "4", "mask": "f"}] if source_hx: reglas.append({"field": "Common::IPv4::Source Address", "value": source_hx, "mask": "ffffffff"}) if destination_hx: reglas.append({"field": "Common::IPv4::Destination Address", "value": destination_hx, "mask": "ffffffff"}) if protocolo: reglas.append({"field": "Common::IPv4::Protocol", "value": protocolo, "mask": "ff"}) - configuracion = {"profiles": [{"dramAllocation": {"mode": "AUTO","fixedSize": 1700352},"rules": reglas,"tag": "test_api"}],"defaultProfile": {"dramAllocation": {"mode": "AUTO","fixedSize": 1700352},"tag": "defaultProfile"}} - return configuracion - def añadir_configuracion_puerto_ipv6(source,destination): - """ - Añade una configuración de puerto según las IPv6s de origen y destino de la comunicación. - - Args: - source (int): Dirección IPv6 de origen en hexadecimal. - destination (int): Dirección IPv6 de destino en hexadecimal. - - Returns: - La respuesta de la API (texto). - """ - reglas=[{'field': 'Common::IPv6::Version', 'bitRange': 'L3@0[7]+3', 'value': '6', 'mask': 'f'}] + return { + "profiles": [ + { + "dramAllocation": {"mode": "AUTO", "fixedSize": 1700352}, + "rules": reglas, + "tag": "test_api", + } + ], + "defaultProfile": { + "dramAllocation": {"mode": "AUTO", "fixedSize": 1700352}, + "tag": "defaultProfile", + }, + } + + @staticmethod + def añadir_configuracion_puerto_ipv6( + source: str | None, + destination: str | None, + ) -> dict[str, Any]: + """Generate IPv6 matching rule configuration dictionary.""" + reglas: list[dict[str, Any]] = [ + {"field": "Common::IPv6::Version", "bitRange": "L3@0[7]+3", "value": "6", "mask": "f"} + ] if source: - reglas.append({'field': 'Common::IPv6::Source Address', 'bitRange': 'L3@8[7]+127', 'value': source, 'mask': 'ffffffffffffffffffffffffffffffff'}) + reglas.append( + { + "field": "Common::IPv6::Source Address", + "bitRange": "L3@8[7]+127", + "value": source, + "mask": "ffffffffffffffffffffffffffffffff", + } + ) if destination: - reglas.append({'field': 'Common::IPv6::Destination Address', 'bitRange': 'L3@24[7]+127', 'value': destination, 'mask': 'ffffffffffffffffffffffffffffffff'}) - configuracion = {"profiles": [{"dramAllocation": {"mode": "AUTO","fixedSize": 1700352},"rules": reglas,"tag": "test_api"}],"defaultProfile": {"dramAllocation": {"mode": "AUTO","fixedSize": 1700352},"tag": "defaultProfile"}} - return configuracion - - def añadir_configuracion_VLAN(vlan): - """ - Añade una configuración de puerto según su VLAN. - - Args: - vlan (int): Número de identificación de la VLAN. - - Returns: - La respuesta de la API (texto). - """ - vlan=hex(vlan)[2:] - configuracion={'profiles': [{'dramAllocation': {'mode': 'AUTO', 'fixedSize': 1700352}, 'rules': [{'field': 'Common::Second Tag::VLAN ID', 'bitRange': 'L2@18[3]+11', 'value': vlan, 'mask': 'fff'}]}]} - return configuracion - - def añadir_configuración_packetDrop(drops,total,version,desv): - """ - Añade una configuración de puerto según su configuracion de packet Drop un NE2. - - Args: - drops (int): cantidad de paquetes dropeados - total (int): cantidad total de paquetes - version (string): version of the probability - - Returns: - La respuesta de la API (texto). - """ - if version != "GAUSSIAN" and version !="POISSON": - configuracion={'packetDrop': {'rdmSel': {'dist': version, 'burstlen': drops, 'interval': total, 'stddev': 10.0}, 'enabled': True}} - else: - configuracion={'packetDrop': {'rdmSel': {'dist': version, 'burstlen': drops, 'interval': total, 'stddev': desv}, 'enabled': True}} - return configuracion - - def añadir_configuracion_policer(bitrate): - """ - Añade una configuración de puerto según su TX bandwidth a un NE2. - - Args: - bitrate (int): bandwith en Tx - - Returns: - La respuesta de la API (texto). - """ - configuracion={'policer':{'excessBitRate': bitrate, 'excessBurstTolerance': 64000, 'commitedBurstTolerance': 64000, 'commitedBitRate': bitrate, 'enabled': True, 'enableRateCoupling': False}} - return configuracion - - def añadir_configuracion_shaper(bitrate): - """ - Añade una configuración de puerto según su RX bandwidth a un NE2. - - Args: - bitrate (int): bandwith en Rx - - Returns: - La respuesta de la API (texto). - """ - configuracion={'shaper': {'burstTolerance': 64000, 'bitRate': bitrate, 'enabled': True}} - return configuracion - - def añadir_configuracion_reorder(packages,reorder,npackagesreorder, maxreord, version, stev): - """ - Adds reorder configuration. - - Args: - packages: number of packages total - reorder: number of packages to reorder - npackagesreorder: number of packages of reorder - maxreord: total number of packages of reorder - version: version of reorder - stev: desviation - - Returns: - response of the API (text). - """ - if packages is not None and npackagesreorder is not None and maxreord is not None: - reorderByMin = min(npackagesreorder, maxreord) - reorderByMax = max(npackagesreorder, maxreord) - if version != 'GAUSSIAN': - configuracion={"reorder": {"rdmSel": {"dist": version, "burstlen": reorder, "interval": packages, "stddev": 10.0 }, "reorderByMin": reorderByMin, "reorderByMax": reorderByMax, "enabled": True}} - else: - configuracion={"reorder": {"rdmSel": {"dist": "GAUSSIAN", "burstlen": reorder, "interval": packages, "stddev": stev }, "reorderByMin": reorderByMin, "reorderByMax": reorderByMax, "enabled": True}} - if int(reorder) <= 0: - configuracion["filterWarning"] = "El valor de reorder es inválido, está fuera del rango permitido." - else: - reorder=10000-reorder - configuracion={"reorder": {"rdmSel": {"dist": "PERIODIC", "burstlen": int(reorder/100), "interval": 100, "stddev": 10.0 }, "reorderByMin": 1, "reorderByMax": 5, "enabled": True}} - return configuracion - - def envio_peticion(ip,puerto, configuracion): - """ - Envía una configuración de puerto a un NE2. - - Args: - ip (int): IP del NE2 - puerto: número del puerto a configurar - configuración: la información que se quiere configurar en el puerto - - Returns: - La respuesta de la API (texto). - """ - print(f'\nCONFIGURACION\n{configuracion}') + reglas.append( + { + "field": "Common::IPv6::Destination Address", + "bitRange": "L3@24[7]+127", + "value": destination, + "mask": "ffffffffffffffffffffffffffffffff", + } + ) + + return { + "profiles": [ + { + "dramAllocation": {"mode": "AUTO", "fixedSize": 1700352}, + "rules": reglas, + "tag": "test_api", + } + ], + "defaultProfile": { + "dramAllocation": {"mode": "AUTO", "fixedSize": 1700352}, + "tag": "defaultProfile", + }, + } + + @staticmethod + def añadir_configuracion_VLAN(vlan: int) -> dict[str, Any]: + """Generate VLAN matching configuration dictionary.""" + vlan_hex = hex(vlan)[2:] + return { + "profiles": [ + { + "dramAllocation": {"mode": "AUTO", "fixedSize": 1700352}, + "rules": [ + { + "field": "Common::Second Tag::VLAN ID", + "bitRange": "L2@18[3]+11", + "value": vlan_hex, + "mask": "fff", + } + ], + } + ] + } + + @staticmethod + def añadir_configuración_packetDrop( + drops: int | None = 0, + total: int | None = 100, + version: str = "PERIODIC", + desv: float = 10.0, + ) -> dict[str, Any]: + """Generate packet drop configuration dictionary.""" + stddev = desv if version in ("GAUSSIAN", "POISSON") else 10.0 + return { + "packetDrop": { + "rdmSel": { + "dist": version, + "burstlen": drops, + "interval": total, + "stddev": stddev, + }, + "enabled": True, + } + } + + @staticmethod + def añadir_configuracion_policer(bitrate: int) -> dict[str, Any]: + """Generate TX policer configuration dictionary.""" + return { + "policer": { + "excessBitRate": bitrate, + "excessBurstTolerance": 64000, + "commitedBurstTolerance": 64000, + "commitedBitRate": bitrate, + "enabled": True, + "enableRateCoupling": False, + } + } + + @staticmethod + def añadir_configuracion_shaper(bitrate: int) -> dict[str, Any]: + """Generate RX shaper configuration dictionary.""" + return { + "shaper": { + "burstTolerance": 64000, + "bitRate": bitrate, + "enabled": True, + } + } + + @staticmethod + def añadir_configuracion_reorder( + packages: int | None = None, + reorder: int | None = 0, + npackagesreorder: int | None = None, + maxreord: int | None = None, + version: str = "PERIODIC", + stev: float = 10.0, + ) -> dict[str, Any]: + """Generate packet reorder configuration dictionary.""" + if packages is not None and npackagesreorder is not None and maxreord is not None: + reorder_by_min = min(npackagesreorder, maxreord) + reorder_by_max = max(npackagesreorder, maxreord) + stddev = stev if version == "GAUSSIAN" else 10.0 + config: dict[str, Any] = { + "reorder": { + "rdmSel": { + "dist": version, + "burstlen": reorder, + "interval": packages, + "stddev": stddev, + }, + "reorderByMin": reorder_by_min, + "reorderByMax": reorder_by_max, + "enabled": True, + } + } + if (reorder or 0) <= 0: + config["filterWarning"] = "El valor de reorder es inválido, está fuera del rango permitido." + return config + + reorder_val = (10000 - (reorder or 0)) // 100 + return { + "reorder": { + "rdmSel": { + "dist": "PERIODIC", + "burstlen": int(reorder_val), + "interval": 100, + "stddev": 10.0, + }, + "reorderByMin": 1, + "reorderByMax": 5, + "enabled": True, + } + } + + @staticmethod + def envio_peticion(ip: str, puerto: str, configuracion: dict[str, Any]) -> str | None: + """Send port configuration payload to NEII emulator.""" url = f"http://{ip}/api/hw/Port/{puerto}" - response = requests.put(url, json=configuracion, auth=('admin', 'admin')) - - if response.status_code == 200: - print(f'\n{configuracion}') - return response.text - else: - try: - error_info = response.json() - except ValueError: - error_info = response.text - - print(f"\n\nError al añadir configuración de puerto: {response.status_code}") - print(f"Mensaje de error: {error_info}") - print(f"Configuración enviada: {configuracion}\n\n") - return None \ No newline at end of file + try: + response = requests.put(url, json=configuracion, auth=("admin", "admin"), timeout=30) + if response.status_code == 200: + return response.text + logger.error("Error setting port %s on %s: status %s", puerto, ip, response.status_code) + return None + except Exception as e: + logger.error("Failed to send port configuration to %s: %s", ip, e) + return None diff --git a/src/realizer/ixia/ixia_connect.py b/src/realizer/ixia/ixia_connect.py index 3b09566..c24459b 100644 --- a/src/realizer/ixia/ixia_connect.py +++ b/src/realizer/ixia/ixia_connect.py @@ -1,11 +1,11 @@ # Copyright 2022-2026 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. @@ -14,23 +14,27 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. +"""Connector sending configuration requests to IXIA NEII network emulator.""" + +from __future__ import annotations + +from typing import Any + from .helpers.NEII_V4 import NEII_controller -def ixia_connect(requests, ixia_ip): - """ - Connect to the IXIA NEII controller and send the requests. - +def ixia_connect(requests: dict[str, Any], ixia_ip: str) -> Any: + """Connect to the IXIA NEII controller and send slice intents. + Args: - requests (dict): IXIA NEII requests - ixia_ip (str): IXIA NEII controller IP address - + requests: Dictionary containing list of service intents under 'services'. + ixia_ip: IXIA NEII controller IP address. + Returns: - response (requests.Response): Response from the IXIA NEII controller + Response from the IXIA NEII controller. """ response = None neii_controller = NEII_controller(ixia_ip) - for intent in requests["services"]: - # Send each separate IXIA request + for intent in requests.get("services", []): response = neii_controller.nscNEII(intent) - return response \ No newline at end of file + return response diff --git a/src/realizer/ixia/main.py b/src/realizer/ixia/main.py index 37e0937..aae7c29 100644 --- a/src/realizer/ixia/main.py +++ b/src/realizer/ixia/main.py @@ -1,11 +1,11 @@ # Copyright 2022-2026 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. @@ -14,82 +14,85 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. +"""IXIA network emulator slice intent realizer.""" + +from __future__ import annotations + import logging +from typing import Any +from src.utils.safe_get import safe_get -def ixia(ietf_intent): - """ - Prepare an Ixia service request based on the IETF intent. +logger = logging.getLogger(__name__) - This method configures an Ixia service request by: - 1. Defining endpoint routers - 2. Loading a service template - 3. Generating a unique service UUID - 4. Configuring service endpoints - 5. Adding QoS constraints +_METRIC_BOUND_MAP = { + "one-way-bandwidth": "bandwidth", + "one-way-delay-maximum": "latency", + "one-way-delay-variation-maximum": "tolerance", +} - Args: - ietf_intent (dict): IETF-formatted network slice intent. - Returns: - dict: An Ixia service request for configuration. - """ - metric_bounds = ietf_intent.get("ietf-network-slice-service:network-slice-services", {}) \ - .get("slo-sle-templates", {}) \ - .get("slo-sle-template", [{}])[0] \ - .get("slo-policy", {}) \ - .get("metric-bound", []) - - # Inicializar valores - bandwidth = None - latency = None - tolerance = None - - # Assign values according to metric type +def _extract_metrics(metric_bounds: list[dict[str, Any]]) -> dict[str, Any]: + """Extract bandwidth, latency, and tolerance metrics from template metric bounds.""" + extracted: dict[str, Any] = { + "bandwidth": None, + "latency": None, + "tolerance": None, + } for metric in metric_bounds: metric_type = metric.get("metric-type") - bound = metric.get("bound") + target_key = _METRIC_BOUND_MAP.get(metric_type or "") + if target_key: + extracted[target_key] = metric.get("bound") + return extracted + + +def ixia(ietf_intent: dict[str, Any]) -> dict[str, Any]: + """Prepare an Ixia service request based on the IETF slice intent. - if metric_type == "one-way-bandwidth": - bandwidth = bound - elif metric_type == "one-way-delay-maximum": - latency = bound - elif metric_type == "one-way-delay-variation-maximum": - tolerance = bound + Args: + ietf_intent: IETF formatted network slice intent dictionary. + + Returns: + Structured intent configuration for IXIA NEII. + """ + root_services = ietf_intent.get("ietf-network-slice-service:network-slice-services", {}) + templates = root_services.get("slo-sle-templates", {}).get("slo-sle-template", [{}]) + first_template = templates[0] if templates else {} + + metric_bounds = first_template.get("slo-policy", {}).get("metric-bound", []) + metrics = _extract_metrics(metric_bounds) + + slice_services = root_services.get("slice-service", [{}]) + first_slice = slice_services[0] if slice_services else {} + sdps = first_slice.get("sdps", {}).get("sdp", [{}, {}]) + + sdp_src = sdps[0] if len(sdps) > 0 else {} + sdp_dst = sdps[1] if len(sdps) > 1 else {} + + src_node_ip = safe_get( + sdp_src, + ["attachment-circuits", "attachment-circuit", 0, "sdp-peering", "peer-sap-id"], + ) + dst_node_ip = safe_get( + sdp_dst, + ["attachment-circuits", "attachment-circuit", 0, "sdp-peering", "peer-sap-id"], + ) + vlan_id = safe_get( + sdp_src, + ["service-match-criteria", "match-criterion", 0, "match-type", 0, "vlan", 0], + ) - # Construction of the intent dictionary intent = { - "src_node_ip": ietf_intent.get("ietf-network-slice-service:network-slice-services", {}) - .get("slice-service", [{}])[0] - .get("sdps", {}).get("sdp", [{}])[0] - .get("attachment-circuits", {}).get("attachment-circuit", [{}])[0] - .get("sdp-peering", {}).get("peer-sap-id"), - - "dst_node_ip": ietf_intent.get("ietf-network-slice-service:network-slice-services", {}) - .get("slice-service", [{}])[0] - .get("sdps", {}).get("sdp", [{}, {}])[1] - .get("attachment-circuits", {}).get("attachment-circuit", [{}])[0] - .get("sdp-peering", {}).get("peer-sap-id"), - - "vlan_id": ietf_intent.get("ietf-network-slice-service:network-slice-services", {}) - .get("slice-service", [{}])[0] - .get("sdps", {}).get("sdp", [{}])[0] - .get("service-match-criteria", {}).get("match-criterion", [{}])[0] - .get("match-type", [{}])[0] - .get("vlan", [None])[0], - - "bandwidth": bandwidth, - "latency": latency, - "tolerance": tolerance, - - "latency_version": ietf_intent.get("ietf-network-slice-service:network-slice-services", {}) - .get("slo-sle-templates", {}).get("slo-sle-template", [{}])[0] - .get("description"), - - "reliability": ietf_intent.get("ietf-network-slice-service:network-slice-services", {}) - .get("slo-sle-templates", {}).get("slo-sle-template", [{}])[0] - .get("sle-policy", {}).get("reliability"), + "src_node_ip": src_node_ip, + "dst_node_ip": dst_node_ip, + "vlan_id": vlan_id, + "bandwidth": metrics["bandwidth"], + "latency": metrics["latency"], + "tolerance": metrics["tolerance"], + "latency_version": first_template.get("description"), + "reliability": first_template.get("sle-policy", {}).get("reliability"), } - logging.info("IXIA Intent realized\n") - return intent \ No newline at end of file + logger.info("IXIA Intent realized\n") + return intent diff --git a/src/realizer/main.py b/src/realizer/main.py index 75ef8ec..f65098d 100644 --- a/src/realizer/main.py +++ b/src/realizer/main.py @@ -14,6 +14,10 @@ # This file includes original contributions from Telefonica Innovación Digital S.L. +"""Main realizer entry point orchestrating slice creation, monitoring, and reconfiguration.""" + +from __future__ import annotations + import logging from typing import Any @@ -27,23 +31,28 @@ from .get_metrics import get_metrics from .nrp_handler import nrp_handler from .select_way import select_way +logger = logging.getLogger(__name__) + def _determine_e2e_way(rules: Any) -> str | None: """Determine the realization way for E2E controller based on rule actions.""" - if isinstance(rules, list) and len(rules) > 0: + if isinstance(rules, list) and rules: rules = rules[0] actions = rules.get("actions", []) if isinstance(rules, dict) else [] - has_transceiver = any(a.get("type", "").startswith("XR_AGENT_ACTIVATE_TRANSCEIVER") for a in actions) - has_optical = any(a.get("type", "").startswith("PROVISION_MEDIA_CHANNEL") for a in actions) - has_l3 = any(a.get("type", "").startswith("CONFIG_VPNL3") for a in actions) - has_l2 = any(a.get("type", "").startswith("CONFIG_VPNL2") for a in actions) + action_types = {a.get("type", "") for a in actions if isinstance(a, dict)} + + has_transceiver = any(t.startswith("XR_AGENT_ACTIVATE_TRANSCEIVER") for t in action_types) + has_optical = any(t.startswith("PROVISION_MEDIA_CHANNEL") for t in action_types) + has_l3 = any(t.startswith("CONFIG_VPNL3") for t in action_types) + has_l2 = any(t.startswith("CONFIG_VPNL2") for t in action_types) - del_transceiver = any(a.get("type", "").startswith("DEACTIVATE_XR_AGENT_TRANSCEIVER") for a in actions) - del_optical = any(a.get("type", "").startswith("DEPROVISION_OPTICAL_RESOURCE") for a in actions) - del_l3 = any(a.get("type", "").startswith("REMOVE_VPNL3") for a in actions) - del_l2 = any(a.get("type", "").startswith("REMOVE_VPNL2") for a in actions) + del_transceiver = any(t.startswith("DEACTIVATE_XR_AGENT_TRANSCEIVER") for t in action_types) + del_optical = any(t.startswith("DEPROVISION_OPTICAL_RESOURCE") for t in action_types) + del_l3 = any(t.startswith("REMOVE_VPNL3") for t in action_types) + del_l2 = any(t.startswith("REMOVE_VPNL2") for t in action_types) + # Creation mappings if has_transceiver or (has_optical and has_l3): return "L3oWDM" if has_optical and has_l2: @@ -55,6 +64,7 @@ def _determine_e2e_way(rules: Any) -> str | None: if has_l2: return "L2VPN" + # Deletion mappings if del_transceiver or (del_optical and del_l3): return "DEL_L3oWDM" if del_optical and del_l2: @@ -66,7 +76,7 @@ def _determine_e2e_way(rules: Any) -> str | None: if del_l2: return "DEL_L2VPN" - logging.warning("Cannot determine the realization way from rules. Skipping request.") + logger.warning("Cannot determine the realization way from rules. Skipping request.") return None @@ -89,18 +99,29 @@ def _realize_create( if not way: return None else: - way = service.get("way") 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}") + way = service.get("way") if isinstance(service, dict) else None + if not way and isinstance(service, dict): + way = safe_get( + service, + [ + "ietf-network-slice-service:network-slice-services", + "slice-service", + 0, + "service-tags", + "tag-type", + 0, + "tag-type-value", + 0, + ], + ) + + logger.info("Selected way: %s", way) return select_way(controller=controller_type, way=way, ietf_intent=service, response=response, rules=rules) def _realize_monitor(payload: dict[str, Any], controller_type: str | None) -> None: """Handle slice monitoring metric gathering.""" - logging.debug("Realizer action: MONITOR") + logger.debug("Realizer action: MONITOR") slice_id = payload.get("slice_id") service_data = get_data_by_slice_id(slice_id) if len(service_data) > 1: @@ -110,37 +131,37 @@ def _realize_monitor(payload: dict[str, Any], controller_type: str | None) -> No restconf_ip = current_app.config["RESTCONF_IP"] path, response_code = tfs_connector().get_service_path(restconf_ip, service_id) if response_code == 200: - logging.debug(f"Retrieved service path for slice '{slice_id}' (service '{service_id}'): {path}") + logger.debug("Retrieved service path for slice '%s' (service '%s'): %s", slice_id, service_id, path) get_metrics(path, slice_id, controller_type) else: - raise Exception("Error: Service path not retrieved") + raise RuntimeError("Error: Service path not retrieved") def _realize_reconfig(payload: Any) -> dict[str, Any]: """Handle slice reconfiguration path and topology retrieval.""" - logging.debug("Realizer action: RECONFIG") + logger.debug("Realizer action: RECONFIG") slice_id = payload.get("slice_id") if isinstance(payload, dict) else payload service_data = get_data_by_slice_id(slice_id) - logging.debug(f"DEBUG: Slice data found for slice '{slice_id}': {service_data}") + logger.debug("DEBUG: Slice data found for slice '%s': %s", slice_id, service_data) if not service_data: raise ValueError(f"No services found for slice '{slice_id}'") service_id = service_data[0]["service_id"] - logging.debug(f"DEBUG: Service ID for slice '{slice_id}': {service_id}") + logger.debug("DEBUG: Service ID for slice '%s': %s", slice_id, service_id) tfs_ip = current_app.config.get("RESTCONF_IP", "127.0.0.1") - logging.debug(f"DEBUG: TFS IP: {tfs_ip}") + logger.debug("DEBUG: TFS IP: %s", tfs_ip) conn = tfs_connector() path, path_code = conn.get_service_path(tfs_ip, service_id) - logging.debug(f"DEBUG: Path: {path}") + logger.debug("DEBUG: Path: %s", path) if path_code != 200 or not path: - raise Exception(f"Could not retrieve service path for service '{service_id}'") + raise RuntimeError(f"Could not retrieve service path for service '{service_id}'") network, topo_code = conn.get_network_topology(tfs_ip, slice_id) - logging.debug(f"DEBUG: Network topology: {network}") + logger.debug("DEBUG: Network topology: %s", network) if topo_code != 200 or not network: - raise Exception(f"Could not retrieve network topology for slice '{slice_id}'") + raise RuntimeError(f"Could not retrieve network topology for slice '{slice_id}'") return { "slice_id": slice_id, @@ -159,21 +180,20 @@ def realizer( rules: Any = None, action: str = "CREATE", ) -> Any: - """ - Manage the slice realization workflow dispatching based on action. + """Manage the slice realization workflow dispatching based on action. Args: - payload (Any): Intent, service, or slice data payload. - need_nrp (bool, optional): Whether NRP handling is needed. Defaults to False. - order (str, optional): NRP operation (READ, UPDATE, CREATE). Defaults to None. - nrp (dict, optional): Network Resource Partition data. Defaults to None. - controller_type (str, optional): Target SDN controller type. Defaults to None. - response (Any, optional): Outgoing response object. Defaults to None. - rules (Any, optional): Dynamic realization rules. Defaults to None. - action (str, optional): Action type ('CREATE', 'MONITOR', 'RECONFIG'). Defaults to "CREATE". + payload: Intent, service, or slice data payload. + need_nrp: Whether NRP handling is needed. Defaults to False. + order: NRP operation (READ, UPDATE, CREATE). Defaults to None. + nrp: Network Resource Partition data. Defaults to None. + controller_type: Target SDN controller type. Defaults to None. + response: Outgoing response object. Defaults to None. + rules: Dynamic realization rules. Defaults to None. + action: Action type ('CREATE', 'MONITOR', 'RECONFIG'). Defaults to 'CREATE'. Returns: - Any: Response from downstream controller realization. + Response from downstream controller realization. """ match action: case "CREATE": @@ -183,5 +203,5 @@ def realizer( case "RECONFIG": return _realize_reconfig(payload) case _: + logger.warning("Unknown realizer action: %s", action) return None - diff --git a/src/realizer/nrp_handler.py b/src/realizer/nrp_handler.py index 0d01598..cd666ed 100644 --- a/src/realizer/nrp_handler.py +++ b/src/realizer/nrp_handler.py @@ -1,11 +1,11 @@ # Copyright 2022-2026 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 - +# +# 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. @@ -14,63 +14,55 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. +"""Network Resource Partition (NRP) CRUD handler and static datastore.""" + +from __future__ import annotations + import json import logging -import os +from pathlib import Path +from typing import Any from src.config.constants import DATABASE_PATH +logger = logging.getLogger(__name__) -def nrp_handler(request, nrp): - """ - Manage Network Resource Partition (NRP) operations. - This method handles CRUD operations for Network Resource Partitions, - interacting with Network Controllers (currently done statically via a JSON-based database file). +def _read_nrp_database() -> list[dict[str, Any]]: + """Read the existing NRP database records from disk.""" + db_file = Path(DATABASE_PATH) / "nrp_ddbb.json" + if not db_file.exists(): + return [] + with db_file.open("r", encoding="utf-8") as f: + return json.load(f) - Args: - request (str): The type of operation to perform. - Supported values: - - "CREATE": Add a new NRP to the database - - "READ": Retrieve the current NRP view - - "UPDATE": Update an existing NRP (currently a placeholder) - nrp (dict): The Network Resource Partition details to create or update. +def nrp_handler(request: str | None, nrp: dict[str, Any] | None) -> list[dict[str, Any]] | None | str: + """Manage Network Resource Partition (NRP) operations. - Returns: - None or answer: - - For "CREATE": Returns the response from the controller (currently using a static JSON) - - For "READ": Gets the NRP view from the controller (currently using a static JSON) - - For "UPDATE": Placeholder for update functionality + Args: + request: Operation type ('CREATE', 'READ', 'UPDATE'). + nrp: Network Resource Partition data dictionary. - Notes: - - Uses a local JSON file "nrp_ddbb.json" to store NRP information as controller operation is not yet defined + Returns: + NRP records for 'READ', placeholder string for 'UPDATE', or None for 'CREATE'. """ - if request == "CREATE": - # TODO: Implement actual request to Controller to create an NRP - logging.debug("Creating NRP") - - # Load existing NRP database - with open(os.path.join(DATABASE_PATH, "nrp_ddbb.json"), "r") as archivo: - nrp_view = json.load(archivo) - - # Append new NRP to the view - nrp_view.append(nrp) - - # Placeholder for controller POST request - answer = None - return answer - elif request == "READ": - # TODO: Request to Controller to get topology and current NRP view - logging.debug("Reading Topology") - - # Load NRP database - with open(os.path.join(DATABASE_PATH, "nrp_ddbb.json"), "r") as archivo: - # self.__nrp_view = json.load(archivo) - nrp_view = json.load(archivo) - return nrp_view - - elif request == "UPDATE": - # TODO: Implement request to Controller to update NRP - logging.debug("Updating NRP") - answer = "" \ No newline at end of file + match request: + case "CREATE": + logger.debug("Creating NRP") + nrp_view = _read_nrp_database() + if nrp is not None: + nrp_view.append(nrp) + return None + + case "READ": + logger.debug("Reading Topology and NRP view") + return _read_nrp_database() + + case "UPDATE": + logger.debug("Updating NRP") + return "" + + case _: + logger.warning("Unsupported NRP request type: %s", request) + return None diff --git a/src/realizer/restconf/connectors/cisco_connector.py b/src/realizer/restconf/connectors/cisco_connector.py index c5ddb5c..ba8623e 100644 --- a/src/realizer/restconf/connectors/cisco_connector.py +++ b/src/realizer/restconf/connectors/cisco_connector.py @@ -1,11 +1,11 @@ # Copyright 2022-2026 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. @@ -14,110 +14,92 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. +"""SSH Netmiko connector for Cisco XR devices.""" + +from __future__ import annotations + import logging +from typing import Any from netmiko import ConnectHandler +logger = logging.getLogger(__name__) + 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 + """Class to interact with Cisco XR devices via SSH using Netmiko.""" + + def __init__(self, address: str, configs: list[dict[str, Any]] | None = None) -> None: + self.address = address + self.configs = configs or [] + + def execute_commands(self, commands: list[str]) -> None: + """Execute a list of configuration commands on the Cisco device. - 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. + commands: List of commands to execute. """ + device: dict[str, Any] = { + "device_type": "cisco_xr", + "host": self.address, + "username": "cisco", + "password": "cisco12345", + } 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() - + with ConnectHandler(**device) as connection: + output = connection.send_config_set(commands) + logger.debug(output) except Exception as e: - logging.error(f"Failed to execute commands on {self.address}: {e!s}") + logger.error("Failed to execute commands on %s: %s", self.address, e) - def create_command_template(self, config): - """ - Create command template for configuring a Cisco device. + def create_command_template(self, config: dict[str, Any]) -> list[str]: + """Create command template for configuring an L2VPN profile on a Cisco device. Args: - config (dict): Configuration parameters for the device. - + config: Configuration dictionary for the device interface and VLAN. + Returns: - list: List of commands to configure the device. + List of Cisco XR CLI commands. """ - commands = [ + number = config.get("number") + ni_name = config.get("ni_name") + interface = config.get("interface") + vlan = config.get("vlan") + remote_router = config.get("remote_router") + + return [ "l2vpn", - f"pw-class l2vpn_vpws_profile_example_{config['number']}", - "encapsulation mpls" - ] - - commands.extend([ + f"pw-class l2vpn_vpws_profile_example_{number}", + "encapsulation mpls", "transport-mode vlan passthrough", - "control-word" - ]) - - commands.extend([ - f"preferred-path interface tunnel-te {config['number']}", + "control-word", + f"preferred-path interface tunnel-te {number}", + "exit", "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']}", + f"p2p {ni_name}", + f"interface {interface}.{vlan}", + f"neighbor ipv4 {remote_router} pw-id {vlan}", "no pw-class l2vpn_vpws_profile_example", - f"pw-class l2vpn_vpws_profile_example_{config['number']}" - ]) - + f"pw-class l2vpn_vpws_profile_example_{number}", + ] + + def full_create_command_template(self) -> list[str]: + """Create full command template for configuring all stored device configurations. - 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. + Aggregated list of Cisco XR CLI commands ending with commit. """ - commands =[] + commands: list[str] = [] for config in self.configs: - commands_temp = self.create_command_template(config) - commands.extend(commands_temp) - commands.append("commit") - commands.append("end") + commands.extend(self.create_command_template(config)) + commands.extend(["commit", "end"]) return commands - def create_command_template_delete(self): - """ - Create command template for deleting L2VPN configuration on a Cisco device. + def create_command_template_delete(self) -> list[str]: + """Create command template for deleting L2VPN configuration on a Cisco device. + Returns: - list: List of commands to delete the L2VPN configuration. + 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 + return ["no l2vpn", "commit", "end"] diff --git a/src/realizer/restconf/connectors/frr_connector.py b/src/realizer/restconf/connectors/frr_connector.py index fc64bb8..85030cd 100644 --- a/src/realizer/restconf/connectors/frr_connector.py +++ b/src/realizer/restconf/connectors/frr_connector.py @@ -14,86 +14,108 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. +"""SSH Netmiko connector for Free Range Routing (FRR) Linux devices.""" + +from __future__ import annotations + import logging +from typing import Any from netmiko import ConnectHandler +logger = logging.getLogger(__name__) + class frr_connector: """Class to interact with FRR devices via SSH using Netmiko.""" - def __init__(self, address): + def __init__(self, address: str) -> None: 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() + def execute_commands(self, commands: list[str]) -> None: + """Execute a list of vtysh configuration commands on the FRR host. + Args: + commands: List of shell/vtysh command lines. + """ + device: dict[str, Any] = { + "device_type": "linux", + "host": self.address, + "username": "root", + "password": "root", + } + try: + with ConnectHandler(**device) as connection: + output = connection.send_config_set(commands) + logger.debug(output) except Exception as e: - logging.error(f"Failed to execute commands on {self.address}: {e!s}") + logger.error("Failed to execute commands on %s: %s", self.address, e) raise - def setup_slice(self, config: dict, assignments: dict[int, int]) -> list[str]: - """ - Build PBR commands dynamically based on active slot assignments. + def setup_slice(self, config: dict[str, Any], assignments: dict[int, int]) -> list[str]: + """Build PBR commands dynamically based on active slot assignments. Args: - config: Device config (interfaces, addresses, etc.) + config: Device config (interfaces, addresses, output_interfaces). 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 + # Nexthop groups - one per non-default slice + for i, iface in enumerate(config["output_interfaces"], start=1): + commands.extend( + [ + f"nexthop-group SLICE-{i}", + f"nexthop {iface}", + "exit", + ] + ) + + # PBR rules - one per active slot, ordered by sequence 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", - ] + commands.extend( + [ + 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", - ] + commands.extend( + [ + "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", - ] + commands.extend( + [ + 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]: + """Build commands to remove all slice PBR configurations. + + Returns: + List of FRR/vtysh commands. + """ return [ "vtysh", "conf te", @@ -102,4 +124,4 @@ class frr_connector: "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 index b2c6dc3..a6340d9 100644 --- a/src/realizer/restconf/connectors/tfs_connector.py +++ b/src/realizer/restconf/connectors/tfs_connector.py @@ -1,11 +1,11 @@ # Copyright 2022-2026 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. @@ -14,10 +14,15 @@ # This file includes original contributions from Telefonica Innovación Digital S.L. +"""TeraFlowSDN RESTCONF and NBI Connector for topology, paths, and telemetry streaming.""" + +from __future__ import annotations + import asyncio import json import logging import threading +from typing import Any import aiohttp import requests @@ -31,20 +36,22 @@ from src.config.constants import ( from src.database.service_db import get_data_by_slice_id from src.utils.safe_get import safe_get -# Temp until moving to .env -SDN_SUBSCRIPTION_PERIOD = 10 # seconds -SDN_SUBS_INACTIVITY_THRESHOLD = 30 # seconds +logger = logging.getLogger(__name__) + +SDN_SUBSCRIPTION_PERIOD = 10 +SDN_SUBS_INACTIVITY_THRESHOLD = 30 + -class tfs_connector: - """ - Helper class to interact with TeraFlowSDN Northbound Interface (NBI) and WebUI. - """ - _loop = None - _thread = None - _session = None +class tfs_connector: + """Helper class to interact with TeraFlowSDN Northbound Interface (NBI) and WebUI.""" + + _loop: asyncio.AbstractEventLoop | None = None + _thread: threading.Thread | None = None + _session: aiohttp.ClientSession | None = None @classmethod - def get_background_loop(cls): + def get_background_loop(cls) -> asyncio.AbstractEventLoop: + """Get or initialize the shared background asyncio event loop.""" if cls._loop is None: cls._loop = asyncio.new_event_loop() cls._thread = threading.Thread(target=cls._run_loop, args=(cls._loop,), daemon=True) @@ -52,337 +59,240 @@ class tfs_connector: return cls._loop @staticmethod - def _run_loop(loop): + def _run_loop(loop: asyncio.AbstractEventLoop) -> None: asyncio.set_event_loop(loop) loop.run_forever() - def __init__(self): - pass - - 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="" + def webui_post(self, tfs_ip: str, service: dict[str, Any]) -> requests.Response: + """Post service descriptor to TFS WebUI.""" 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: + """Post service descriptor to TFS NBI.""" session = requests.Session() - session.auth = (user, password) - url = f'http://{tfs_ip}/{path}' - headers = {'Content-Type': 'application/json'} + session.auth = ("admin", "admin") + url = f"http://{tfs_ip}/{path}" + headers = {"Content-Type": "application/json"} data = json.dumps(service) - logging.debug("Posting to TFS NBI: %s",data) - token={'csrf_token':token} - response = session.post(url,headers=headers,data=data,timeout=60) + logger.debug("Posting to TFS NBI: %s", data) + response = session.post(url, headers=headers, data=data, timeout=60) response.raise_for_status() - logging.debug("Http response: %s",response.text) + logger.debug("Http response: %s", response.text) return response - - def nbi_delete(self, tfs_ip: str, service_type: str , service_id: str) -> 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'.") + + def nbi_delete(self, tfs_ip: str, service_type: str, service_id: str) -> requests.Response: + """Delete service from TFS NBI.""" + base_url = f"http://admin:admin@{tfs_ip}" + match service_type: + case "L2": + url = f"{base_url}/{NBI_L2_PATH}/vpn-service={service_id}" + case "L3": + url = f"{base_url}/{NBI_L3_PATH}/vpn-service={service_id}" + case _: + 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) + logger.debug("Service deleted successfully: %s", response.text) return response - - def get_network_topology(self, tfs_ip: str, slice_id: str) -> tuple[dict[str, any], int]: - user="admin" - password="admin" - url = f'http://{user}:{password}@{tfs_ip}' - url = url + f'/{NBI_IETF_NETWORKS_PATH}' - # MOCKED TOPOLOGY + + def get_network_topology(self, tfs_ip: str, slice_id: str) -> tuple[dict[str, Any] | None, int]: + """Retrieve physical network topology from TFS.""" + url = f"http://admin:admin@{tfs_ip}/{NBI_IETF_NETWORKS_PATH}" response = requests.get(url, timeout=60) response.raise_for_status() network_raw = response.json() - network = next((n["ietf-network:networks"]["network"][0] for n in network_raw if n["ietf-network:networks"]["network"][0]["network-id"] == "urn:tfs:network:admin"), None) - logging.debug(f"Retrieved topology for slice '{slice_id}': {network}") - + network = next( + ( + n["ietf-network:networks"]["network"][0] + for n in network_raw + if n["ietf-network:networks"]["network"][0]["network-id"] == "urn:tfs:network:admin" + ), + None, + ) + logger.debug("Retrieved topology for slice '%s': %s", slice_id, network) return network, 200 def get_device_name(self, tfs_ip: str, device_uuid: str) -> str: - """ - Get device name for a given device_uuid from TFS. - - Args: - tfs_ip (str): IP address of the TFS instance - device_uuid (str): Device UUID - - Returns: - str: Name of the device - """ + """Get human-readable device name for a device UUID from TFS.""" url = f"http://{tfs_ip}/tfs-api/device/{device_uuid}" - headers = {'accept': 'application/json'} + headers = {"accept": "application/json"} response = requests.get(url, headers=headers, timeout=60) response.raise_for_status() device_data = response.json() - return device_data.get("name", "") + return str(device_data.get("name", "")) def get_service_path(self, tfs_ip: str, service_id: str) -> tuple[list[str], int]: - """ - Get ordered list of node names along the service path from TFS. - - Args: - tfs_ip (str): IP address of the TFS instance - service_id (str): Service UUID - - Returns: - Tuple[List[str], int]: List of node names in order and status code - """ - url = f'http://{tfs_ip}/tfs-api/context/admin/service/{service_id}/connections' - headers = {'accept': 'application/json'} + """Get ordered list of node names along the service path from TFS.""" + url = f"http://{tfs_ip}/tfs-api/context/admin/service/{service_id}/connections" + headers = {"accept": "application/json"} try: response = requests.get(url, headers=headers, timeout=60) - if response.status_code == 200: - data = response.json() - connections = data.get("connections", []) - if not connections: - return [], 200 - - # Extract device sequences from connections - conn_sequences = [] - for conn in connections: - hops = conn.get("path_hops_endpoint_ids", []) - seq = [] - for hop in hops: - dev_id = safe_get(hop, ["device_id", "device_uuid", "uuid"]) - if dev_id: - if not seq or seq[-1] != dev_id: - seq.append(dev_id) - if seq: - conn_sequences.append(seq) - - if not conn_sequences: - return [], 200 - - # Chain or order sequences to ensure topological continuity - remaining = list(conn_sequences) - remaining.sort(key=len, reverse=True) - current_chain = remaining.pop(0) - - while remaining: - last_dev = current_chain[-1] - matched_next = None - for i, seq in enumerate(remaining): - if seq[0] == last_dev or last_dev in seq: - matched_next = i - break - if matched_next is not None: - next_seq = remaining.pop(matched_next) - current_chain.extend(next_seq) - else: - current_chain.extend(remaining.pop(0)) - - # Deduplicate while preserving strict traversal order - raw_uuids = [] - seen_uuids = set() - for dev_id in current_chain: - if dev_id not in seen_uuids: - seen_uuids.add(dev_id) - raw_uuids.append(dev_id) - - name_cache = {} - path = [] - for dev_uuid in raw_uuids: - if dev_uuid not in name_cache: - try: - dev_name = self.get_device_name(tfs_ip, dev_uuid) - name_cache[dev_uuid] = dev_name if dev_name else dev_uuid - except Exception as e: - logging.warning(f"Could not retrieve device name for '{dev_uuid}': {e}") - name_cache[dev_uuid] = dev_uuid - path.append(name_cache[dev_uuid]) - - logging.debug(f"Retrieved ordered service path for service '{service_id}': {path}") - return path, 200 - else: - logging.error(f"Failed to retrieve service path for service '{service_id}': status {response.status_code}") + if response.status_code != 200: + logger.error( + "Failed to retrieve service path for service '%s': status %s", service_id, response.status_code + ) return [], response.status_code + + data = response.json() + connections = data.get("connections", []) + if not connections: + return [], 200 + + conn_sequences: list[list[str]] = [] + for conn in connections: + hops = conn.get("path_hops_endpoint_ids", []) + seq: list[str] = [] + for hop in hops: + dev_id = safe_get(hop, ["device_id", "device_uuid", "uuid"]) + if dev_id and (not seq or seq[-1] != dev_id): + seq.append(dev_id) + if seq: + conn_sequences.append(seq) + + if not conn_sequences: + return [], 200 + + remaining = list(conn_sequences) + remaining.sort(key=len, reverse=True) + current_chain = remaining.pop(0) + + while remaining: + last_dev = current_chain[-1] + matched_next = None + for i, seq in enumerate(remaining): + if seq[0] == last_dev or last_dev in seq: + matched_next = i + break + if matched_next is not None: + current_chain.extend(remaining.pop(matched_next)) + else: + current_chain.extend(remaining.pop(0)) + + raw_uuids: list[str] = [] + seen_uuids: set[str] = set() + for dev_id in current_chain: + if dev_id not in seen_uuids: + seen_uuids.add(dev_id) + raw_uuids.append(dev_id) + + name_cache: dict[str, str] = {} + path: list[str] = [] + for dev_uuid in raw_uuids: + if dev_uuid not in name_cache: + try: + dev_name = self.get_device_name(tfs_ip, dev_uuid) + name_cache[dev_uuid] = dev_name if dev_name else dev_uuid + except Exception as e: + logger.warning("Could not retrieve device name for '%s': %s", dev_uuid, e) + name_cache[dev_uuid] = dev_uuid + path.append(name_cache[dev_uuid]) + + logger.debug("Retrieved ordered service path for service '%s': %s", service_id, path) + return path, 200 except Exception as e: - logging.exception(f"Error retrieving service path for service '{service_id}': {e}") + logger.exception("Error retrieving service path for service '%s': %s", service_id, e) return [], 500 - def get_slice_topology(self, tfs_ip: str, slice_id: str) -> tuple[dict[str, any], int]: - """ - Build and return the Network Slice Topology according to draft-ietf-teas-network-slice-topology-yang-04. - Retrieves physical network topology and filters nodes, links, and termination points by the slice service path - obtained via get_service_path, displaying each element with its associated slo-sle-template. - - Args: - tfs_ip (str): IP address of the TFS instance - slice_id (str): Slice ID - - Returns: - Tuple[Dict[str, any], int]: Network Slice Topology formatted as ietf-network:networks JSON and HTTP status code - """ - intent = {} + def get_slice_topology(self, tfs_ip: str, slice_id: str) -> tuple[dict[str, Any], int]: + """Build and return the Network Slice Topology according to draft-ietf-teas-network-slice-topology-yang-04.""" + intent: dict[str, Any] = {} try: from src.database.sysrepo_store import get_data_store + data = get_data_store(f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']") if data: intent = data except Exception: pass - # Extract slice service details - slice_svc = {} + slice_svc: dict[str, Any] = {} if isinstance(intent, dict): if intent.get("id") == slice_id or "slo-sle-template" in intent: slice_svc = intent else: services = ( - safe_get(intent, ['ietf-network-slice-service:network-slice-services', 'slice-service']) - or safe_get(intent, ['network-slice-services', 'slice-service']) - or intent.get('slice-service') + safe_get(intent, ["ietf-network-slice-service:network-slice-services", "slice-service"]) + or safe_get(intent, ["network-slice-services", "slice-service"]) + or intent.get("slice-service") or [] ) if isinstance(services, list): - slice_svc = next((s for s in services if isinstance(s, dict) and s.get('id') == slice_id), {}) - elif isinstance(services, dict) and services.get('id') == slice_id: + slice_svc = next((s for s in services if isinstance(s, dict) and s.get("id") == slice_id), {}) + elif isinstance(services, dict) and services.get("id") == slice_id: slice_svc = services elif isinstance(intent, list): - slice_svc = next((s for s in intent if isinstance(s, dict) and s.get('id') == slice_id), {}) + slice_svc = next((s for s in intent if isinstance(s, dict) and s.get("id") == slice_id), {}) if not intent or not slice_svc: from src.utils.send_response import send_response + return send_response(False, code=404, message=f"Slice '{slice_id}' not found") - slice_template = slice_svc.get('slo-sle-template', 'bronze') if isinstance(slice_svc, dict) else 'bronze' + slice_template = slice_svc.get("slo-sle-template", "bronze") if isinstance(slice_svc, dict) else "bronze" # Parse SDPs to nodes - raw_sdps = safe_get(slice_svc, ['sdps', 'sdp']) or [] - if isinstance(raw_sdps, dict): - sdps = [raw_sdps] - elif isinstance(raw_sdps, (list, tuple)): - sdps = list(raw_sdps) - elif hasattr(raw_sdps, "__iter__"): - try: - sdps = list(raw_sdps) - except Exception: - sdps = [] - else: - sdps = [] + raw_sdps = safe_get(slice_svc, ["sdps", "sdp"]) or [] + sdps = [raw_sdps] if isinstance(raw_sdps, dict) else (list(raw_sdps) if hasattr(raw_sdps, "__iter__") else []) - sdp_nodes = {} + sdp_nodes: dict[str, Any] = {} for sdp in sdps: if not isinstance(sdp, dict): continue - sdp_id = sdp.get('id', '') - raw_acs = safe_get(sdp, ['attachment-circuits', 'attachment-circuit']) or [] - first_ac = None - if isinstance(raw_acs, dict): - first_ac = raw_acs - elif isinstance(raw_acs, (list, tuple)) and raw_acs: - first_ac = raw_acs[0] if isinstance(raw_acs[0], dict) else {} - elif hasattr(raw_acs, "__iter__"): - try: - for item in raw_acs: - if isinstance(item, dict): - first_ac = item - break - except Exception: - first_ac = None - - ac_ipv4 = first_ac.get('ac-ipv4-address', sdp_id) if isinstance(first_ac, dict) else sdp_id + sdp_id = sdp.get("id", "") + raw_acs = safe_get(sdp, ["attachment-circuits", "attachment-circuit"]) or [] + first_ac = ( + raw_acs if isinstance(raw_acs, dict) else (raw_acs[0] if isinstance(raw_acs, list) and raw_acs else {}) + ) + ac_ipv4 = first_ac.get("ac-ipv4-address", sdp_id) if isinstance(first_ac, dict) else sdp_id sdp_nodes[sdp_id] = ac_ipv4 # Extract connection groups and map templates - raw_cgs = safe_get(slice_svc, ['connection-groups', 'connection-group']) or [] - if isinstance(raw_cgs, dict): - cg_list = [raw_cgs] - elif isinstance(raw_cgs, (list, tuple)): - cg_list = list(raw_cgs) - elif hasattr(raw_cgs, "__iter__"): - try: - cg_list = list(raw_cgs) - except Exception: - cg_list = [] - else: - cg_list = [] + raw_cgs = safe_get(slice_svc, ["connection-groups", "connection-group"]) or [] + cg_list = [raw_cgs] if isinstance(raw_cgs, dict) else (list(raw_cgs) if hasattr(raw_cgs, "__iter__") else []) - cg_map = {} + cg_map: dict[str, Any] = {} for cg in cg_list: if not isinstance(cg, dict): continue - cg_id = cg.get('id') - cg_template = cg.get('slo-sle-template') or slice_template - constructs = cg.get('connectivity-construct', []) + cg_id = cg.get("id") + cg_template = cg.get("slo-sle-template") or slice_template + constructs = cg.get("connectivity-construct", []) if isinstance(constructs, dict): constructs = [constructs] - elif not isinstance(constructs, list) and hasattr(constructs, "__iter__"): - try: - constructs = list(constructs) - except Exception: - constructs = [] + elif not isinstance(constructs, list): + constructs = list(constructs) if hasattr(constructs, "__iter__") else [] cg_map[cg_id] = { - 'template': cg_template, - 'constructs': constructs if isinstance(constructs, list) else [] + "template": cg_template, + "constructs": constructs if isinstance(constructs, list) else [], } - # 1. Retrieve full physical network topology from TFS underlay_network = None try: underlay_network, _ = self.get_network_topology(tfs_ip, slice_id) except Exception as e: - logging.warning(f"Could not retrieve underlay topology from TFS: {e}") + logger.warning("Could not retrieve underlay topology from TFS: %s", e) - # 2. Retrieve service path for this slice from TFS - service_path_nodes = [] + service_path_nodes: list[str] = [] try: service_data = get_data_by_slice_id(slice_id) if len(service_data) > 1: @@ -392,23 +302,22 @@ class tfs_connector: if path_code == 200 and isinstance(path_nodes, list): service_path_nodes = path_nodes except Exception as e: - logging.warning(f"Could not retrieve service path from TFS for '{slice_id}': {e}") + logger.warning("Could not retrieve service path from TFS for '%s': %s", slice_id, e) underlay_nodes = underlay_network.get("node", []) if isinstance(underlay_network, dict) else [] - underlay_links = underlay_network.get("ietf-network-topology:link", []) if isinstance(underlay_network, dict) else [] + underlay_links = ( + underlay_network.get("ietf-network-topology:link", []) if isinstance(underlay_network, dict) else [] + ) - def node_matches_path(node_dict: dict) -> bool: + def node_matches_path(node_dict: dict[str, Any]) -> bool: n_id = str(node_dict.get("node-id", "")) n_name = str(safe_get(node_dict, ["ietf-l3-unicast-topology:l3-node-attributes", "name"]) or "") if service_path_nodes: for p in service_path_nodes: p_str = str(p) - if p_str == n_id or p_str in n_id or n_id in p_str: - return True - if n_name and (p_str == n_name or p_str in n_name or n_name in p_str): + if p_str in (n_id, n_name) or p_str in n_id or n_id in p_str: return True return False - # Fallback if service path is empty: match SDP IPs or names if sdp_nodes: for sdp_id, node_ip in sdp_nodes.items(): ip_str = str(node_ip) @@ -416,31 +325,22 @@ class tfs_connector: return True return True - # 3. Filter Nodes by service path and sort in order of service_path_nodes - filtered_underlay_nodes = [] - matched_node_ids = set() - if underlay_nodes: - for node in underlay_nodes: - if isinstance(node, dict) and node_matches_path(node): - filtered_underlay_nodes.append(node) - matched_node_ids.add(str(node.get("node-id", ""))) + filtered_underlay_nodes = [n for n in underlay_nodes if isinstance(n, dict) and node_matches_path(n)] + matched_node_ids = {str(n.get("node-id", "")) for n in filtered_underlay_nodes} - def get_node_path_index(node_dict: dict) -> int: + def get_node_path_index(node_dict: dict[str, Any]) -> int: n_id = str(node_dict.get("node-id", "")) n_name = str(safe_get(node_dict, ["ietf-l3-unicast-topology:l3-node-attributes", "name"]) or "") if service_path_nodes: for idx, p in enumerate(service_path_nodes): p_str = str(p) - if p_str == n_id or p_str in n_id or n_id in p_str: - return idx - if n_name and (p_str == n_name or p_str in n_name or n_name in p_str): + if p_str in (n_id, n_name) or p_str in n_id or n_id in p_str: return idx return 99999 filtered_underlay_nodes.sort(key=get_node_path_index) - # 4. Filter Links connecting the filtered nodes - filtered_links = [] + filtered_links: list[dict[str, Any]] = [] if underlay_links and filtered_underlay_nodes: for link in underlay_links: if not isinstance(link, dict): @@ -448,29 +348,29 @@ class tfs_connector: l_id = link.get("link-id", "") src_node = str(safe_get(link, ["source", "source-node"]) or "") dst_node = str(safe_get(link, ["destination", "dest-node"]) or "") - + if src_node in matched_node_ids and dst_node in matched_node_ids: template_assigned = slice_template - for cg_id, cg_info in cg_map.items(): + for _, cg_info in cg_map.items(): if ("1.1.1.1" in l_id and "4.4.4.4" in l_id) or ("2.2.2.2" in l_id and "1.1.1.1" in l_id): - if cg_info['template'] == 'gold': - template_assigned = 'gold' + if cg_info["template"] == "gold": + template_assigned = "gold" elif ("2.2.2.2" in l_id and "3.3.3.3" in l_id) or ("3.3.3.3" in l_id and "4.4.4.4" in l_id): - if cg_info['template'] == 'silver': - template_assigned = 'silver' - else: - if cg_info.get('template'): - template_assigned = cg_info['template'] - - filtered_links.append({ - "link-id": f"{l_id}:{slice_id}", - "source": link.get("source", {}), - "destination": link.get("destination", {}), - "ietf-ns-topo:slo-sle-template": template_assigned - }) - - # 5. Filter Termination Points for filtered nodes - active_tps = set() + if cg_info["template"] == "silver": + template_assigned = "silver" + elif cg_info.get("template"): + template_assigned = cg_info["template"] + + filtered_links.append( + { + "link-id": f"{l_id}:{slice_id}", + "source": link.get("source", {}), + "destination": link.get("destination", {}), + "ietf-ns-topo:slo-sle-template": template_assigned, + } + ) + + active_tps: set[str] = set() for fl in filtered_links: src_tp = safe_get(fl, ["source", "source-tp"]) dst_tp = safe_get(fl, ["destination", "dest-tp"]) @@ -479,95 +379,99 @@ class tfs_connector: if dst_tp: active_tps.add(str(dst_tp)) - nodes_result = [] + nodes_result: list[dict[str, Any]] = [] if filtered_underlay_nodes: for node in filtered_underlay_nodes: n_id = node.get("node-id") n_attrs = node.get("ietf-l3-unicast-topology:l3-node-attributes", {}) tps = node.get("ietf-network-topology:termination-point", []) - - filtered_tps = [] - if active_tps and isinstance(tps, list): - filtered_tps = [tp for tp in tps if isinstance(tp, dict) and str(tp.get("tp-id", "")) in active_tps] - + + filtered_tps = ( + [tp for tp in tps if isinstance(tp, dict) and str(tp.get("tp-id", "")) in active_tps] + if active_tps and isinstance(tps, list) + else (tps if isinstance(tps, list) else []) + ) if not filtered_tps and isinstance(tps, list): filtered_tps = tps - nodes_result.append({ - "node-id": n_id, - "ietf-l3-unicast-topology:l3-node-attributes": n_attrs, - "ietf-network-topology:termination-point": filtered_tps - }) + nodes_result.append( + { + "node-id": n_id, + "ietf-l3-unicast-topology:l3-node-attributes": n_attrs, + "ietf-network-topology:termination-point": filtered_tps, + } + ) else: - # Fallback default nodes based on SDP IPs for sdp_id, node_ip in sdp_nodes.items(): node_urn = f"urn:tfs:node:{node_ip}" if node_ip and not str(node_ip).startswith("urn:") else node_ip - nodes_result.append({ - "node-id": node_urn, - "ietf-l3-unicast-topology:l3-node-attributes": {"name": f"{sdp_id} ({node_ip})"}, - "ietf-network-topology:termination-point": [{"tp-id": "urn:tfs:tp:eth0"}] - }) + nodes_result.append( + { + "node-id": node_urn, + "ietf-l3-unicast-topology:l3-node-attributes": {"name": f"{sdp_id} ({node_ip})"}, + "ietf-network-topology:termination-point": [{"tp-id": "urn:tfs:tp:eth0"}], + } + ) links_result = filtered_links if not links_result: - # Generate virtual links for connection groups constructs if no underlay links matched - for cg_id, cg_info in cg_map.items(): - for construct in cg_info.get('constructs', []): + for _, cg_info in cg_map.items(): + for construct in cg_info.get("constructs", []): if not isinstance(construct, dict): continue - sender = construct.get('p2p-sender-sdp') - receiver = construct.get('p2p-receiver-sdp') + sender = construct.get("p2p-sender-sdp") + receiver = construct.get("p2p-receiver-sdp") sender_ip = sdp_nodes.get(sender, sender) receiver_ip = sdp_nodes.get(receiver, receiver) - src_node = f"urn:tfs:node:{sender_ip}" if sender_ip and not str(sender_ip).startswith("urn:") else sender_ip - dst_node = f"urn:tfs:node:{receiver_ip}" if receiver_ip and not str(receiver_ip).startswith("urn:") else receiver_ip - - link_tmpl = construct.get('slo-sle-template') or cg_info.get('template') or slice_template - links_result.append({ - "link-id": f"urn:tfs:link:{sender_ip}-{receiver_ip}:{slice_id}", - "source": { - "source-node": src_node, - "source-tp": "urn:tfs:tp:eth0" - }, - "destination": { - "dest-node": dst_node, - "dest-tp": "urn:tfs:tp:eth0" - }, - "ietf-ns-topo:slo-sle-template": link_tmpl - }) + src_node = ( + f"urn:tfs:node:{sender_ip}" + if sender_ip and not str(sender_ip).startswith("urn:") + else sender_ip + ) + dst_node = ( + f"urn:tfs:node:{receiver_ip}" + if receiver_ip and not str(receiver_ip).startswith("urn:") + else receiver_ip + ) + link_tmpl = construct.get("slo-sle-template") or cg_info.get("template") or slice_template + links_result.append( + { + "link-id": f"urn:tfs:link:{sender_ip}-{receiver_ip}:{slice_id}", + "source": {"source-node": src_node, "source-tp": "urn:tfs:tp:eth0"}, + "destination": {"dest-node": dst_node, "dest-tp": "urn:tfs:tp:eth0"}, + "ietf-ns-topo:slo-sle-template": link_tmpl, + } + ) slice_topology = { "ietf-network:networks": { "network": [ { "network-id": f"urn:tfs:network:{slice_id}", - "network-types": { - "ietf-ns-topo:network-slice": {} - }, + "network-types": {"ietf-ns-topo:network-slice": {}}, "ietf-ns-topo:slo-sle-template": slice_template, "node": nodes_result, - "ietf-network-topology:link": links_result + "ietf-network-topology:link": links_result, } ] } } - return slice_topology, 200 - # --- SDN STREAMS --- + # --- Telemetry and Streaming --- - async def get_session(self): - # Crea la sesión si no existe en el loop de background + async def get_session(self) -> aiohttp.ClientSession: + """Get or initialize the shared aiohttp ClientSession.""" if tfs_connector._session is None or tfs_connector._session.closed: tfs_connector._session = aiohttp.ClientSession() return tfs_connector._session - - async def close_session(self): - # Llama a esto al apagar tu app - if tfs_connector._session: + + async def close_session(self) -> None: + """Close the shared aiohttp ClientSession.""" + if tfs_connector._session and not tfs_connector._session.closed: await tfs_connector._session.close() - def _extract_stream_uri(self, payload, base_url): + def _extract_stream_uri(self, payload: Any, base_url: str) -> str | None: + """Recursively extract stream URI from SSE subscription response payload.""" if not isinstance(payload, dict): return None @@ -575,11 +479,14 @@ class tfs_connector: uri = payload["uri"].strip() if uri.startswith(("http://", "https://")): return uri - if base_url: - return f"{base_url.rstrip('/')}/{uri.lstrip('/')}" - return uri - - for key in ("ietf-subscribed-notifications:subscription-result", "subscription-result", "stream", "ietf-subscribed-notifications:stream"): + return f"{base_url.rstrip('/')}/{uri.lstrip('/')}" if base_url else uri + + for key in ( + "ietf-subscribed-notifications:subscription-result", + "subscription-result", + "stream", + "ietf-subscribed-notifications:stream", + ): if key in payload: uri = self._extract_stream_uri(payload[key], base_url) if uri: @@ -593,86 +500,96 @@ class tfs_connector: return None - async def listenStream(self, session, link_id, slice_id, stream_url, telemetry_cache): - logging.debug(f"Listening to stream for link {link_id} in slice {slice_id} at {stream_url}") - try: + async def listenStream( + self, + session: aiohttp.ClientSession, + link_id: str, + slice_id: str, + stream_url: str, + telemetry_cache: dict[str, Any], + ) -> None: + """Listen to an SSE telemetry stream for a specific link and update the cache.""" + logger.debug("Listening to stream for link %s in slice %s at %s", link_id, slice_id, stream_url) + try: async with session.get( stream_url, - auth=aiohttp.BasicAuth("admin", "admin"), + auth=aiohttp.BasicAuth("admin", "admin"), timeout=None, - headers = { - "Accept": "text/event-stream", - "Cache-Control": "no-cache" - } + headers={"Accept": "text/event-stream", "Cache-Control": "no-cache"}, ) as resp: - logging.debug(f"Connection established for link {link_id}") + logger.debug("Connection established for link %s", link_id) buffer = "" async for chunk in resp.content.iter_any(): buffer += chunk.decode() - + while "\n\n" in buffer: event_block, buffer = buffer.split("\n\n", 1) - + for line in event_block.split("\n"): if line.startswith("data:"): json_str = line.replace("data:", "").strip() - try: data = json.loads(json_str) - logging.debug(f"Received data for link {link_id}: {data}") - - telemetry = data["notification"]["push-update"]["datastore-contents"]["simap-telemetry:simap-telemetry"] + logger.debug("Received data for link %s: %s", link_id, data) + telemetry = data["notification"]["push-update"]["datastore-contents"][ + "simap-telemetry:simap-telemetry" + ] bw = telemetry.get("bandwidth-utilization", "N/A") latency = telemetry.get("latency", "N/A") telemetry_cache.setdefault(slice_id, {})[link_id] = { "timestamp": data["notification"]["eventTime"], "bandwidth": float(bw) if bw != "N/A" else bw, "latency": float(latency) if latency != "N/A" else latency, - "services": telemetry.get("related-service-ids", []) + "services": telemetry.get("related-service-ids", []), } - except json.JSONDecodeError: - logging.error(f"Error parsing JSON for link {link_id}: {json_str}") - except Exception as e: - logging.error(f"Error processing data for link {link_id}: {e}") - except aiohttp.ClientError as e: - logging.error(f"Connection error with stream for link {link_id}: {e}") + except json.JSONDecodeError: + logger.error("Error parsing JSON for link %s: %s", link_id, json_str) + except Exception as e: + logger.error("Error processing data for link %s: %s", link_id, e) + except aiohttp.ClientError as e: + logger.error("Connection error with stream for link %s: %s", link_id, e) except Exception as e: - logging.error(f"Unexpected error in listenStream for link {link_id}: {e}") - - logging.info(f"Stopped listening to stream for link {link_id} in slice {slice_id}") - - async def startStreams(self, tfs_ip, suscription_period, links, slice_id, telemetry_cache): - #async with self.get_session() as session: #timeout = aiohttp.ClientTimeout(total=10) + logger.error("Unexpected error in listenStream for link %s: %s", link_id, e) + + logger.info("Stopped listening to stream for link %s in slice %s", link_id, slice_id) + + async def startStreams( + self, + tfs_ip: str, + suscription_period: int, + links: list[str], + slice_id: str, + telemetry_cache: dict[str, Any], + ) -> None: + """Start asynchronous SSE telemetry subscriptions for a set of links.""" session = await self.get_session() tasks = [] - base_url = f'http://{tfs_ip}' - url = f'{base_url}{NBI_SIMAP_SUSCRIPTION_PATH}' + base_url = f"http://{tfs_ip}" + url = f"{base_url}{NBI_SIMAP_SUSCRIPTION_PATH}" for link in links: - sub_resp = await session.post(url, json={ - "ietf-subscribed-notifications:input": { - "datastore": "operational", - "ietf-yang-push:datastore-xpath-filter": f"/ietf-network:networks/network=admin/ietf-network-topology:link={link}/simap-telemetry:simap-telemetry", - "ietf-yang-push:periodic": { - "ietf-yang-push:period": suscription_period + sub_resp = await session.post( + url, + json={ + "ietf-subscribed-notifications:input": { + "datastore": "operational", + "ietf-yang-push:datastore-xpath-filter": f"/ietf-network:networks/network=admin/ietf-network-topology:link={link}/simap-telemetry:simap-telemetry", + "ietf-yang-push:periodic": {"ietf-yang-push:period": suscription_period}, } - } - }, - auth=aiohttp.BasicAuth("admin", "admin") - ) + }, + auth=aiohttp.BasicAuth("admin", "admin"), + ) sub_resp.raise_for_status() sub_data = await sub_resp.json(content_type=None) stream_url = self._extract_stream_uri(sub_data, base_url) if stream_url: tasks.append( - asyncio.create_task( - self.listenStream(session, link, slice_id, stream_url, telemetry_cache) - ) + asyncio.create_task(self.listenStream(session, link, slice_id, stream_url, telemetry_cache)) ) - logging.debug("Created telemetry listener for link '%s' in slice '%s' at %s", link, slice_id, stream_url) + logger.debug("Created telemetry listener for link '%s' in slice '%s' at %s", link, slice_id, stream_url) else: - logging.warning("Subscription for link '%s' did not return a stream URI. Response: %s", link, sub_data) + logger.warning("Subscription for link '%s' did not return a stream URI. Response: %s", link, sub_data) - logging.debug(f"Telemetry cache for slice '{slice_id}': {telemetry_cache.get(slice_id)}") + logger.debug("Telemetry cache for slice '%s': %s", slice_id, telemetry_cache.get(slice_id)) diff --git a/src/realizer/restconf/main.py b/src/realizer/restconf/main.py index 61d2d59..776aa62 100644 --- a/src/realizer/restconf/main.py +++ b/src/realizer/restconf/main.py @@ -1,11 +1,11 @@ # Copyright 2022-2026 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. @@ -14,29 +14,39 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. +"""RESTCONF slice intent realization entry point.""" + +from __future__ import annotations + import logging +from typing import Any from .service_types.l2vpn import l2vpn from .service_types.l3vpn import l3vpn +logger = logging.getLogger(__name__) -def restconf(ietf_intent, way=None, response=None): - """ - Generates a TFS realizing request based on the specified way (L2 or L3). + +def restconf( + ietf_intent: dict[str, Any], + way: str | None = None, + response: Any = None, +) -> dict[str, Any] | None: + """Generate a RESTCONF/TFS realization request based on specified service 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. - + ietf_intent: The IETF slice intent dictionary to be realized. + way: Service layer type ('L2' or 'L3'). Defaults to None. + response: Response built for user feedback. Defaults to None. + Returns: - dict: A realization request for the specified network slice type. + Realization request dictionary or None if SDPs are missing. """ - 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 + match way: + case "L2": + return l2vpn(ietf_intent) + case "L3": + return l3vpn(ietf_intent) + case _: + logger.warning("Unsupported way: %s. Defaulting to L2 realization.", way) + return l2vpn(ietf_intent) diff --git a/src/realizer/restconf/restconf_connect.py b/src/realizer/restconf/restconf_connect.py index b0cba44..e882c7c 100644 --- a/src/realizer/restconf/restconf_connect.py +++ b/src/realizer/restconf/restconf_connect.py @@ -14,7 +14,12 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. +"""RESTCONF connector orchestrating TFS NBI dispatching and FRR dataplane provisioning.""" + +from __future__ import annotations + import logging +from typing import Any from flask import current_app @@ -26,7 +31,9 @@ from src.utils.slice_manager import SliceManager from .connectors.frr_connector import frr_connector from .connectors.tfs_connector import tfs_connector -FRR_DEVICES = [ +logger = logging.getLogger(__name__) + +FRR_DEVICES: list[dict[str, Any]] = [ { "management_address": "10.60.125.20", "input_address": "192.168.24.2", @@ -43,65 +50,90 @@ FRR_DEVICES = [ _slice_manager = SliceManager() -def restconf_connect(requests, restconf_ip): - """ - Connect to controller and upload services. - + +def _handle_frr_dataplane(dscp: int) -> tuple[bool, Any]: + """Assign DSCP slot and configure FRR devices for dataplane support.""" + if _slice_manager.is_full(): + return False, 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 False, send_response(False, code=429, message=f"Could not assign slot for DSCP {dscp}") + + logger.info("Assigned DSCP %s to SLICE-%s", dscp, 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 False, send_response( + False, + code=500, + message=f"FRR config failed on {device_config['management_address']}: {e!s}", + ) + + return True, None + + +def restconf_connect(requests: dict[str, Any], restconf_ip: str) -> Any: + """Connect to controller and upload RESTCONF/TFS services. + Args: - requests (dict): Dictionary containing services to upload - tfs_ip (str): IP address of the TFS controller - + requests: Dictionary containing service intents under 'services'. + restconf_ip: IP address of the RESTCONF/TFS controller. + Returns: - response (requests.Response): Response from TFS controller - """ + Response from controller or API error response. + """ response = None - 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']}: {e!s}") - - 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}") + for intent in requests.get("services", []): + controller_type = current_app.config.get("SDN_CONTROLLER_TYPE") + if controller_type != "TFS": + return send_response(False, code=400, message=f"Unsupported SDN controller type: {controller_type}") + + 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.get("DATAPLANE_SUPPORT") == "FRR": + success, error_response = _handle_frr_dataplane(dscp) + if not success: + return error_response else: - return send_response(False, code=400, message=f"Unsupported SDN controller type: {current_app.config['SDN_CONTROLLER_TYPE']}") + 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}", + ) if response is None: return send_response(True, code=200, message="No services processed") - return response \ No newline at end of file + return response diff --git a/src/realizer/restconf/service_types/builders/apply_metric_constraint.py b/src/realizer/restconf/service_types/builders/apply_metric_constraint.py index f98ec94..9672223 100644 --- a/src/realizer/restconf/service_types/builders/apply_metric_constraint.py +++ b/src/realizer/restconf/service_types/builders/apply_metric_constraint.py @@ -14,51 +14,91 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. -def apply_metric_constraint(service, qos_class, constraint, vpn_id, layer_type): - """Aplica una restricción de métrica específica.""" +"""Metric constraint applicability builders for QoS and SLO profiles.""" + +from __future__ import annotations + +from typing import Any + +_BANDWIDTH_MULTIPLIER = { + "bps": 1, + "kbps": 1_000, + "Mbps": 1_000_000, + "Gbps": 1_000_000_000, +} + + +def _apply_bandwidth( + service: dict[str, Any], + constraint: dict[str, Any], + metric_value: float, + vpn_id: str, + layer_type: str, +) -> None: + """Apply bandwidth constraints according to layer type.""" + unit = constraint.get("metric-unit", "bps") + multiplier = _BANDWIDTH_MULTIPLIER.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": int(bandwidth * 0.05), + }, + { + "type": "bw-per-svc", + "direction": "output-bw", + "vpn-id": vpn_id, + "cir": bandwidth, + "cbs": int(bandwidth * 0.05), + }, + ] + } + elif layer_type == "l3": + service["svc-input-bandwidth"] = bandwidth + service["svc-output-bandwidth"] = bandwidth + + +def apply_metric_constraint( + service: dict[str, Any], + qos_class: dict[str, Any], + constraint: dict[str, Any], + vpn_id: str, + layer_type: str, +) -> None: + """Apply a specific SLO metric constraint to the service or QoS class. + + Args: + service: Target service dictionary to configure. + qos_class: QoS class sub-dictionary to configure. + constraint: Metric constraint definition dictionary. + vpn_id: VPN identifier. + layer_type: Network layer ('l2' or 'l3'). + """ 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": int(bandwidth*0.05) - }, - { - "type": "bw-per-svc", - "direction": "output-bw", - "vpn-id": vpn_id, - "cir": bandwidth, - "cbs": int(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 + match metric_type: + case "two-way-bandwidth": + _apply_bandwidth(service, constraint, metric_value, vpn_id, layer_type) + + case "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) + + case "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) + + case "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 index 84f9a0f..b543c0d 100644 --- a/src/realizer/restconf/service_types/builders/configure_match_criteria.py +++ b/src/realizer/restconf/service_types/builders/configure_match_criteria.py @@ -14,41 +14,46 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. +"""Match criteria configuration builders for network access and site objects.""" + +from __future__ import annotations + import logging +from typing import Any from src.utils.safe_get import safe_get +logger = logging.getLogger(__name__) -def configure_match_criteria(network_access, site, sdp, layer_type): - """Configura los criterios de coincidencia en el acceso a la red.""" - MATCH_TYPE_MAPPING = { - "dscp": "dscp", - "vlan": "dot1q", - "any": "any" - } +_BASE_MATCH_TYPE_MAPPING: dict[str, str] = { + "dscp": "dscp", + "vlan": "dot1q", + "any": "any", +} + + +def _get_match_mapping(layer_type: str) -> dict[str, str]: + """Retrieve match mapping dictionary according to network layer.""" + mapping = dict(_BASE_MATCH_TYPE_MAPPING) 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 - + mapping["source-ip-prefix"] = "ipv4-src-prefix" + mapping["destination-ip-prefix"] = "ipv4-dst-prefix" + return mapping + + +def _configure_vlan_match( + network_access: dict[str, Any], + site: dict[str, Any], + sdp: dict[str, Any], + value: Any, + layer_type: str, +) -> None: + """Configure VLAN matching logic for L2 or L3 sites.""" provider_address = safe_get(sdp, ["sdp", "attachment-circuits", "attachment-circuit", 0, "ac-ipv4-address"]) prefix_length = safe_get(sdp, ["sdp", "attachment-circuits", "attachment-circuit", 0, "ac-ipv4-prefix-length"]) lan = f"{provider_address}/{prefix_length}" if provider_address and prefix_length else None - if layer_type == "l3" and match_type == "vlan": + if layer_type == "l3": site["routing-protocols"] = {"routing-protocol": []} routing_protocol = { "type": "static", @@ -58,27 +63,61 @@ def configure_match_criteria(network_access, site, sdp, layer_type): { "lan": lan, "lan-tag": value, - "next-hop": provider_address # This is not correct, should be the management ip of the provider router, but we don't have that info in the SDP. Need to check how to handle this. + "next-hop": provider_address, } ] } - } + }, } - site["routing-protocols"]["routing-protocol"].append(routing_protocol) - return - elif layer_type == "l2" and match_type == "vlan": + elif layer_type == "l2": network_access["connection"]["tagged-interface"]["dot1q-vlan-tagged"]["cvlan-id"] = value - # Do not add rule when match type is any + +def configure_match_criteria( + network_access: dict[str, Any], + site: dict[str, Any], + sdp: dict[str, Any], + layer_type: str, +) -> None: + """Configure traffic match criteria rules in the network access policy. + + Args: + network_access: Network access dictionary to update. + site: Parent site dictionary. + sdp: Service Delivery Point specification. + layer_type: Network layer ('l2' or 'l3'). + """ + 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]) + + logger.debug( + "Configuring match criteria for SDP: %s with match type: %s and value: %s", + safe_get(sdp, ["sdp", "id"]), + match_type, + value, + ) + + mapping = _get_match_mapping(layer_type) + if match_type not in mapping: + logger.warning("Unknown match type: %s", match_type) + return + + if match_type == "vlan": + _configure_vlan_match(network_access, site, sdp, value, layer_type) + if layer_type == "l3": + return + if match_type == "any": return rule = { "id": f"match-{match_type}-{index}", - "match-flow": { - MATCH_TYPE_MAPPING[match_type]: value - } + "match-flow": {mapping[match_type]: value}, } - network_access["service"]["qos"]["qos-classification-policy"]["rule"].append(rule) diff --git a/src/realizer/restconf/service_types/builders/configure_slos.py b/src/realizer/restconf/service_types/builders/configure_slos.py index facc760..d7f669e 100644 --- a/src/realizer/restconf/service_types/builders/configure_slos.py +++ b/src/realizer/restconf/service_types/builders/configure_slos.py @@ -14,38 +14,50 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. +"""SLO configuration helper for network access objects.""" + +from __future__ import annotations + import logging +from typing import Any from src.utils.safe_get import safe_get from .apply_metric_constraint import apply_metric_constraint +logger = logging.getLogger(__name__) + -def configure_slos(network_access, ietf_intent, layer_type): - """Configura los SLOs (Service Level Objectives) en el acceso a la red.""" +def configure_slos( + network_access: dict[str, Any], + ietf_intent: dict[str, Any], + layer_type: str, +) -> None: + """Configure Service Level Objectives (SLOs) on the network access structure. + + Args: + network_access: Network access dictionary to mutate. + ietf_intent: Full IETF slice intent dictionary. + layer_type: Network layer ('l2' or 'l3'). + """ 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'])}") - - # Configure metric constraints + metric_bounds = safe_get(ietf_intent, ["template", "slo-policy", "metric-bound"]) + logger.debug("Configuring SLOs with constraints: %s", metric_bounds) + if metric_bounds: + vpn_id = str(ietf_intent.get("id", "")) for constraint in metric_bounds: - apply_metric_constraint(service, qos_class, constraint, ietf_intent["id"], layer_type) - - # Configure availability + apply_metric_constraint(service, qos_class, constraint, vpn_id, layer_type) + availability = safe_get(ietf_intent, ["template", "slo-policy", "availability"]) - if availability: + if availability is not None: qos_class.setdefault("bandwidth", {})["guaranteed-bw-percent"] = availability - - # Configure MTU + mtu = safe_get(ietf_intent, ["template", "slo-policy", "mtu"]) - if mtu: + if mtu is not None: service["svc-mtu"] = mtu - - # Configure availability and MTU defaults if not configured - if "guaranteed-bw-percent" not in qos_class.get("bandwidth", {}): - qos_class.setdefault("bandwidth", {})["guaranteed-bw-percent"] = 0 - if "svc-mtu" not in service: - service["svc-mtu"] = 1500 \ No newline at end of file + + qos_class.setdefault("bandwidth", {}).setdefault("guaranteed-bw-percent", 0) + service.setdefault("svc-mtu", 1500) 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 index 884ea6a..bc92bd3 100644 --- a/src/realizer/restconf/service_types/builders/create_site_from_sdp.py +++ b/src/realizer/restconf/service_types/builders/create_site_from_sdp.py @@ -14,7 +14,12 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. +"""Site structure builder from Service Delivery Point (SDP).""" + +from __future__ import annotations + import logging +from typing import Any from src.utils.safe_get import safe_get @@ -22,59 +27,71 @@ from .configure_match_criteria import configure_match_criteria from .configure_slos import configure_slos from .create_network_access import create_network_access +logger = logging.getLogger(__name__) -def create_site_from_sdp(sdp, ietf_intent, connectivity_type, layer_type): - """ - Creates the configuration of a site from an SDP. + +def create_site_from_sdp( + sdp: dict[str, Any], + ietf_intent: dict[str, Any], + connectivity_type: str, + layer_type: str, +) -> dict[str, Any]: + """Create the site configuration structure from an SDP. Args: - sdp: Service Delivery Point - ietf_intent: Complete IETF intent - connectivity_type: Connectivity type + sdp: Service Delivery Point dictionary. + ietf_intent: Full IETF slice intent dictionary. + connectivity_type: Connectivity type (point-to-point, hub-spoke, etc.). + layer_type: Network layer ('l2' or 'l3'). Returns: - Dictionary with site configuration + Structured site dictionary. """ - logging.debug(f"Processing SDP: {sdp}") - - # Extract basic information + logger.debug("Processing SDP: %s", sdp) + 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"]) - sdp_id = safe_get(sdp, ["sdp", "id"]) or safe_get(sdp, ["sdp", "node-id"]) - logging.debug(f"Configured site for SDP {sdp_id} with location: {location}, router_id: {router_id}, router_if: {router_if}") - - network_access = create_network_access(sdp, ietf_intent, connectivity_type, router_id, router_if, layer_type) - # Create site structure - site = { + logger.debug( + "Configured site for SDP %s with location: %s, router_id: %s, router_if: %s", + sdp_id, + location, + router_id, + router_if, + ) + + network_access = create_network_access( + sdp, + ietf_intent, + connectivity_type, + router_id, + router_if, + layer_type, + ) + + site: dict[str, Any] = { "site-id": sdp_id, - "locations": { - "location": [{"location-id": location}] - }, + "locations": {"location": [{"location-id": location}]}, "devices": { - "device": [{ - "device-id": router_id, - "location": location - }] - }, - "management": { - "type": "provider-managed" + "device": [ + { + "device-id": router_id, + "location": location, + } + ] }, - "site-network-accesses": { - "site-network-access": [network_access] - } + "management": {"type": "provider-managed"}, + "site-network-accesses": {"site-network-access": [network_access]}, } if layer_type == "l2": site["default-ce-vlan-id"] = 1 - - if layer_type == "l3": + elif layer_type == "l3": site["routing-protocols"] = {"routing-protocol": []} - # Configure match criteria and SLOs configure_match_criteria(network_access, site, sdp, layer_type) configure_slos(network_access, ietf_intent, 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 index d8d652f..3bf2ebd 100644 --- a/src/realizer/restconf/service_types/builders/initialize_structure.py +++ b/src/realizer/restconf/service_types/builders/initialize_structure.py @@ -14,25 +14,38 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. -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": [] - } - } - } +"""Base YANG datastore structure initializer for L2VPN/L3VPN services.""" + +from __future__ import annotations + +from typing import Any + + +def initialize_structure(vpn_id: str, connectivity_type: str, layer_type: str) -> dict[str, Any]: + """Initialize the base IETF L2VPN or L3VPN service structure. + + Args: + vpn_id: Unique VPN identifier. + connectivity_type: Type of VPN connectivity (point-to-point, hub-spoke, etc.). + layer_type: Network layer ('l2' or 'l3'). + + Returns: + Structured base service dictionary. + """ + vpn_service_entry: dict[str, Any] = {"vpn-id": vpn_id} if layer_type == "l2": - structure[f"ietf-{layer_type}vpn-svc:{layer_type}vpn-svc"]["vpn-services"]["vpn-service"][0] = { - "vpn-id": vpn_id, - "ce-vlan-preservation": False, - "ce-vlan-cos-preservation": False, - "frame-delivery": { "multicast-gp-port-mapping":"static-mapping"} # This field should not be needed, it is optional but the YANG validator in TFS sets it to mandatory + vpn_service_entry.update( + { + "ce-vlan-preservation": False, + "ce-vlan-cos-preservation": False, + "frame-delivery": {"multicast-gp-port-mapping": "static-mapping"}, + } + ) + + root_key = f"ietf-{layer_type}vpn-svc:{layer_type}vpn-svc" + return { + root_key: { + "vpn-services": {"vpn-service": [vpn_service_entry]}, + "sites": {"site": []}, } - 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 index e2302d3..2de1d98 100644 --- a/src/realizer/restconf/service_types/l2vpn.py +++ b/src/realizer/restconf/service_types/l2vpn.py @@ -1,11 +1,11 @@ # Copyright 2022-2026 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. @@ -14,39 +14,41 @@ # This file includes original contributions from Telefonica Innovación Digital S.L. +"""IETF L2VPN service model realizer for RESTCONF.""" + +from __future__ import annotations + import logging +from typing import Any from .builders.create_site_from_sdp import create_site_from_sdp from .builders.initialize_structure import initialize_structure +logger = logging.getLogger(__name__) -def l2vpn(ietf_intent): - """ - Creates an L2VPN service based on the provided IETF intent. + +def l2vpn(ietf_intent: dict[str, Any]) -> dict[str, Any] | None: + """Create an L2VPN service payload based on the provided IETF slice intent. Args: - ietf_intent: Dictionary with IETF intent configuration - response: Response object + ietf_intent: Dictionary containing parsed IETF slice configuration. Returns: - Dictionary with L2VPN service configuration or None if no SDPs + Structured L2VPN service dictionary or None if SDPs are missing. """ - # Early validation - if not ietf_intent.get("sdps"): - logging.warning("SDPs not found in the intent. Skipping L2VPN realization.") + sdps = ietf_intent.get("sdps") + if not sdps: + logger.warning("SDPs not found in the intent. Skipping L2VPN realization.") return None - # Initialize L2VPN structure - connectivity_type = ietf_intent["connectivity_type"] - l2_service = initialize_structure(ietf_intent["id"], connectivity_type, layer_type="l2") - - - # Process each SDP - for sdp in ietf_intent["sdps"]: + connectivity_type = ietf_intent.get("connectivity_type", "ietf-vpn-common:point-to-point") + vpn_id = str(ietf_intent.get("id", "")) + l2_service = initialize_structure(vpn_id, connectivity_type, layer_type="l2") + + for sdp in 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 + logger.debug("L2VPN service created: %s", l2_service) + logger.info("L2VPN Intent realized") + return l2_service diff --git a/src/realizer/restconf/service_types/l3vpn.py b/src/realizer/restconf/service_types/l3vpn.py index cd69996..30f7625 100644 --- a/src/realizer/restconf/service_types/l3vpn.py +++ b/src/realizer/restconf/service_types/l3vpn.py @@ -1,11 +1,11 @@ # Copyright 2022-2026 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. @@ -14,38 +14,41 @@ # This file includes original contributions from Telefonica Innovación Digital S.L. +"""IETF L3VPN service model realizer for RESTCONF.""" + +from __future__ import annotations + import logging +from typing import Any from .builders.create_site_from_sdp import create_site_from_sdp from .builders.initialize_structure import initialize_structure +logger = logging.getLogger(__name__) -def l3vpn(ietf_intent): - """ - Creates an L3VPN service based on the provided IETF intent. + +def l3vpn(ietf_intent: dict[str, Any]) -> dict[str, Any] | None: + """Create an L3VPN service payload based on the provided IETF slice intent. Args: - ietf_intent: Dictionary with IETF intent configuration - response: Response object + ietf_intent: Dictionary containing parsed IETF slice configuration. Returns: - Dictionary with L3VPN service configuration or None if no SDPs + Structured L3VPN service dictionary or None if SDPs are missing. """ - # Early validation - if not ietf_intent.get("sdps"): - logging.warning("SDPs not found in the intent. Skipping L3VPN realization.") + sdps = ietf_intent.get("sdps") + if not sdps: + logger.warning("SDPs not found in the intent. Skipping L3VPN realization.") return None - # Initialize L3VPN structure - connectivity_type = ietf_intent["connectivity_type"] - l3_service = initialize_structure(ietf_intent["id"], connectivity_type, layer_type="l3") - - # Process each SDP - for sdp in ietf_intent["sdps"]: + connectivity_type = ietf_intent.get("connectivity_type", "ietf-vpn-common:point-to-point") + vpn_id = str(ietf_intent.get("id", "")) + l3_service = initialize_structure(vpn_id, connectivity_type, layer_type="l3") + + for sdp in 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 + logger.debug("L3VPN service created: %s", l3_service) + logger.info("L3VPN Intent realized") + return l3_service diff --git a/src/realizer/select_way.py b/src/realizer/select_way.py index ab87fad..592afbc 100644 --- a/src/realizer/select_way.py +++ b/src/realizer/select_way.py @@ -1,57 +1,62 @@ -# Copyright 2022-2026 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 typing import Any - -from .e2e.main import e2e -from .ixia.main import ixia -from .restconf.main import restconf -from .tfs.main import tfs - - -def select_way( - controller: str | None = None, - way: str | None = None, - ietf_intent: Any = None, - response: Any = None, - rules: Any = None, -) -> Any: - """ - Determine the method of slice realization and invoke the appropriate provider. - - Args: - controller (str, optional): Target controller (TFS, IXIA, E2E, RESTCONF). - way (str, optional): Technology way identifier. - ietf_intent (Any, optional): IETF formatted network slice intent. - response (Any, optional): Outgoing user response dictionary. - rules (Any, optional): Optional rule specifications. - - Returns: - Any: Response payload from the selected controller integration. - """ - match controller: - case "TFS": - return tfs(ietf_intent, way, response) - case "IXIA": - return ixia(ietf_intent) - case "E2E": - return e2e(ietf_intent, way, response, rules) - case "RESTCONF": - return restconf(ietf_intent, way, response) - case _: - logging.warning(f"Unsupported controller: {controller}. Defaulting to TFS realization.") - return tfs(ietf_intent, way, response) \ No newline at end of file +# Copyright 2022-2026 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. + +"""Dispatcher for selecting realization provider according to target controller.""" + +from __future__ import annotations + +import logging +from typing import Any + +from .e2e.main import e2e +from .ixia.main import ixia +from .restconf.main import restconf +from .tfs.main import tfs + +logger = logging.getLogger(__name__) + + +def select_way( + controller: str | None = None, + way: str | None = None, + ietf_intent: Any = None, + response: Any = None, + rules: Any = None, +) -> Any: + """Determine the method of slice realization and invoke the appropriate provider. + + Args: + controller: Target controller (TFS, IXIA, E2E, RESTCONF). + way: Technology way identifier (e.g., 'L2', 'L3', 'L3oWDM'). + ietf_intent: IETF formatted network slice intent. + response: Outgoing user response dictionary. + rules: Dynamic rule specifications. + + Returns: + Response payload from the selected controller integration. + """ + match controller: + case "TFS": + return tfs(ietf_intent, way, response) + case "IXIA": + return ixia(ietf_intent) + case "E2E": + return e2e(ietf_intent, way, response, rules) + case "RESTCONF": + return restconf(ietf_intent, way, response) + case _: + logger.warning("Unsupported controller: %s. Defaulting to TFS realization.", controller) + return tfs(ietf_intent, way, response) diff --git a/src/realizer/send_controller.py b/src/realizer/send_controller.py index 1302e73..10bfcee 100644 --- a/src/realizer/send_controller.py +++ b/src/realizer/send_controller.py @@ -1,11 +1,11 @@ # Copyright 2022-2026 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 - +# +# 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. @@ -14,6 +14,10 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. +"""Router dispatching provisioning requests to designated network controllers.""" + +from __future__ import annotations + import logging from typing import Any @@ -24,24 +28,25 @@ from .ixia.ixia_connect import ixia_connect from .restconf.restconf_connect import restconf_connect from .tfs.tfs_connect import tfs_connect +logger = logging.getLogger(__name__) + def send_controller( - controller_type: str, + controller_type: str | None, requests: Any, is_update: bool = False, old_service_id: str | None = None, ) -> Any: - """ - Route provisioning requests to the appropriate network controller. + """Route provisioning requests to the appropriate network controller. Args: - controller_type (str): Type of controller ("TFS", "IXIA", "E2E", "RESTCONF"). - requests (Any): Configuration request payload. - is_update (bool, optional): Whether it is a modification request. Defaults to False. - old_service_id (str, optional): Old service ID to delete on update. Defaults to None. + controller_type: Type of controller ("TFS", "IXIA", "E2E", "RESTCONF"). + requests: Configuration request payload. + is_update: Whether it is a modification request. Defaults to False. + old_service_id: Old service ID to delete on update. Defaults to None. Returns: - Any: Response from the controller, or True if DUMMY_MODE is active. + Response from the controller, or True if DUMMY_MODE is active. """ if current_app.config.get("DUMMY_MODE", False): return True @@ -49,12 +54,14 @@ def send_controller( match controller_type: case "TFS": response = tfs_connect(requests, current_app.config["TFS_IP"]) - logging.info("Request sent to Teraflow") + logger.info("Request sent to Teraflow") return response + case "IXIA": response = ixia_connect(requests, current_app.config["IXIA_IP"]) - logging.info("Requests sent to Ixia") + logger.info("Requests sent to Ixia") return response + case "E2E": response = e2e_connect( requests, @@ -62,12 +69,14 @@ def send_controller( is_update=is_update, old_service_id=old_service_id, ) - logging.info("Requests sent to Teraflow E2E") + logger.info("Requests sent to Teraflow E2E") return response + case "RESTCONF": response = restconf_connect(requests, current_app.config["RESTCONF_IP"]) - logging.info("Requests sent to restconf controller") + logger.info("Requests sent to restconf controller") return response + case _: - logging.warning(f"Unknown controller type: {controller_type}") - return None \ No newline at end of file + logger.warning("Unknown controller type: %s", controller_type) + return None diff --git a/src/realizer/tfs/helpers/cisco_connector.py b/src/realizer/tfs/helpers/cisco_connector.py index cc26560..9430506 100644 --- a/src/realizer/tfs/helpers/cisco_connector.py +++ b/src/realizer/tfs/helpers/cisco_connector.py @@ -1,11 +1,11 @@ # Copyright 2022-2026 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. @@ -14,108 +14,92 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. +"""SSH Netmiko connector for Cisco XR devices within TFS integration.""" + +from __future__ import annotations + import logging +from typing import Any from netmiko import ConnectHandler +logger = logging.getLogger(__name__) + 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 + """Class to interact with Cisco XR devices via SSH using Netmiko.""" + + def __init__(self, address: str, configs: list[dict[str, Any]] | None = None) -> None: + self.address = address + self.configs = configs or [] + + def execute_commands(self, commands: list[str]) -> None: + """Execute a list of commands on the Cisco device. - 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. + commands: List of commands to execute on the device. """ + device: dict[str, Any] = { + "device_type": "cisco_xr", + "host": self.address, + "username": "cisco", + "password": "cisco12345", + } try: - # Device configuration - device = { - 'device_type': 'cisco_xr', - '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() + with ConnectHandler(**device) as connection: + output = connection.send_config_set(commands) + logger.debug(output) + except Exception as e: + logger.error("Failed to execute commands on %s: %s", self.address, e) - except EOFError as e: - logging.error("Failed to execute commands on %s: %s",self.address, str(e)) - - def create_command_template(self, config): - """ - Create command template for configuring a Cisco device. + def create_command_template(self, config: dict[str, Any]) -> list[str]: + """Create command template for configuring a Cisco device. Args: - config (dict): Configuration parameters for the device. + config: Configuration parameters for the device. Returns: - list: List of commands to configure the device. + List of commands to configure the device. """ - commands = [ - "l2vpn", - f"pw-class l2vpn_vpws_profile_example_{config['number']}", - "encapsulation mpls" - ] + number = config.get("number") + ni_name = config.get("ni_name") + interface = config.get("interface") + vlan = config.get("vlan") + remote_router = config.get("remote_router") - commands.extend([ + return [ + "l2vpn", + f"pw-class l2vpn_vpws_profile_example_{number}", + "encapsulation mpls", "transport-mode vlan passthrough", - "control-word" - ]) - - commands.extend([ - f"preferred-path interface tunnel-te {config['number']}", + "control-word", + f"preferred-path interface tunnel-te {number}", + "exit", "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']}", + f"p2p {ni_name}", + f"interface {interface}.{vlan}", + f"neighbor ipv4 {remote_router} pw-id {vlan}", "no pw-class l2vpn_vpws_profile_example", - f"pw-class l2vpn_vpws_profile_example_{config['number']}" - ]) - - return commands + f"pw-class l2vpn_vpws_profile_example_{number}", + ] - def full_create_command_template(self): - """ - Create full command template for configuring a Cisco device based on the provided configurations. + def full_create_command_template(self) -> list[str]: + """Create full command template for configuring a Cisco device based on the provided configurations. Returns: - list: List of commands to configure the device. + List of commands to configure the device. """ - commands =[] + commands: list[str] = [] for config in self.configs: - commands_temp = self.create_command_template(config) - commands.extend(commands_temp) - commands.append("commit") - commands.append("end") + commands.extend(self.create_command_template(config)) + commands.extend(["commit", "end"]) return commands - def create_command_template_delete(self): - """ - Create command template for deleting L2VPN configuration on a Cisco device. + def create_command_template_delete(self) -> list[str]: + """Create command template for deleting L2VPN configuration on a Cisco device. + Returns: - list: List of commands to delete the L2VPN configuration. + List of commands to delete the L2VPN configuration. """ - commands = [ - "no l2vpn", - ] - - commands.append("commit") - commands.append("end") - return commands + return ["no l2vpn", "commit", "end"] diff --git a/src/realizer/tfs/helpers/tfs_connector.py b/src/realizer/tfs/helpers/tfs_connector.py index 2d20da0..e919d43 100644 --- a/src/realizer/tfs/helpers/tfs_connector.py +++ b/src/realizer/tfs/helpers/tfs_connector.py @@ -1,11 +1,11 @@ # Copyright 2022-2026 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. @@ -14,157 +14,111 @@ # This file includes original contributions from Telefonica Innovación Digital S.L. +"""TeraFlowSDN NBI and WebUI HTTP connector helper.""" + +from __future__ import annotations + import json import logging +from typing import Any import requests from src.config.constants import NBI_L2_PATH, NBI_L3_PATH +logger = logging.getLogger(__name__) + -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="" +class tfs_connector: + """Helper class to interact with TeraFlowSDN Northbound Interface (NBI) and WebUI.""" + + def webui_post(self, tfs_ip: str, service: dict[str, Any]) -> requests.Response: + """Post service descriptor to TFS WebUI.""" 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: + """Post service descriptor to TFS NBI.""" session = requests.Session() - session.auth = (user, password) - url = f'http://{tfs_ip}/{path}' - headers = {'Content-Type': 'application/json'} + session.auth = ("admin", "admin") + url = f"http://{tfs_ip}/{path}" + headers = {"Content-Type": "application/json"} data = json.dumps(service) - logging.debug("Posting to TFS NBI: %s",data) - token={'csrf_token':token} - response = session.post(url,headers=headers,data=data,timeout=60) + logger.debug("Posting to TFS NBI: %s", data) + response = session.post(url, headers=headers, data=data, timeout=60) response.raise_for_status() - logging.debug("Http response: %s",response.text) + logger.debug("Http response: %s", response.text) return response - def ipowdm_post(self, tfs_ip: str, slice_id: str, payload: object, timeout: int = 60): - """ - Post IPoWDM service payload to the controller NBI endpoint: - http://{tfs_ip}/restconf/ipowdm/v1/service/{slice_id} - - Args: - tfs_ip: controller host (ip[:port]) - slice_id: identifier for the slice (path parameter) - payload: JSON-serializable payload to send - timeout: request timeout in seconds - - Returns: - requests.Response - """ + def ipowdm_post(self, tfs_ip: str, slice_id: str, payload: Any, timeout: int = 60) -> requests.Response: + """Post IPoWDM service payload to the controller NBI endpoint.""" session = requests.Session() - url = f'http://{tfs_ip}/restconf/ipowdm/v1/service/{slice_id}' - headers = {'Content-Type': 'application/json'} + url = f"http://{tfs_ip}/restconf/ipowdm/v1/service/{slice_id}" + headers = {"Content-Type": "application/json"} data = json.dumps(payload) - logging.debug("Posting IPoWDM to %s: %s", url, data) + logger.debug("Posting IPoWDM to %s: %s", url, data) response = session.post(url, headers=headers, data=data, timeout=timeout) response.raise_for_status() - logging.debug("Http response: %s", response.text) - # STATIC RESPONSE FOR TESTING PURPOSES - # response = requests.Response() - # response.status_code = 200 - # response._content = b'{"status": "success", "message": "IPoWDM service created successfully"}' - # logging.debug("Mocked Http response: %s", response.text) + logger.debug("Http response: %s", response.text) return response - def ipowdm_put(self, tfs_ip: str, slice_id: str, payload: object, timeout: int = 60): - """ - Put (Update) IPoWDM service payload to the controller NBI endpoint: - PUT http://{tfs_ip}/restconf/ipowdm/v1/service/{slice_id} - """ + def ipowdm_put(self, tfs_ip: str, slice_id: str, payload: Any, timeout: int = 60) -> requests.Response: + """Put (Update) IPoWDM service payload to the controller NBI endpoint.""" session = requests.Session() - url = f'http://{tfs_ip}/restconf/ipowdm/v1/service/{slice_id}' - headers = {'Content-Type': 'application/json'} + url = f"http://{tfs_ip}/restconf/ipowdm/v1/service/{slice_id}" + headers = {"Content-Type": "application/json"} data = json.dumps(payload) - logging.debug("Putting IPoWDM to %s: %s", url, data) + logger.debug("Putting IPoWDM to %s: %s", url, data) response = session.put(url, headers=headers, data=data, timeout=timeout) response.raise_for_status() - logging.debug("Http response: %s", response.text) + logger.debug("Http response: %s", response.text) return response - def ipowdm_delete(self, tfs_ip: str, slice_id: str, timeout: int = 60): - """ - Delete IPoWDM service payload from the controller NBI endpoint: - DELETE http://{tfs_ip}/restconf/ipowdm/v1/service/{slice_id} - """ + def ipowdm_delete(self, tfs_ip: str, slice_id: str, timeout: int = 60) -> requests.Response | None: + """Delete IPoWDM service payload from the controller NBI endpoint.""" session = requests.Session() - url = f'http://{tfs_ip}/restconf/ipowdm/v1/service/{slice_id}' - headers = {'Content-Type': 'application/json'} - logging.debug("Deleting IPoWDM at %s", url) + url = f"http://{tfs_ip}/restconf/ipowdm/v1/service/{slice_id}" + headers = {"Content-Type": "application/json"} + logger.debug("Deleting IPoWDM at %s", url) try: response = session.delete(url, headers=headers, json={}, timeout=timeout) response.raise_for_status() - logging.debug("Delete Http response: %s", response.text) + logger.debug("Delete Http response: %s", response.text) return response except Exception as e: - logging.error(f"Failed to delete IPoWDM service: {e}") + logger.error("Failed to delete IPoWDM service: %s", e) return None - - def nbi_delete(self, tfs_ip: str, service_type: str , service_id: str) -> 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'.") + + def nbi_delete(self, tfs_ip: str, service_type: str, service_id: str) -> requests.Response: + """Delete service from TFS NBI.""" + base_url = f"http://admin:admin@{tfs_ip}" + match service_type: + case "L2": + url = f"{base_url}/{NBI_L2_PATH}/vpn-service={service_id}" + case "L3": + url = f"{base_url}/{NBI_L3_PATH}/vpn-service={service_id}" + case _: + 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 + logger.debug("Service deleted successfully") + logger.debug("Http response: %s", response.text) + return response diff --git a/src/realizer/tfs/main.py b/src/realizer/tfs/main.py index d79bc30..8e7cb37 100644 --- a/src/realizer/tfs/main.py +++ b/src/realizer/tfs/main.py @@ -1,11 +1,11 @@ # Copyright 2022-2026 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. @@ -14,29 +14,39 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. +"""TeraFlowSDN (TFS) slice intent realizer entry point.""" + +from __future__ import annotations + import logging +from typing import Any from .service_types.tfs_l2vpn import tfs_l2vpn from .service_types.tfs_l3vpn import tfs_l3vpn +logger = logging.getLogger(__name__) -def tfs(ietf_intent, way=None, response=None): - """ - Generates a TFS realizing request based on the specified way (L2 or L3). + +def tfs( + ietf_intent: dict[str, Any], + way: str | None = None, + response: Any = None, +) -> dict[str, Any] | None: + """Generate a TFS realization 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. - + ietf_intent: The IETF slice intent dictionary to be realized. + way: Service layer type ('L2' or 'L3'). Defaults to None. + response: Response built for user feedback. Defaults to None. + Returns: - dict: A realization request for the specified network slice type. + Realization request dictionary or None if endpoints are missing. """ - if way == "L2": - realizing_request = tfs_l2vpn(ietf_intent, response) - elif way == "L3": - realizing_request = tfs_l3vpn(ietf_intent, response) - else: - logging.warning(f"Unsupported way: {way}. Defaulting to L2 realization.") - realizing_request = tfs_l2vpn(ietf_intent, response) - return realizing_request \ No newline at end of file + match way: + case "L2": + return tfs_l2vpn(ietf_intent, response) + case "L3": + return tfs_l3vpn(ietf_intent, response) + case _: + logger.warning("Unsupported way: %s. Defaulting to L2 realization.", way) + return tfs_l2vpn(ietf_intent, response) diff --git a/src/realizer/tfs/service_types/tfs_l2vpn.py b/src/realizer/tfs/service_types/tfs_l2vpn.py index 3a3ca1d..3a25bad 100644 --- a/src/realizer/tfs/service_types/tfs_l2vpn.py +++ b/src/realizer/tfs/service_types/tfs_l2vpn.py @@ -1,11 +1,11 @@ # Copyright 2022-2026 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. @@ -14,8 +14,13 @@ # This file includes original contributions from Telefonica Innovación Digital S.L. +"""TFS L2VPN slice intent translation and service template builder.""" + +from __future__ import annotations + import logging -import os +from pathlib import Path +from typing import Any from flask import current_app @@ -25,118 +30,186 @@ from src.utils.safe_get import safe_get from ..helpers.cisco_connector import cisco_connector +logger = logging.getLogger(__name__) + + +def _build_webui_l2vpn( + ietf_intent: dict[str, Any], + slice_data: dict[str, Any] | None, + origin_router_id: str, + destination_router_id: str, +) -> dict[str, Any]: + """Build TFS WebUI L2VPN service descriptor.""" + origin_router_if = "0/0/0-GigabitEthernet0/0/0/0" + destination_router_if = "0/0/0-GigabitEthernet0/0/0/0" + + tfs_request = load_template(str(Path(TEMPLATES_PATH) / "L2-VPN_template_empty.json"))["services"][0] + slice_id = safe_get( + ietf_intent, + ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "id"], + ) + tfs_request["service_id"]["service_uuid"]["uuid"] = slice_id + + for endpoint in tfs_request["service_endpoint_ids"]: + is_first = endpoint is tfs_request["service_endpoint_ids"][0] + endpoint["device_id"]["device_uuid"]["uuid"] = origin_router_id if is_first else destination_router_id + endpoint["endpoint_uuid"]["uuid"] = origin_router_if if is_first else destination_router_if + + if slice_data: + for constraint in slice_data.get("requirements", []): + tfs_request["service_constraints"].append({"custom": constraint}) + + for i, config_rule in enumerate(tfs_request["service_config"]["config_rules"][1:], start=1): + router_id = origin_router_id if i == 1 else destination_router_id + router_if = origin_router_if if i == 1 else destination_router_if + resource_value = config_rule["custom"]["resource_value"] + + sdp_index = i - 1 + vlan_value = safe_get( + ietf_intent, + [ + "ietf-network-slice-service:network-slice-services", + "slice-service", + 0, + "sdps", + "sdp", + sdp_index, + "service-match-criteria", + "match-criterion", + 0, + "match-type", + 0, + "vlan", + 0, + ], + ) + if vlan_value: + resource_value["vlan_id"] = int(vlan_value) + resource_value["circuit_id"] = vlan_value + resource_value["remote_router"] = destination_router_id if i == 1 else origin_router_id + resource_value["ni_name"] = f"ELAN{vlan_value!s}" + config_rule["custom"]["resource_key"] = f"/device[{router_id}]/endpoint[{router_if}]/settings" + + return tfs_request + + +def _build_nbi_l2vpn( + ietf_intent: dict[str, Any], + origin_router_id: str, + destination_router_id: str, +) -> dict[str, Any]: + """Build TFS NBI L2VPN service descriptor.""" + tfs_request = load_template(str(Path(TEMPLATES_PATH) / "ietfL2VPN_template_empty.json")) + tfs_request["path"] = NBI_L2_PATH + + full_id = ( + safe_get( + ietf_intent, + ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "id"], + ) + or "" + ) + uuid_only = full_id.split("slice-service-")[-1] + tfs_request["ietf-l2vpn-svc:vpn-service"][0]["vpn-id"] = uuid_only + + sites = tfs_request["ietf-l2vpn-svc:vpn-service"][0]["site"] + sdps = ( + safe_get( + ietf_intent, + ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "sdps", "sdp"], + ) + or [] + ) + + for i, site in enumerate(sites): + is_origin = i == 0 + router_id = origin_router_id if is_origin else destination_router_id + sdp = sdps[0] if is_origin and len(sdps) > 0 else (sdps[1] if len(sdps) > 1 else {}) + site["site-id"] = router_id + site["site-location"] = sdp.get("node-id") + site["site-network-access"]["interface"]["ip-address"] = sdp.get("sdp-ip-address") + + return tfs_request -def tfs_l2vpn(ietf_intent, response): - """ - Translate slice intent into a TeraFlow service request. - This method prepares a L2VPN service request by: - 1. Defining endpoint routers - 2. Loading a service template - 3. Generating a unique service UUID - 4. Configuring service endpoints - 5. Adding QoS constraints - 6. Preparing configuration rules for network interfaces +def tfs_l2vpn( + ietf_intent: dict[str, Any], + response: list[dict[str, Any]] | None = None, +) -> dict[str, Any] | None: + """Translate slice intent into a TeraFlow L2VPN service request. Args: - ietf_intent (dict): IETF-formatted network slice intent. - response (dict): Response data containing slice information. + ietf_intent: IETF-formatted network slice intent. + response: Slice database records. Returns: - dict: A TeraFlow service request for L2VPN configuration. - + Structured TFS L2VPN service request. """ - # 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"]) + logger.info("Translating TFS L2VPN intent: %s", 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.") + logger.warning("Origin router ID not found in the intent. Skipping L2VPN realization.") return None - origin_router_if = '0/0/0-GigabitEthernet0/0/0/0' - destination_router_id = safe_get(ietf_intent, ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "sdps", "sdp", 1, "attachment-circuits", "attachment-circuit", 0, "sdp-peering", "peer-sap-id"]) + + destination_router_id = safe_get( + ietf_intent, + [ + "ietf-network-slice-service:network-slice-services", + "slice-service", + 0, + "sdps", + "sdp", + 1, + "attachment-circuits", + "attachment-circuit", + 0, + "sdp-peering", + "peer-sap-id", + ], + ) if not destination_router_id: - logging.warning("Destination router ID not found in the intent. Skipping L2VPN realization.") + logger.warning("Destination router ID not found in the intent. Skipping L2VPN realization.") return None - destination_router_if = '0/0/0-GigabitEthernet0/0/0/0' - id = ietf_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"] - slice = next((d for d in response if d.get("id") == id), None) - - if current_app.config["UPLOAD_TYPE"] == "WEBUI": - # Load L2VPN service template - tfs_request = load_template(os.path.join(TEMPLATES_PATH, "L2-VPN_template_empty.json"))["services"][0] - - # Configure service UUID - tfs_request["service_id"]["service_uuid"]["uuid"] = ietf_intent['ietf-network-slice-service:network-slice-services']['slice-service'][0]["id"] - # Configure service endpoints - for endpoint in tfs_request["service_endpoint_ids"]: - endpoint["device_id"]["device_uuid"]["uuid"] = origin_router_id if endpoint is tfs_request["service_endpoint_ids"][0] else destination_router_id - endpoint["endpoint_uuid"]["uuid"] = origin_router_if if endpoint is tfs_request["service_endpoint_ids"][0] else destination_router_if - - # Add service constraints - for constraint in slice.get("requirements", []): - tfs_request["service_constraints"].append({"custom": constraint}) + slice_id = safe_get( + ietf_intent, + ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "id"], + ) + slice_data = next((d for d in (response or []) if d.get("id") == slice_id), None) + + upload_type = current_app.config.get("UPLOAD_TYPE", "WEBUI") + if upload_type == "WEBUI": + tfs_request = _build_webui_l2vpn(ietf_intent, slice_data, origin_router_id, destination_router_id) + elif upload_type == "NBI": + tfs_request = _build_nbi_l2vpn(ietf_intent, origin_router_id, destination_router_id) + else: + logger.warning("Unsupported upload type: %s", upload_type) + return None - # Add configuration rules - for i, config_rule in enumerate(tfs_request["service_config"]["config_rules"][1:], start=1): - router_id = origin_router_id if i == 1 else destination_router_id - router_if = origin_router_if if i == 1 else destination_router_if - resource_value = config_rule["custom"]["resource_value"] - - sdp_index = i - 1 - vlan_value = ietf_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][sdp_index]["service-match-criteria"]["match-criterion"][0]["match-type"][0]["vlan"][0] - if vlan_value: - resource_value["vlan_id"] = int(vlan_value) - resource_value["circuit_id"] = vlan_value - resource_value["remote_router"] = destination_router_id if i == 1 else origin_router_id - resource_value["ni_name"] = f'ELAN{vlan_value!s:s}' - config_rule["custom"]["resource_key"] = f"/device[{router_id}]/endpoint[{router_if}]/settings" - - elif current_app.config["UPLOAD_TYPE"] == "NBI": - #self.path = NBI_L2_PATH - # Load IETF L2VPN service template - tfs_request = load_template(os.path.join(TEMPLATES_PATH, "ietfL2VPN_template_empty.json")) - - # Add path to the request - tfs_request["path"] = NBI_L2_PATH - - # Generate service UUID - full_id = ietf_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"] - uuid_only = full_id.split("slice-service-")[-1] - tfs_request["ietf-l2vpn-svc:vpn-service"][0]["vpn-id"] = uuid_only - - # Configure service endpoints - sites = tfs_request["ietf-l2vpn-svc:vpn-service"][0]["site"] - sdps = ietf_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"] - - for i, site in enumerate(sites): - is_origin = (i == 0) - router_id = origin_router_id if is_origin else destination_router_id - sdp = sdps[0] if is_origin else sdps[1] - site["site-id"] = router_id - site["site-location"] = sdp["node-id"] - site["site-network-access"]["interface"]["ip-address"] = sdp["sdp-ip-address"] - - logging.info("L2VPN Intent realized") + logger.info("L2VPN Intent realized") return tfs_request -def tfs_l2vpn_support(requests): - """ - Configuration support for L2VPN with path selection based on MPLS traffic-engineering tunnels - Args: - requests (list): A list of configuration parameters. +def tfs_l2vpn_support(requests: list[dict[str, Any]]) -> None: + """Configure Cisco devices for L2VPN with MPLS traffic-engineering tunnels.""" + sources: dict[str, Any] = {"source": "10.60.125.44", "config": []} + destinations: dict[str, Any] = {"destination": "10.60.125.45", "config": []} - """ - sources={ - "source": "10.60.125.44", - "config":[] - } - destinations={ - "destination": "10.60.125.45", - "config":[] - } for request in requests: config_rules = safe_get(request, ["service_config", "config_rules"]) or [] if len(config_rules) < 3: @@ -149,50 +222,39 @@ def tfs_l2vpn_support(requests): continue endpoints = request.get("service_endpoint_ids", [{}, {}]) - config = { - "ni_name": temp_source.get("ni_name", ""), - "remote_router": temp_source.get("remote_router", ""), - "interface": endpoints[0].get("endpoint_uuid", {}).get("uuid", "").replace("0/0/0-", ""), - "vlan" : temp_source.get("vlan_id", 0), - "number" : temp_source.get("vlan_id", 0) % 10 + 1 - } - sources["config"].append(config) - - config = { - "ni_name": temp_destiny.get("ni_name", ""), - "remote_router": temp_destiny.get("remote_router", ""), - "interface": endpoints[1].get("endpoint_uuid", {}).get("uuid", "").replace("0/0/3-", ""), - "vlan" : temp_destiny.get("vlan_id", 0), - "number" : temp_destiny.get("vlan_id", 0) % 10 + 1 - } - destinations["config"].append(config) - - #cisco_source = cisco_connector(source_address, ni_name, remote_router, vlan, vlan % 10 + 1) + sources["config"].append( + { + "ni_name": temp_source.get("ni_name", ""), + "remote_router": temp_source.get("remote_router", ""), + "interface": endpoints[0].get("endpoint_uuid", {}).get("uuid", "").replace("0/0/0-", ""), + "vlan": temp_source.get("vlan_id", 0), + "number": temp_source.get("vlan_id", 0) % 10 + 1, + } + ) + + destinations["config"].append( + { + "ni_name": temp_destiny.get("ni_name", ""), + "remote_router": temp_destiny.get("remote_router", ""), + "interface": endpoints[1].get("endpoint_uuid", {}).get("uuid", "").replace("0/0/3-", ""), + "vlan": temp_destiny.get("vlan_id", 0), + "number": temp_destiny.get("vlan_id", 0) % 10 + 1, + } + ) + cisco_source = cisco_connector(sources["source"], sources["config"]) - commands = cisco_source.full_create_command_template() - cisco_source.execute_commands(commands) + cisco_source.execute_commands(cisco_source.full_create_command_template()) - #cisco_destiny = cisco_connector(destination_address, ni_name, remote_router, vlan, vlan % 10 + 1) cisco_destiny = cisco_connector(destinations["destination"], destinations["config"]) - commands = cisco_destiny.full_create_command_template() - cisco_destiny.execute_commands(commands) + cisco_destiny.execute_commands(cisco_destiny.full_create_command_template()) -def tfs_l2vpn_delete(): - """ - Delete L2VPN configurations from Cisco devices. - - This method removes L2VPN configurations from Cisco routers - Notes: - - Uses cisco_connector to generate and execute deletion commands - - Clears Network Interface (NI) settings - """ - # Delete Source Endpoint Configuration +def tfs_l2vpn_delete() -> None: + """Delete L2VPN configurations from Cisco devices.""" source_address = "10.60.125.44" cisco_source = cisco_connector(source_address) cisco_source.execute_commands(cisco_source.create_command_template_delete()) - # Delete Destination Endpoint Configuration destination_address = "10.60.125.45" cisco_destiny = cisco_connector(destination_address) - cisco_destiny.execute_commands(cisco_destiny.create_command_template_delete()) \ No newline at end of file + cisco_destiny.execute_commands(cisco_destiny.create_command_template_delete()) diff --git a/src/realizer/tfs/service_types/tfs_l3vpn.py b/src/realizer/tfs/service_types/tfs_l3vpn.py index 52b07ad..073876c 100644 --- a/src/realizer/tfs/service_types/tfs_l3vpn.py +++ b/src/realizer/tfs/service_types/tfs_l3vpn.py @@ -1,11 +1,11 @@ # Copyright 2022-2026 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. @@ -14,8 +14,13 @@ # This file includes original contributions from Telefonica Innovación Digital S.L. +"""TFS L3VPN slice intent translation and service template builder.""" + +from __future__ import annotations + import logging -import os +from pathlib import Path +from typing import Any from flask import current_app @@ -23,123 +28,210 @@ from src.config.constants import NBI_L3_PATH, TEMPLATES_PATH from src.utils.load_template import load_template from src.utils.safe_get import safe_get +logger = logging.getLogger(__name__) + + +def _apply_qos_requirements(access: dict[str, Any], requirements: list[dict[str, Any]]) -> None: + """Apply QoS requirements to an NBI L3VPN network access structure.""" + for constraint in requirements: + ctype = constraint.get("constraint_type", "") + cvalue = float(constraint.get("constraint_value", 0)) + + if ctype.startswith("one-way-bandwidth"): + unit = ctype.split("[")[-1].rstrip("]") + multiplier = {"bps": 1, "kbps": 1_000, "Mbps": 1_000_000, "Gbps": 1_000_000_000}.get(unit, 1) + value = int(cvalue * multiplier) + access["service"]["svc-input-bandwidth"] = value + access["service"]["svc-output-bandwidth"] = value + elif ctype == "one-way-delay-maximum[milliseconds]": + access["service"]["qos"]["qos-profile"]["classes"]["class"][0]["latency"]["latency-boundary"] = int(cvalue) + elif ctype == "availability[%]": + access["service"]["qos"]["qos-profile"]["classes"]["class"][0]["bandwidth"]["guaranteed-bw-percent"] = int( + cvalue + ) + elif ctype == "mtu[bytes]": + access["service"]["svc-mtu"] = int(cvalue) + + +def _build_webui_l3vpn( + ietf_intent: dict[str, Any], + slice_data: dict[str, Any] | None, + origin_router_id: str, + destination_router_id: str, +) -> dict[str, Any]: + """Build TFS WebUI L3VPN service descriptor.""" + origin_router_if = "0/0/0-GigabitEthernet0/0/0/0" + destination_router_if = "0/0/0-GigabitEthernet0/0/0/0" + + tfs_request = load_template(str(Path(TEMPLATES_PATH) / "L3-VPN_template_empty.json"))["services"][0] + slice_id = safe_get( + ietf_intent, + ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "id"], + ) + tfs_request["service_id"]["service_uuid"]["uuid"] = slice_id + + for endpoint in tfs_request["service_endpoint_ids"]: + is_first = endpoint is tfs_request["service_endpoint_ids"][0] + endpoint["device_id"]["device_uuid"]["uuid"] = origin_router_id if is_first else destination_router_id + endpoint["endpoint_uuid"]["uuid"] = origin_router_if if is_first else destination_router_if + + if slice_data: + for constraint in slice_data.get("requirements", []): + tfs_request["service_constraints"].append({"custom": constraint}) -def tfs_l3vpn(ietf_intent, response): - """ - Translate L3VPN (Layer 3 Virtual Private Network) intent into a TeraFlow service request. - - Similar to __tfs_l2vpn, but configured for Layer 3 VPN: - 1. Defines endpoint routers - 2. Loads service template - 3. Generates unique service UUID - 4. Configures service endpoints - 5. Adds QoS constraints - 6. Prepares configuration rules for network interfaces + for i, config_rule in enumerate(tfs_request["service_config"]["config_rules"][1:], start=1): + router_id = origin_router_id if i == 1 else destination_router_id + router_if = origin_router_if if i == 1 else destination_router_if + resource_value = config_rule["custom"]["resource_value"] + + sdp_index = i - 1 + vlan_value = safe_get( + ietf_intent, + [ + "ietf-network-slice-service:network-slice-services", + "slice-service", + 0, + "sdps", + "sdp", + sdp_index, + "service-match-criteria", + "match-criterion", + 0, + "match-type", + 0, + "vlan", + 0, + ], + ) + resource_value["router_id"] = destination_router_id if i == 1 else origin_router_id + resource_value["vlan_id"] = int(vlan_value) if vlan_value else 0 + resource_value["address_ip"] = destination_router_id if i == 1 else origin_router_id + resource_value["policy_AZ"] = "policyA" + resource_value["policy_ZA"] = "policyB" + resource_value["ni_name"] = f"ELAN{vlan_value!s}" + config_rule["custom"]["resource_key"] = f"/device[{router_id}]/endpoint[{router_if}]/settings" + + return tfs_request + + +def _build_nbi_l3vpn( + ietf_intent: dict[str, Any], + slice_data: dict[str, Any] | None, + origin_router_id: str, + destination_router_id: str, +) -> dict[str, Any]: + """Build TFS NBI L3VPN service descriptor.""" + origin_router_if = "0/0/0-GigabitEthernet0/0/0/0" + destination_router_if = "0/0/0-GigabitEthernet0/0/0/0" + + tfs_request = load_template(str(Path(TEMPLATES_PATH) / "ietfL3VPN_template_empty.json")) + tfs_request["path"] = NBI_L3_PATH + + full_id = safe_get( + ietf_intent, + ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "id"], + ) + tfs_request["ietf-l3vpn-svc:l3vpn-svc"]["vpn-services"]["vpn-service"][0]["vpn-id"] = full_id + + sites = tfs_request["ietf-l3vpn-svc:l3vpn-svc"]["sites"]["site"] + sdps = ( + safe_get( + ietf_intent, + ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "sdps", "sdp"], + ) + or [] + ) + + for i, site in enumerate(sites): + is_origin = i == 0 + sdp_index = 0 if is_origin else 1 + location = sdps[sdp_index].get("node-id") if len(sdps) > sdp_index else None + router_id = origin_router_id if is_origin else destination_router_id + router_if = origin_router_if if is_origin else destination_router_if + + site["site-id"] = f"site_{location}" + site["locations"]["location"][0]["location-id"] = location + site["devices"]["device"][0]["device-id"] = router_id + site["devices"]["device"][0]["location"] = location + + access = site["site-network-accesses"]["site-network-access"][0] + access["site-network-access-id"] = router_if + access["device-reference"] = router_id + access["vpn-attachment"]["vpn-id"] = full_id + + if slice_data: + _apply_qos_requirements(access, slice_data.get("requirements", [])) + + return tfs_request + + +def tfs_l3vpn( + ietf_intent: dict[str, Any], + response: list[dict[str, Any]] | None = None, +) -> dict[str, Any] | None: + """Translate L3VPN slice intent into a TeraFlow service request. Args: - ietf_intent (dict): IETF-formatted network slice intent. - response (dict): Response data containing slice information. + ietf_intent: IETF-formatted network slice intent dictionary. + response: Slice database records. Returns: - dict: A TeraFlow service request for L3VPN configuration. + Structured TFS L3VPN service request dictionary. """ - # Hardcoded router endpoints - # TODO (should be dynamically determined) - 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"]) + 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 L3VPN realization.") + logger.warning("Origin router ID not found in the intent. Skipping L3VPN realization.") return None - origin_router_if = '0/0/0-GigabitEthernet0/0/0/0' - destination_router_id = safe_get(ietf_intent, ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "sdps", "sdp", 1, "attachment-circuits", "attachment-circuit", 0, "sdp-peering", "peer-sap-id"]) + + destination_router_id = safe_get( + ietf_intent, + [ + "ietf-network-slice-service:network-slice-services", + "slice-service", + 0, + "sdps", + "sdp", + 1, + "attachment-circuits", + "attachment-circuit", + 0, + "sdp-peering", + "peer-sap-id", + ], + ) if not destination_router_id: - logging.warning("Destination router ID not found in the intent. Skipping L3VPN realization.") + logger.warning("Destination router ID not found in the intent. Skipping L3VPN realization.") + return None + + slice_id = safe_get( + ietf_intent, + ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "id"], + ) + slice_data = next((d for d in (response or []) if d.get("id") == slice_id), None) + + upload_type = current_app.config.get("UPLOAD_TYPE", "WEBUI") + if upload_type == "WEBUI": + tfs_request = _build_webui_l3vpn(ietf_intent, slice_data, origin_router_id, destination_router_id) + elif upload_type == "NBI": + tfs_request = _build_nbi_l3vpn(ietf_intent, slice_data, origin_router_id, destination_router_id) + else: + logger.warning("Unsupported upload type: %s", upload_type) return None - destination_router_if = '0/0/0-GigabitEthernet0/0/0/0' - id = ietf_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"] - slice = next((d for d in response if d.get("id") == id), None) - - if current_app.config["UPLOAD_TYPE"] == "WEBUI": - # Load L3VPN service template - tfs_request = load_template(os.path.join(TEMPLATES_PATH, "L3-VPN_template_empty.json"))["services"][0] - # Configure service UUID - tfs_request["service_id"]["service_uuid"]["uuid"] = ietf_intent['ietf-network-slice-service:network-slice-services']['slice-service'][0]["id"] - - # Configure service endpoints - for endpoint in tfs_request["service_endpoint_ids"]: - endpoint["device_id"]["device_uuid"]["uuid"] = origin_router_id if endpoint is tfs_request["service_endpoint_ids"][0] else destination_router_id - endpoint["endpoint_uuid"]["uuid"] = origin_router_if if endpoint is tfs_request["service_endpoint_ids"][0] else destination_router_if - - # Add service constraints - for constraint in slice.get("requirements", []): - tfs_request["service_constraints"].append({"custom": constraint}) - # Add configuration rules - for i, config_rule in enumerate(tfs_request["service_config"]["config_rules"][1:], start=1): - router_id = origin_router_id if i == 1 else destination_router_id - router_if = origin_router_if if i == 1 else destination_router_if - resource_value = config_rule["custom"]["resource_value"] - - sdp_index = i - 1 - vlan_value = ietf_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][sdp_index]["service-match-criteria"]["match-criterion"][0]["match-type"][0]["vlan"][0] - resource_value["router_id"] = destination_router_id if i == 1 else origin_router_id - resource_value["vlan_id"] = int(vlan_value) - resource_value["address_ip"] = destination_router_id if i == 1 else origin_router_id - resource_value["policy_AZ"] = "policyA" - resource_value["policy_ZA"] = "policyB" - resource_value["ni_name"] = f'ELAN{vlan_value!s:s}' - config_rule["custom"]["resource_key"] = f"/device[{router_id}]/endpoint[{router_if}]/settings" - - elif current_app.config["UPLOAD_TYPE"] == "NBI": - #self.path = NBI_L3_PATH - - # Load IETF L3VPN service template - tfs_request = load_template(os.path.join(TEMPLATES_PATH, "ietfL3VPN_template_empty.json")) - - # Add path to the request - tfs_request["path"] = NBI_L3_PATH - - # Generate service UUID - full_id = ietf_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"] - tfs_request["ietf-l3vpn-svc:l3vpn-svc"]["vpn-services"]["vpn-service"][0]["vpn-id"] = full_id - # Configure service endpoints - for i, site in enumerate(tfs_request["ietf-l3vpn-svc:l3vpn-svc"]["sites"]["site"]): - - # Determine if origin or destination - is_origin = (i == 0) - sdp_index = 0 if is_origin else 1 - location = ietf_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][sdp_index]["node-id"] - router_id = origin_router_id if is_origin else destination_router_id - router_if = origin_router_if if is_origin else destination_router_if - - # Assign common values - site["site-id"] = f"site_{location}" - site["locations"]["location"][0]["location-id"] = location - site["devices"]["device"][0]["device-id"] = router_id - site["devices"]["device"][0]["location"] = location - - access = site["site-network-accesses"]["site-network-access"][0] - access["site-network-access-id"] = router_if - access["device-reference"] = router_id - access["vpn-attachment"]["vpn-id"] = full_id - - # Aplicar restricciones QoS - for constraint in slice.get("requirements", []): - ctype = constraint["constraint_type"] - cvalue = float(constraint["constraint_value"]) - if constraint["constraint_type"].startswith("one-way-bandwidth"): - unit = constraint["constraint_type"].split("[")[-1].rstrip("]") - multiplier = {"bps": 1, "kbps": 1_000, "Mbps": 1_000_000, "Gbps": 1_000_000_000}.get(unit, 1) - value = int(cvalue * multiplier) - access["service"]["svc-input-bandwidth"] = value - access["service"]["svc-output-bandwidth"] = value - elif ctype == "one-way-delay-maximum[milliseconds]": - access["service"]["qos"]["qos-profile"]["classes"]["class"][0]["latency"]["latency-boundary"] = int(cvalue) - elif ctype == "availability[%]": - access["service"]["qos"]["qos-profile"]["classes"]["class"][0]["bandwidth"]["guaranteed-bw-percent"] = int(cvalue) - elif ctype == "mtu[bytes]": - access["service"]["svc-mtu"] = int(cvalue) - - - logging.info("L3VPN Intent realized") - #self.answer[self.subnet]["VLAN"] = vlan_value - return tfs_request \ No newline at end of file + logger.info("L3VPN Intent realized") + return tfs_request diff --git a/src/realizer/tfs/tfs_connect.py b/src/realizer/tfs/tfs_connect.py index 36a260c..d385a3f 100644 --- a/src/realizer/tfs/tfs_connect.py +++ b/src/realizer/tfs/tfs_connect.py @@ -1,11 +1,11 @@ # Copyright 2022-2026 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. @@ -14,6 +14,12 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. +"""Connector sending slice provisioning requests to TeraFlowSDN (TFS) controller.""" + +from __future__ import annotations + +from typing import Any + from flask import current_app from src.utils.send_response import send_response @@ -22,30 +28,34 @@ from .helpers.tfs_connector import tfs_connector from .service_types.tfs_l2vpn import tfs_l2vpn_support -def tfs_connect(requests, tfs_ip): - """ - Connect to TeraflowSDN (TFS) controller and upload services. - +def tfs_connect(requests: dict[str, Any], tfs_ip: str) -> Any: + """Connect to TeraFlowSDN (TFS) 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 - """ - if current_app.config["UPLOAD_TYPE"] == "WEBUI": - response = tfs_connector().webui_post(tfs_ip, requests) - elif current_app.config["UPLOAD_TYPE"] == "NBI": - for intent in requests["services"]: - # Send each separate NBI request - path = intent.pop("path") - response = tfs_connector().nbi_post(tfs_ip, intent, path) + requests: Dictionary containing services to upload. + tfs_ip: IP address of the TFS controller. + Returns: + Response from TFS controller or API error response. + """ + upload_type = current_app.config.get("UPLOAD_TYPE", "WEBUI") + connector = tfs_connector() + response = None + + if upload_type == "WEBUI": + response = connector.webui_post(tfs_ip, requests) + elif upload_type == "NBI": + for intent in requests.get("services", []): + path = intent.pop("path", "") + response = connector.nbi_post(tfs_ip, intent, path) if not response.ok: - return send_response(False, code=response.status_code, message=f"Teraflow upload failed. Response: {response.text}") - - # For deploying an L2VPN with path selection (not supported by Teraflow) - if current_app.config["TFS_L2VPN_SUPPORT"]: - tfs_l2vpn_support(requests["services"]) - - return response \ No newline at end of file + return send_response( + False, + code=response.status_code, + message=f"Teraflow upload failed. Response: {response.text}", + ) + + if current_app.config.get("TFS_L2VPN_SUPPORT"): + tfs_l2vpn_support(requests.get("services", [])) + + return response diff --git a/src/tests/conftest.py b/src/tests/conftest.py index fcc4203..46d2e7f 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -23,7 +23,7 @@ import pytest # ----------------------------------------------------------------------------- # Safely mock C-extensions (sysrepo, libyang) if not installed in current environment # ----------------------------------------------------------------------------- -if 'sysrepo' not in sys.modules: +if "sysrepo" not in sys.modules: try: import sysrepo except ImportError: @@ -33,45 +33,49 @@ if 'sysrepo' not in sys.modules: conn_mock = MagicMock() conn_mock.start_session.return_value = session_mock sysrepo_mock.SysrepoConnection.return_value = conn_mock - sys.modules['sysrepo'] = sysrepo_mock + sys.modules["sysrepo"] = sysrepo_mock -if 'libyang' not in sys.modules: +if "libyang" not in sys.modules: try: import libyang except ImportError: libyang_mock = MagicMock() - sys.modules['libyang'] = libyang_mock + sys.modules["libyang"] = libyang_mock # ----------------------------------------------------------------------------- # Shared Pytest Fixtures # ----------------------------------------------------------------------------- + @pytest.fixture(scope="session") def flask_app(): """Creates a minimal Flask app for testing with default configuration.""" from app import create_app + app = create_app() app.config["DUMMY_MODE"] = True - app.config.update({ - "TESTING": True, - "SERVER_NAME": "localhost", - "API_USERNAME": "admin", - "API_PASSWORD": "password", - "NRP_ENABLED": False, - "PLANNER_ENABLED": False, - "PCE_EXTERNAL": False, - "DUMMY_MODE": True, - "DUMP_TEMPLATES": False, - "TFS_L2VPN_SUPPORT": False, - "WEBUI_DEPLOY": False, - "UPLOAD_TYPE": "WEBUI", - "PLANNER_TYPE": "ENERGY", - "HRAT_IP": "10.0.0.1", - "OPTICAL_PLANNER_IP": "10.0.0.1", - "RESTCONF_IP": "10.0.0.1", - "TFS_IP": "10.0.0.1", - }) + app.config.update( + { + "TESTING": True, + "SERVER_NAME": "localhost", + "API_USERNAME": "admin", + "API_PASSWORD": "password", + "NRP_ENABLED": False, + "PLANNER_ENABLED": False, + "PCE_EXTERNAL": False, + "DUMMY_MODE": True, + "DUMP_TEMPLATES": False, + "TFS_L2VPN_SUPPORT": False, + "WEBUI_DEPLOY": False, + "UPLOAD_TYPE": "WEBUI", + "PLANNER_TYPE": "ENERGY", + "HRAT_IP": "10.0.0.1", + "OPTICAL_PLANNER_IP": "10.0.0.1", + "RESTCONF_IP": "10.0.0.1", + "TFS_IP": "10.0.0.1", + } + ) return app @@ -86,11 +90,8 @@ def auth_headers(flask_app): """Generates Basic Auth headers matching app config.""" username = flask_app.config["API_USERNAME"] password = flask_app.config["API_PASSWORD"] - token = base64.b64encode(f"{username}:{password}".encode()).decode('utf-8') - return { - "Authorization": f"Basic {token}", - "Content-Type": "application/json" - } + token = base64.b64encode(f"{username}:{password}".encode()).decode("utf-8") + return {"Authorization": f"Basic {token}", "Content-Type": "application/json"} @pytest.fixture @@ -126,13 +127,9 @@ def sample_ietf_intent(): "id": "qos1", "slo-policy": { "metric-bound": [ - { - "metric-type": "one-way-bandwidth", - "metric-unit": "kbps", - "bound": 100000 - } + {"metric-type": "one-way-bandwidth", "metric-unit": "kbps", "bound": 100000} ] - } + }, } ] }, @@ -147,56 +144,22 @@ def sample_ietf_intent(): "node-id": "A", "sdp-ip-address": "10.0.0.1", "service-match-criteria": { - "match-criterion": [ - { - "match-type": [ - { - "type": "vlan", - "vlan": [100] - } - ] - } - ] + "match-criterion": [{"match-type": [{"type": "vlan", "vlan": [100]}]}] }, - "attachment-circuits": { - "attachment-circuit": [ - { - "sdp-peering": { - "peer-sap-id": "R1" - } - } - ] - } + "attachment-circuits": {"attachment-circuit": [{"sdp-peering": {"peer-sap-id": "R1"}}]}, }, { "id": "sdp-2", "node-id": "B", "sdp-ip-address": "10.0.0.2", "service-match-criteria": { - "match-criterion": [ - { - "match-type": [ - { - "type": "vlan", - "vlan": [100] - } - ] - } - ] + "match-criterion": [{"match-type": [{"type": "vlan", "vlan": [100]}]}] }, - "attachment-circuits": { - "attachment-circuit": [ - { - "sdp-peering": { - "peer-sap-id": "R2" - } - } - ] - } - } + "attachment-circuits": {"attachment-circuit": [{"sdp-peering": {"peer-sap-id": "R2"}}]}, + }, ] - } + }, } - ] + ], } } diff --git a/src/tests/test_api.py b/src/tests/test_api.py index ee7025d..790b803 100644 --- a/src/tests/test_api.py +++ b/src/tests/test_api.py @@ -30,26 +30,29 @@ from src.main import NSController # Load environment variables load_dotenv() + @pytest.fixture(scope="session") def flask_app(): """Creates a minimal Flask app for tests.""" app = Flask(__name__) - app.config.update({ - "TESTING": True, - "SERVER_NAME": "localhost", - 'NRP_ENABLED': os.getenv('NRP_ENABLED', 'False').lower() == 'true', - 'PLANNER_ENABLED': os.getenv('PLANNER_ENABLED', 'False').lower() == 'true', - 'PCE_EXTERNAL': os.getenv('PCE_EXTERNAL', 'False').lower() == 'true', - 'DUMMY_MODE': os.getenv('DUMMY_MODE', 'True').lower() == 'true', - 'DUMP_TEMPLATES': os.getenv('DUMP_TEMPLATES', 'False').lower() == 'true', - 'TFS_L2VPN_SUPPORT': os.getenv('TFS_L2VPN_SUPPORT', 'False').lower() == 'true', - 'WEBUI_DEPLOY': os.getenv('WEBUI_DEPLOY', 'True').lower() == 'true', - 'UPLOAD_TYPE': os.getenv('UPLOAD_TYPE', 'WEBUI'), - 'PLANNER_TYPE': os.getenv('PLANNER_TYPE', 'ENERGY'), - 'HRAT_IP' : os.getenv('HRAT_IP', '10.0.0.1'), - 'OPTICAL_PLANNER_IP' : os.getenv('OPTICAL_PLANNER_IP', '10.0.0.1'), - 'TFS_IP' : os.getenv('TFS_IP', '10.0.0.1') - }) + app.config.update( + { + "TESTING": True, + "SERVER_NAME": "localhost", + "NRP_ENABLED": os.getenv("NRP_ENABLED", "False").lower() == "true", + "PLANNER_ENABLED": os.getenv("PLANNER_ENABLED", "False").lower() == "true", + "PCE_EXTERNAL": os.getenv("PCE_EXTERNAL", "False").lower() == "true", + "DUMMY_MODE": True, + "DUMP_TEMPLATES": os.getenv("DUMP_TEMPLATES", "False").lower() == "true", + "TFS_L2VPN_SUPPORT": os.getenv("TFS_L2VPN_SUPPORT", "False").lower() == "true", + "WEBUI_DEPLOY": os.getenv("WEBUI_DEPLOY", "True").lower() == "true", + "UPLOAD_TYPE": os.getenv("UPLOAD_TYPE", "WEBUI"), + "PLANNER_TYPE": os.getenv("PLANNER_TYPE", "ENERGY"), + "HRAT_IP": os.getenv("HRAT_IP", "10.0.0.1"), + "OPTICAL_PLANNER_IP": os.getenv("OPTICAL_PLANNER_IP", "10.0.0.1"), + "TFS_IP": os.getenv("TFS_IP", "10.0.0.1"), + } + ) return app @@ -59,11 +62,12 @@ def push_flask_context(flask_app): with flask_app.app_context(): yield + @pytest.fixture def temp_db(tmp_path): """Fixture to create and cleanup a test database using SQLite instead of JSON.""" test_db_name = str(tmp_path / "test_slice.db") - + # Create database with proper schema conn = sqlite3.connect(test_db_name) cursor = conn.cursor() @@ -76,9 +80,9 @@ def temp_db(tmp_path): """) conn.commit() conn.close() - + yield test_db_name - + # Cleanup - properly close connections and remove file try: time.sleep(0.1) @@ -97,15 +101,15 @@ def temp_db(tmp_path): def env_variables(): """Fixture to load and provide environment variables.""" env_vars = { - 'NRP_ENABLED': os.getenv('NRP_ENABLED', 'False').lower() == 'true', - 'PLANNER_ENABLED': os.getenv('PLANNER_ENABLED', 'False').lower() == 'true', - 'PCE_EXTERNAL': os.getenv('PCE_EXTERNAL', 'False').lower() == 'true', - 'DUMMY_MODE': os.getenv('DUMMY_MODE', 'True').lower() == 'true', - 'DUMP_TEMPLATES': os.getenv('DUMP_TEMPLATES', 'False').lower() == 'true', - 'TFS_L2VPN_SUPPORT': os.getenv('TFS_L2VPN_SUPPORT', 'False').lower() == 'true', - 'WEBUI_DEPLOY': os.getenv('WEBUI_DEPLOY', 'True').lower() == 'true', - 'UPLOAD_TYPE': os.getenv('UPLOAD_TYPE', 'WEBUI'), - 'PLANNER_TYPE': os.getenv('PLANNER_TYPE', 'standard'), + "NRP_ENABLED": os.getenv("NRP_ENABLED", "False").lower() == "true", + "PLANNER_ENABLED": os.getenv("PLANNER_ENABLED", "False").lower() == "true", + "PCE_EXTERNAL": os.getenv("PCE_EXTERNAL", "False").lower() == "true", + "DUMMY_MODE": os.getenv("DUMMY_MODE", "True").lower() == "true", + "DUMP_TEMPLATES": os.getenv("DUMP_TEMPLATES", "False").lower() == "true", + "TFS_L2VPN_SUPPORT": os.getenv("TFS_L2VPN_SUPPORT", "False").lower() == "true", + "WEBUI_DEPLOY": os.getenv("WEBUI_DEPLOY", "True").lower() == "true", + "UPLOAD_TYPE": os.getenv("UPLOAD_TYPE", "WEBUI"), + "PLANNER_TYPE": os.getenv("PLANNER_TYPE", "standard"), } return env_vars @@ -113,7 +117,7 @@ def env_variables(): @pytest.fixture def controller_with_mocked_db(temp_db): """Creates an NSController with a mocked database.""" - with patch('src.database.db.DB_NAME', temp_db): + with patch("src.database.db.DB_NAME", temp_db): yield NSController(controller_type="TFS") @@ -127,14 +131,8 @@ def ietf_intent(): { "id": "qos1", "slo-policy": { - "metric-bound": [ - { - "metric-type": "one-way-bandwidth", - "metric-unit": "kbps", - "bound": 1000 - } - ] - } + "metric-bound": [{"metric-type": "one-way-bandwidth", "metric-unit": "kbps", "bound": 1000}] + }, } ] }, @@ -147,51 +145,17 @@ def ietf_intent(): "sdp-ip-address": "10.0.0.1", "node-id": "node1", "service-match-criteria": { - "match-criterion": [ - { - "match-type": [ - { - "type": "vlan", - "vlan": [100] - } - ] - } - ] - }, - "attachment-circuits": { - "attachment-circuit": [ - { - "sdp-peering": { - "peer-sap-id": "R1" - } - } - ] + "match-criterion": [{"match-type": [{"type": "vlan", "vlan": [100]}]}] }, + "attachment-circuits": {"attachment-circuit": [{"sdp-peering": {"peer-sap-id": "R1"}}]}, }, { "sdp-ip-address": "10.0.0.2", "node-id": "node2", "service-match-criteria": { - "match-criterion": [ - { - "match-type": [ - { - "type": "vlan", - "vlan": [100] - } - ] - } - ] - }, - "attachment-circuits": { - "attachment-circuit": [ - { - "sdp-peering": { - "peer-sap-id": "R2" - } - } - ] + "match-criterion": [{"match-type": [{"type": "vlan", "vlan": [100]}]}] }, + "attachment-circuits": {"attachment-circuit": [{"sdp-peering": {"peer-sap-id": "R2"}}]}, }, ] }, @@ -204,78 +168,68 @@ def ietf_intent(): class TestBasicApiOperations: """Tests for basic API operations.""" - + def test_get_flows_empty(self, controller_with_mocked_db): """Should return an error when there are no slices.""" result, code = Api(controller_with_mocked_db).get_flows() assert code == 404 assert result["success"] is False assert result["data"] is None - + def test_add_flow_success(self, controller_with_mocked_db, ietf_intent): """Should successfully add a flow.""" - with patch('src.database.db.save_data') as mock_save: + with patch("src.database.db.save_data") as mock_save: result, code = Api(controller_with_mocked_db).add_flow(ietf_intent) assert code == 201 assert result["success"] is True assert "slices" in result["data"] - + def test_add_and_get_flow(self, controller_with_mocked_db, ietf_intent): """Should add a flow and then retrieve it.""" - with patch('src.database.db.save_data') as mock_save, \ - patch('src.database.db.get_all_data') as mock_get_all: - + with patch("src.database.db.save_data") as mock_save, patch("src.database.db.get_all_data") as mock_get_all: Api(controller_with_mocked_db).add_flow(ietf_intent) - - mock_get_all.return_value = [ - { - "slice_id": "slice-test-1", - "intent": ietf_intent, - "controller": "TFS" - } - ] - + + mock_get_all.return_value = [{"slice_id": "slice-test-1", "intent": ietf_intent, "controller": "TFS"}] + flows, code = Api(controller_with_mocked_db).get_flows() assert code == 200 assert any(s["slice_id"] == "slice-test-1" for s in flows) - + def test_modify_flow_success(self, controller_with_mocked_db, ietf_intent): """Should successfully modify an existing flow.""" - with patch('src.database.db.update_data') as mock_update: + with patch("src.database.db.update_data") as mock_update: Api(controller_with_mocked_db).add_flow(ietf_intent) new_intent = ietf_intent.copy() - new_intent["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["id"] = "qos2" - + new_intent["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0][ + "id" + ] = "qos2" + result, code = Api(controller_with_mocked_db).modify_flow("slice-test-1", new_intent) print(result) assert code == 200 assert result["success"] is True - + def test_delete_specific_flow_success(self, controller_with_mocked_db, ietf_intent): """Should delete a specific flow.""" - with patch('src.database.db.delete_data') as mock_delete: + with patch("src.database.db.delete_data") as mock_delete: Api(controller_with_mocked_db).add_flow(ietf_intent) result, code = Api(controller_with_mocked_db).delete_flows("slice-test-1") assert code == 204 assert result == {} - + def test_delete_all_flows_success(self, controller_with_mocked_db): """Should delete all flows.""" - with patch('src.database.db.delete_all_data') as mock_delete_all: + with patch("src.database.db.delete_all_data") as mock_delete_all: result, code = Api(controller_with_mocked_db).delete_flows() assert code == 204 assert result == {} - + def test_get_specific_flow(self, controller_with_mocked_db, ietf_intent): """Should retrieve a specific flow.""" - with patch('src.database.db.get_data') as mock_get: + with patch("src.database.db.get_data") as mock_get: Api(controller_with_mocked_db).add_flow(ietf_intent) - mock_get.return_value = { - "slice_id": "slice-test-1", - "intent": ietf_intent, - "controller": "TFS" - } - + mock_get.return_value = {"slice_id": "slice-test-1", "intent": ietf_intent, "controller": "TFS"} + result, code = Api(controller_with_mocked_db).get_flows("slice-test-1") assert code == 200 assert result["slice_id"] == "slice-test-1" @@ -283,42 +237,42 @@ class TestBasicApiOperations: class TestErrorHandling: """Tests for error handling.""" - + def test_add_flow_with_empty_intent(self, controller_with_mocked_db): """Should fail if an empty intent is provided.""" result, code = Api(controller_with_mocked_db).add_flow({}) assert code in (400, 404, 500) assert result["success"] is False - + def test_add_flow_with_none(self, controller_with_mocked_db): """Should fail if None is provided as intent.""" result, code = Api(controller_with_mocked_db).add_flow(None) assert code in (400, 500) assert result["success"] is False - + def test_get_nonexistent_slice(self, controller_with_mocked_db): """Should return 404 if a nonexistent slice is requested.""" - with patch('src.database.db.get_data') as mock_get: + with patch("src.database.db.get_data") as mock_get: mock_get.side_effect = ValueError("No slice found") - + result, code = Api(controller_with_mocked_db).get_flows("slice-does-not-exist") assert code == 404 assert result["success"] is False - + def test_modify_nonexistent_flow(self, controller_with_mocked_db, ietf_intent): """Should fail if attempting to modify a nonexistent flow.""" - with patch('src.database.db.update_data') as mock_update: + with patch("src.database.db.update_data") as mock_update: mock_update.side_effect = ValueError("No slice found") - + result, code = Api(controller_with_mocked_db).modify_flow("nonexistent", ietf_intent) assert code == 404 assert result["success"] is False - + def test_delete_nonexistent_flow(self, controller_with_mocked_db): """Should fail if attempting to delete a nonexistent flow.""" - with patch('src.database.db.delete_data') as mock_delete: + with patch("src.database.db.delete_data") as mock_delete: mock_delete.side_effect = ValueError("No slice found") - + result, code = Api(controller_with_mocked_db).delete_flows("nonexistent") assert code == 404 assert result["success"] is False @@ -388,7 +342,7 @@ class TestAlertOperations: "tapi-notification:notification": { "uuid": "alert-100", "notification-type": "ALARM", - "additional-info": {"service-id": "slice-1"} + "additional-info": {"service-id": "slice-1"}, } } ] @@ -415,45 +369,50 @@ class TestAlertOperations: def test_receive_alert_endpoint_swap(self, controller_with_mocked_db, sample_ietf_intent, temp_sqlite_db): """Test receive_alert when swapping receiver endpoints.""" api = Api(controller_with_mocked_db) - + # Setup intent with P2MP sender and receivers intent_p2mp = { "ietf-network-slice-service:network-slice-services": { - "slice-service": [{ - "id": "slice-p2mp-1", - "sdps": { - "sdp": [ - {"id": "sdp-sender"}, - {"id": "sdp-rec-1"}, - {"id": "sdp-rec-2"}, - {"id": "sdp-alt-1"} - ] - }, - "connection-groups": { - "connection-group": [{ - "id": "cg-1", - "connectivity-construct": [{ - "id": "cc-1", - "p2mp-sender-sdp": "sdp-sender", - "p2mp-receiver-sdp": ["sdp-rec-1", "sdp-rec-2"] - }] - }] + "slice-service": [ + { + "id": "slice-p2mp-1", + "sdps": { + "sdp": [{"id": "sdp-sender"}, {"id": "sdp-rec-1"}, {"id": "sdp-rec-2"}, {"id": "sdp-alt-1"}] + }, + "connection-groups": { + "connection-group": [ + { + "id": "cg-1", + "connectivity-construct": [ + { + "id": "cc-1", + "p2mp-sender-sdp": "sdp-sender", + "p2mp-receiver-sdp": ["sdp-rec-1", "sdp-rec-2"], + } + ], + } + ] + }, } - }] + ] } } - - with patch("src.database.db.get_data") as mock_get_db, \ - patch.object(api.slice_service, "nsc", return_value=True): + + with ( + patch("src.database.db.get_data") as mock_get_db, + patch.object(api.slice_service, "nsc", return_value=True), + ): mock_get_db.return_value = {"slice_id": "slice-p2mp-1", "intent": intent_p2mp} alert_payload = { - "tapi-notification:notification-context": [{ - "tapi-notification:notification": { - "uuid": "alert-swap-1", - "notification-type": "ALARM", - "additional-info": {"service-id": "slice-p2mp-1"} + "tapi-notification:notification-context": [ + { + "tapi-notification:notification": { + "uuid": "alert-swap-1", + "notification-type": "ALARM", + "additional-info": {"service-id": "slice-p2mp-1"}, + } } - }] + ] } res, code = api.receive_alert(alert_payload) assert code in [200, 201] @@ -465,9 +424,11 @@ class TestRestconfDatastoreOperations: def test_slo_sle_template_crud(self, controller_with_mocked_db): api = Api(controller_with_mocked_db) tmpl = {"id": "tmpl-test-1", "slo-policy": {}} - - with patch("src.api.main.get_data_store", return_value=None), \ - patch("src.api.main.create_data_store", return_value=True): + + with ( + patch("src.api.main.get_data_store", return_value=None), + patch("src.api.main.create_data_store", return_value=True), + ): res_add, code_add = api.add_slo_sle_template(tmpl.copy()) assert code_add == 201 @@ -479,8 +440,10 @@ class TestRestconfDatastoreOperations: res_get_err, code_get_err = api.get_slo_sle_templates("nonexistent-tmpl") assert code_get_err == 404 - with patch("src.api.main.get_data_store", return_value={"tmpl-test-1": tmpl}), \ - patch("src.api.main.delete_data_store", return_value=True): + with ( + patch("src.api.main.get_data_store", return_value={"tmpl-test-1": tmpl}), + patch("src.api.main.delete_data_store", return_value=True), + ): res_del, code_del = api.delete_slo_sle_templates("tmpl-test-1") assert code_del == 204 @@ -488,9 +451,11 @@ class TestRestconfDatastoreOperations: api = Api(controller_with_mocked_db) slice_intent = {"id": "slice-svc-1", "service-slo-sle-policy": {"bound": 10}} - with patch("src.api.main.get_data_store", return_value=None), \ - patch.object(api.slice_service, "nsc", return_value=True), \ - patch("src.api.main.create_data_store", return_value=True): + with ( + patch("src.api.main.get_data_store", return_value=None), + patch.object(api.slice_service, "nsc", return_value=True), + patch("src.api.main.create_data_store", return_value=True), + ): res_add, code_add = api.add_slice_service(slice_intent.copy()) assert code_add == 201 @@ -498,8 +463,10 @@ class TestRestconfDatastoreOperations: res_get, code_get = api.get_slice_services("slice-svc-1") assert code_get == 200 - with patch("src.api.main.get_data_store", return_value={"slice-svc-1": slice_intent}), \ - patch("src.api.main.delete_data_store", return_value=True): + with ( + patch("src.api.main.get_data_store", return_value={"slice-svc-1": slice_intent}), + patch("src.api.main.delete_data_store", return_value=True), + ): res_del, code_del = api.delete_slice_services("slice-svc-1") assert code_del == 204 @@ -507,8 +474,10 @@ class TestRestconfDatastoreOperations: api = Api(controller_with_mocked_db) sdp_data = {"id": "sdp-10", "node-id": "N1"} - with patch("src.api.main.get_data_store", return_value=None), \ - patch("src.api.main.create_data_store", return_value=True): + with ( + patch("src.api.main.get_data_store", return_value=None), + patch("src.api.main.create_data_store", return_value=True), + ): res_add, code_add = api.add_sdp("slice-1", sdp_data.copy()) assert code_add == 201 @@ -516,8 +485,10 @@ class TestRestconfDatastoreOperations: res_get, code_get = api.get_sdps("slice-1", "sdp-10") assert code_get == 200 - with patch("src.api.main.get_data_store", return_value={"sdp-10": sdp_data}), \ - patch("src.api.main.delete_data_store", return_value=True): + with ( + patch("src.api.main.get_data_store", return_value={"sdp-10": sdp_data}), + patch("src.api.main.delete_data_store", return_value=True), + ): res_del, code_del = api.delete_sdps("slice-1", "sdp-10") assert code_del == 204 @@ -558,14 +529,18 @@ class TestApiFullCoverage: res_404, code_404 = api.update_network_slice_service(sample_ietf_intent) assert code_404 == 404 - with patch("src.api.main.get_data_store", return_value={"existing": 1}), \ - patch.object(api.slice_service, "nsc", return_value=None): + with ( + patch("src.api.main.get_data_store", return_value={"existing": 1}), + patch.object(api.slice_service, "nsc", return_value=None), + ): res_500, code_500 = api.update_network_slice_service(sample_ietf_intent) assert code_500 == 500 - with patch("src.api.main.get_data_store", return_value={"existing": 1}), \ - patch.object(api.slice_service, "nsc", return_value=True), \ - patch("src.api.main.update_data_store", return_value=True): + with ( + patch("src.api.main.get_data_store", return_value={"existing": 1}), + patch.object(api.slice_service, "nsc", return_value=True), + patch("src.api.main.update_data_store", return_value=True), + ): res_200, code_200 = api.update_network_slice_service(sample_ietf_intent) assert code_200 == 200 @@ -584,26 +559,15 @@ class TestApiFullCoverage: assert code_400 == 400 # Success updating referencing slice - slices_data = { - "network-slice-services": { - "slice-service": [{ - "id": "slice-1", - "slo-sle-template": "tmpl-1" - }] - } - } + slices_data = {"network-slice-services": {"slice-service": [{"id": "slice-1", "slo-sle-template": "tmpl-1"}]}} existing_template = { - "network-slice-services": { - "slo-sle-templates": { - "slo-sle-template": { - "tmpl-1": {"id": "tmpl-1"} - } - } - } + "network-slice-services": {"slo-sle-templates": {"slo-sle-template": {"tmpl-1": {"id": "tmpl-1"}}}} } - with patch("src.api.main.get_data_store", side_effect=[existing_template, slices_data]), \ - patch.object(api.slice_service, "nsc", return_value=True), \ - patch("src.api.main.update_data_store", return_value=True): + with ( + patch("src.api.main.get_data_store", side_effect=[existing_template, slices_data]), + patch.object(api.slice_service, "nsc", return_value=True), + patch("src.api.main.update_data_store", return_value=True), + ): res_200, code_200 = api.update_slo_sle_template("tmpl-1", tmpl.copy()) assert code_200 == 200 @@ -628,9 +592,11 @@ class TestApiFullCoverage: assert code_tmpl_404 == 404 # Success update - with patch("src.api.main.get_data_store", return_value={"slice-1": intent}), \ - patch.object(api.slice_service, "nsc", return_value=True), \ - patch("src.api.main.update_data_store", return_value=True): + with ( + patch("src.api.main.get_data_store", return_value={"slice-1": intent}), + patch.object(api.slice_service, "nsc", return_value=True), + patch("src.api.main.update_data_store", return_value=True), + ): res_200, code_200 = api.update_slice_service("slice-1", intent.copy()) assert code_200 == 200 @@ -654,8 +620,10 @@ class TestApiFullCoverage: assert code_400 == 400 # Success - with patch("src.api.main.get_data_store", return_value={"found": 1}), \ - patch("src.api.main.update_data_store", return_value=True): + with ( + patch("src.api.main.get_data_store", return_value={"found": 1}), + patch("src.api.main.update_data_store", return_value=True), + ): res_200, code_200 = api.update_sdp("slice-1", "sdp-1", sdp.copy()) assert code_200 == 200 @@ -667,19 +635,25 @@ class TestApiFullCoverage: slice_data = { "network-slice-services": { - "slice-service": [{ - "id": "slice-1", - "service-tags": {"tag-type": {"ietf-network-slice-service:service": {"tag-type-value": ["L2"]}}} - }] + "slice-service": [ + { + "id": "slice-1", + "service-tags": { + "tag-type": {"ietf-network-slice-service:service": {"tag-type-value": ["L2"]}} + }, + } + ] } } - with flask_app.app_context(), \ - patch("src.api.main.get_data_store", return_value=slice_data), \ - patch("src.api.main.get_data_by_slice_id", return_value=[{"service_id": "svc-1"}]), \ - patch("src.api.main.tfs_connector"), \ - patch("src.api.main.delete_by_slice_id"), \ - patch.object(api.slice_service, "tfs_l2vpn_delete", create=True), \ - patch("src.api.main.delete_data_store", return_value=True): + with ( + flask_app.app_context(), + patch("src.api.main.get_data_store", return_value=slice_data), + patch("src.api.main.get_data_by_slice_id", return_value=[{"service_id": "svc-1"}]), + patch("src.api.main.tfs_connector"), + patch("src.api.main.delete_by_slice_id"), + patch.object(api.slice_service, "tfs_l2vpn_delete", create=True), + patch("src.api.main.delete_data_store", return_value=True), + ): res_del, code_del = api.delete_network_slice_services() assert code_del == 204 @@ -694,18 +668,22 @@ class TestApiFullCoverage: "slice-service": { "slice-1": { "id": "slice-1", - "service-tags": {"tag-type": {"ietf-network-slice-service:service": {"tag-type-value": ["L2"]}}} + "service-tags": { + "tag-type": {"ietf-network-slice-service:service": {"tag-type-value": ["L2"]}} + }, } } } } - with flask_app.app_context(), \ - patch("src.api.main.get_data_store", return_value=slice_single), \ - patch("src.api.main.get_data_by_slice_id", return_value=[{"service_id": "svc-1"}]), \ - patch("src.api.main.tfs_connector"), \ - patch("src.api.main.delete_by_slice_id"), \ - patch("src.api.main.tfs_l2vpn_delete"), \ - patch("src.api.main.delete_data_store", return_value=True): + with ( + flask_app.app_context(), + patch("src.api.main.get_data_store", return_value=slice_single), + patch("src.api.main.get_data_by_slice_id", return_value=[{"service_id": "svc-1"}]), + patch("src.api.main.tfs_connector"), + patch("src.api.main.delete_by_slice_id"), + patch("src.api.main.tfs_l2vpn_delete"), + patch("src.api.main.delete_data_store", return_value=True), + ): res_del, code_del = api.delete_slice_services("slice-1") assert code_del == 204 @@ -723,8 +701,10 @@ class TestApiFullCoverage: assert code_sdp_404 == 404 # Delete all SDPs success - with patch("src.api.main.get_data_store", return_value={"slice": 1}), \ - patch("src.api.main.delete_data_store", return_value=True): + with ( + patch("src.api.main.get_data_store", return_value={"slice": 1}), + patch("src.api.main.delete_data_store", return_value=True), + ): res_all, code_all = api.delete_sdps("slice-1") assert code_all == 204 @@ -757,13 +737,11 @@ class TestApiFullCoverage: def test_nsc_full_pipeline(self, controller_with_mocked_db, sample_ietf_intent, flask_app): api = Api(controller_with_mocked_db) - with flask_app.app_context(), \ - patch.object(api.slice_service, "nsc", return_value={"status": "ok"}): + with flask_app.app_context(), patch.object(api.slice_service, "nsc", return_value={"status": "ok"}): res, code = api.add_flow(sample_ietf_intent) assert code == 201 - with flask_app.app_context(), \ - patch.object(api.slice_service, "nsc", side_effect=Exception("NSC failure")): + with flask_app.app_context(), patch.object(api.slice_service, "nsc", side_effect=Exception("NSC failure")): res_err, code_err = api.add_flow(sample_ietf_intent) assert code_err == 500 @@ -785,13 +763,15 @@ class TestApiFullCoverage: res_none, code_none = api.get_flows() assert code_none == 404 - with flask_app.app_context(), \ - patch("src.api.main.get_data", return_value=slices[0]), \ - patch("src.api.main.tfs_connector"), \ - patch("src.api.main.delete_data"), \ - patch("src.api.main.get_all_data", return_value=slices), \ - patch("src.api.main.delete_all_data"), \ - patch("src.api.main.tfs_l2vpn_delete"): + with ( + flask_app.app_context(), + patch("src.api.main.get_data", return_value=slices[0]), + patch("src.api.main.tfs_connector"), + patch("src.api.main.delete_data"), + patch("src.api.main.get_all_data", return_value=slices), + patch("src.api.main.delete_all_data"), + patch("src.api.main.tfs_l2vpn_delete"), + ): res_del_single, code_del_single = api.delete_flows("s-1") assert code_del_single == 204 @@ -804,11 +784,7 @@ class TestApiExtendedCoverage: def test_receive_alert_missing_uuid(self, controller_with_mocked_db): api = Api(controller_with_mocked_db) - alert_no_uuid = { - "tapi-notification:notification-context": [ - {"tapi-notification:notification": {}} - ] - } + alert_no_uuid = {"tapi-notification:notification-context": [{"tapi-notification:notification": {}}]} res, code = api.receive_alert(alert_no_uuid) assert code == 400 @@ -819,7 +795,7 @@ class TestApiExtendedCoverage: { "tapi-notification:notification": { "uuid": "alert-uuid-1", - "additional-info": {"service-id": "service-p2mp"} + "additional-info": {"service-id": "service-p2mp"}, } } ] @@ -830,30 +806,27 @@ class TestApiExtendedCoverage: "slice-service": [ { "id": "service-p2mp", - "sdps": { - "sdp": [{"id": "sdp-1"}, {"id": "sdp-2"}, {"id": "sdp-3"}, {"id": "sdp-4"}] - }, + "sdps": {"sdp": [{"id": "sdp-1"}, {"id": "sdp-2"}, {"id": "sdp-3"}, {"id": "sdp-4"}]}, "connection-groups": { "connection-group": [ { "connectivity-construct": [ - { - "p2mp-sender-sdp": "sdp-1", - "p2mp-receiver-sdp": ["sdp-2", "sdp-3"] - } + {"p2mp-sender-sdp": "sdp-1", "p2mp-receiver-sdp": ["sdp-2", "sdp-3"]} ] } ] - } + }, } ] } } - with patch("src.api.main.alert_db.save_alert"), \ - patch("src.database.db.get_slice_id_by_subscription", return_value="slice-p2mp"), \ - patch("src.database.db.get_data", return_value={"slice_id": "slice-p2mp", "intent": slice_intent}), \ - patch.object(api.slice_service, "nsc", return_value=True): + with ( + patch("src.api.main.alert_db.save_alert"), + patch("src.database.db.get_slice_id_by_subscription", return_value="slice-p2mp"), + patch("src.database.db.get_data", return_value={"slice_id": "slice-p2mp", "intent": slice_intent}), + patch.object(api.slice_service, "nsc", return_value=True), + ): res, code = api.receive_alert(alert_data) assert code == 201 @@ -861,28 +834,25 @@ class TestApiExtendedCoverage: api = Api(controller_with_mocked_db) alert_data = { "tapi-notification:notification-context": [ - { - "tapi-notification:notification": { - "uuid": "alert-uuid-fallback" - } - } + {"tapi-notification:notification": {"uuid": "alert-uuid-fallback"}} ] } - fake_intent = { - "ietf-network-slice-service:network-slice-services": { - "slice-service": [] - } - } + fake_intent = {"ietf-network-slice-service:network-slice-services": {"slice-service": []}} fallback_file = tmp_path / "intent.json" fallback_file.write_text(json.dumps(fake_intent)) monkeypatch.setattr("os.path.exists", lambda p: True if p == "/home/llmserver/tfs-nsc/intent.json" else False) - with patch("src.api.main.alert_db.save_alert"), \ - patch("src.database.db.get_slice_id_by_subscription", return_value=None), \ - patch("src.database.db.get_all_data", return_value=[]), \ - patch("builtins.open", MagicMock(return_value=MagicMock(__enter__=lambda s: MagicMock(read=lambda: json.dumps(fake_intent))))): + with ( + patch("src.api.main.alert_db.save_alert"), + patch("src.database.db.get_slice_id_by_subscription", return_value=None), + patch("src.database.db.get_all_data", return_value=[]), + patch( + "builtins.open", + MagicMock(return_value=MagicMock(__enter__=lambda s: MagicMock(read=lambda: json.dumps(fake_intent)))), + ), + ): res, code = api.receive_alert(alert_data) assert code == 201 @@ -921,13 +891,12 @@ class TestApiExtendedCoverage: assert code_400 == 400 # With service-slo-sle-policy - intent_policy = { - "id": "slice-policy", - "service-slo-sle-policy": {"bound": 10} - } - with patch("src.api.main.get_data_store", return_value=None), \ - patch.object(api.slice_service, "nsc", return_value=True), \ - patch("src.api.main.create_data_store"): + intent_policy = {"id": "slice-policy", "service-slo-sle-policy": {"bound": 10}} + with ( + patch("src.api.main.get_data_store", return_value=None), + patch.object(api.slice_service, "nsc", return_value=True), + patch("src.api.main.create_data_store"), + ): res_201, code_201 = api.add_slice_service(intent_policy) assert code_201 == 201 @@ -1000,13 +969,18 @@ class TestApiRequestedMethodsCoverage: def test_get_slice_topology_branches(self, controller_with_mocked_db): api = Api(controller_with_mocked_db) - with patch("src.api.main.tfs_restconf_connector.get_slice_topology", return_value=({"ietf-network:networks": {}}, 200)): + with patch( + "src.api.main.tfs_restconf_connector.get_slice_topology", return_value=({"ietf-network:networks": {}}, 200) + ): res, code = api.get_slice_topology("simap-slice") assert code == 200 assert "ietf-network:networks" in res # Not found -> 404 - with patch("src.api.main.tfs_restconf_connector.get_slice_topology", return_value=({"success": False, "error": "Slice 'nonexistent' not found"}, 404)): + with patch( + "src.api.main.tfs_restconf_connector.get_slice_topology", + return_value=({"success": False, "error": "Slice 'nonexistent' not found"}, 404), + ): res_404, code_404 = api.get_slice_topology("nonexistent") assert code_404 == 404 assert res_404["success"] is False @@ -1043,8 +1017,7 @@ class TestApiRequestedMethodsCoverage: api = Api(controller_with_mocked_db) # Success - with patch.object(api.slice_service, "nsc", return_value={"ok": True}), \ - patch("src.api.main.create_data_store"): + with patch.object(api.slice_service, "nsc", return_value={"ok": True}), patch("src.api.main.create_data_store"): res, code = api.add_network_slice_service(sample_ietf_intent) assert code == 201 @@ -1064,32 +1037,32 @@ class TestApiRequestedMethodsCoverage: # Success with referenced slo-sle-template intent_tmpl = {"id": "slice-tmpl", "slo-sle-template": "tmpl-1"} tmpl_store_data = { - "network-slice-services": { - "slo-sle-templates": { - "slo-sle-template": { - "tmpl-1": {"id": "tmpl-1"} - } - } - } + "network-slice-services": {"slo-sle-templates": {"slo-sle-template": {"tmpl-1": {"id": "tmpl-1"}}}} } - with patch("src.api.main.get_data_store", side_effect=[None, tmpl_store_data]), \ - patch("src.api.main.normalize_libyang_data", side_effect=lambda x: x), \ - patch.object(api.slice_service, "nsc", return_value={"status": "created"}), \ - patch("src.api.main.create_data_store"): + with ( + patch("src.api.main.get_data_store", side_effect=[None, tmpl_store_data]), + patch("src.api.main.normalize_libyang_data", side_effect=lambda x: x), + patch.object(api.slice_service, "nsc", return_value={"status": "created"}), + patch("src.api.main.create_data_store"), + ): res, code = api.add_slice_service(intent_tmpl) assert code == 201 # RuntimeError -> 200 - with patch("src.api.main.get_data_store", side_effect=[None, tmpl_store_data]), \ - patch("src.api.main.normalize_libyang_data", side_effect=lambda x: x), \ - patch.object(api.slice_service, "nsc", side_effect=RuntimeError("No content")): + with ( + patch("src.api.main.get_data_store", side_effect=[None, tmpl_store_data]), + patch("src.api.main.normalize_libyang_data", side_effect=lambda x: x), + patch.object(api.slice_service, "nsc", side_effect=RuntimeError("No content")), + ): res_rt, code_rt = api.add_slice_service(intent_tmpl) assert code_rt == 200 # Exception -> 500 - with patch("src.api.main.get_data_store", side_effect=[None, tmpl_store_data]), \ - patch("src.api.main.normalize_libyang_data", side_effect=lambda x: x), \ - patch.object(api.slice_service, "nsc", side_effect=Exception("Err")): + with ( + patch("src.api.main.get_data_store", side_effect=[None, tmpl_store_data]), + patch("src.api.main.normalize_libyang_data", side_effect=lambda x: x), + patch.object(api.slice_service, "nsc", side_effect=Exception("Err")), + ): res_500, code_500 = api.add_slice_service(intent_tmpl) assert code_500 == 500 @@ -1099,48 +1072,52 @@ class TestApiRequestedMethodsCoverage: intent = {"id": "slice-1", "slo-sle-template": "tmpl-1"} existing_slice = {"slice-1": {"id": "slice-1"}} tmpl_data = { - "network-slice-services": { - "slo-sle-templates": { - "slo-sle-template": { - "tmpl-1": {"id": "tmpl-1"} - } - } - } + "network-slice-services": {"slo-sle-templates": {"slo-sle-template": {"tmpl-1": {"id": "tmpl-1"}}}} } # Success update with referenced template - with patch("src.api.main.get_data_store", side_effect=[existing_slice, tmpl_data]), \ - patch("src.api.main.normalize_libyang_data", side_effect=lambda x: x), \ - patch.object(api.slice_service, "nsc", return_value={"updated": True}), \ - patch("src.api.main.update_data_store"): + with ( + patch("src.api.main.get_data_store", side_effect=[existing_slice, tmpl_data]), + patch("src.api.main.normalize_libyang_data", side_effect=lambda x: x), + patch.object(api.slice_service, "nsc", return_value={"updated": True}), + patch("src.api.main.update_data_store"), + ): res, code = api.update_slice_service("slice-1", intent.copy()) assert code == 200 # nsc returns None -> 500 - with patch("src.api.main.get_data_store", side_effect=[existing_slice, tmpl_data]), \ - patch("src.api.main.normalize_libyang_data", side_effect=lambda x: x), \ - patch.object(api.slice_service, "nsc", return_value=None): + with ( + patch("src.api.main.get_data_store", side_effect=[existing_slice, tmpl_data]), + patch("src.api.main.normalize_libyang_data", side_effect=lambda x: x), + patch.object(api.slice_service, "nsc", return_value=None), + ): res_500, code_500 = api.update_slice_service("slice-1", intent.copy()) assert code_500 == 500 # RuntimeError -> 200 - with patch("src.api.main.get_data_store", side_effect=[existing_slice, tmpl_data]), \ - patch("src.api.main.normalize_libyang_data", side_effect=lambda x: x), \ - patch.object(api.slice_service, "nsc", side_effect=RuntimeError("No service")): + with ( + patch("src.api.main.get_data_store", side_effect=[existing_slice, tmpl_data]), + patch("src.api.main.normalize_libyang_data", side_effect=lambda x: x), + patch.object(api.slice_service, "nsc", side_effect=RuntimeError("No service")), + ): res_rt, code_rt = api.update_slice_service("slice-1", intent.copy()) assert code_rt == 200 # ValueError -> 404 - with patch("src.api.main.get_data_store", side_effect=[existing_slice, tmpl_data]), \ - patch("src.api.main.normalize_libyang_data", side_effect=lambda x: x), \ - patch.object(api.slice_service, "nsc", side_effect=ValueError("Val err")): + with ( + patch("src.api.main.get_data_store", side_effect=[existing_slice, tmpl_data]), + patch("src.api.main.normalize_libyang_data", side_effect=lambda x: x), + patch.object(api.slice_service, "nsc", side_effect=ValueError("Val err")), + ): res_ve, code_ve = api.update_slice_service("slice-1", intent.copy()) assert code_ve == 404 # Exception -> 500 - with patch("src.api.main.get_data_store", side_effect=[existing_slice, tmpl_data]), \ - patch("src.api.main.normalize_libyang_data", side_effect=lambda x: x), \ - patch.object(api.slice_service, "nsc", side_effect=Exception("Gen err")): + with ( + patch("src.api.main.get_data_store", side_effect=[existing_slice, tmpl_data]), + patch("src.api.main.normalize_libyang_data", side_effect=lambda x: x), + patch.object(api.slice_service, "nsc", side_effect=Exception("Gen err")), + ): res_ex, code_ex = api.update_slice_service("slice-1", intent.copy()) assert code_ex == 500 @@ -1156,18 +1133,20 @@ class TestApiRequestedMethodsCoverage: "slice-service": { "slice-1": { "id": "slice-1", - "service-tags": {"tag-type": {"ietf-network-slice-service:service": {"tag-type-value": []}}} + "service-tags": {"tag-type": {"ietf-network-slice-service:service": {"tag-type-value": []}}}, } } } } - with flask_app.app_context(), \ - patch("src.api.main.get_data_store", return_value=existing_single), \ - patch("src.api.main.get_data_by_slice_id", return_value=[{"service_id": "svc-1"}]), \ - patch("src.api.main.tfs_connector") as mock_conn_cls, \ - patch("src.api.main.delete_by_slice_id"), \ - patch("src.api.main.tfs_l2vpn_delete"), \ - patch("src.api.main.delete_data_store"): + with ( + flask_app.app_context(), + patch("src.api.main.get_data_store", return_value=existing_single), + patch("src.api.main.get_data_by_slice_id", return_value=[{"service_id": "svc-1"}]), + patch("src.api.main.tfs_connector") as mock_conn_cls, + patch("src.api.main.delete_by_slice_id"), + patch("src.api.main.tfs_l2vpn_delete"), + patch("src.api.main.delete_data_store"), + ): mock_conn = MagicMock() mock_conn_cls.return_value = mock_conn res_del, code_del = api.delete_slice_services("slice-1") @@ -1185,23 +1164,21 @@ class TestApiRequestedMethodsCoverage: { "id": "slice-10", "service-tags": { - "tag-type": { - "ietf-network-slice-service:service": { - "tag-type-value": ["L2"] - } - } - } + "tag-type": {"ietf-network-slice-service:service": {"tag-type-value": ["L2"]}} + }, } ] } } - with flask_app.app_context(), \ - patch("src.api.main.get_data_store", return_value=store_content), \ - patch("src.api.main.get_data_by_slice_id", return_value=[{"service_id": "svc-10"}]), \ - patch("src.api.main.tfs_connector") as mock_conn_cls, \ - patch("src.api.main.delete_by_slice_id"), \ - patch("src.api.main.tfs_l2vpn_delete"), \ - patch("src.api.main.delete_data_store"): + with ( + flask_app.app_context(), + patch("src.api.main.get_data_store", return_value=store_content), + patch("src.api.main.get_data_by_slice_id", return_value=[{"service_id": "svc-10"}]), + patch("src.api.main.tfs_connector") as mock_conn_cls, + patch("src.api.main.delete_by_slice_id"), + patch("src.api.main.tfs_l2vpn_delete"), + patch("src.api.main.delete_data_store"), + ): mock_conn = MagicMock() mock_conn_cls.return_value = mock_conn res_del_all, code_del_all = api.delete_slice_services() @@ -1237,8 +1214,10 @@ class TestApiRequestedMethodsCoverage: assert code_500 == 500 # delete_subscriptions single slice - with patch("src.api.main.get_client_subscriptions", return_value=["slice-1"]), \ - patch("src.api.main.delete_subscription"): + with ( + patch("src.api.main.get_client_subscriptions", return_value=["slice-1"]), + patch("src.api.main.delete_subscription"), + ): res_del_sub, code_del_sub = api.delete_subscriptions("c1", "slice-1") assert code_del_sub == 204 @@ -1248,8 +1227,10 @@ class TestApiRequestedMethodsCoverage: assert code_del_sub_404 == 404 # delete_subscriptions all - with patch("src.api.main.get_client_subscriptions", return_value=[]), \ - patch("src.api.main.delete_all_subscriptions"): + with ( + patch("src.api.main.get_client_subscriptions", return_value=[]), + patch("src.api.main.delete_all_subscriptions"), + ): res_del_all, code_del_all = api.delete_subscriptions("c1") assert code_del_all == 204 @@ -1262,8 +1243,10 @@ class TestApiRequestedMethodsCoverage: api = Api(controller_with_mocked_db) # single slice subscription success - with patch("src.api.main.get_subscription", return_value={"client_id": "c1", "slice_id": "s1"}), \ - patch.object(api, "get_telemetry", return_value=({"latency": 10}, 200)): + with ( + patch("src.api.main.get_subscription", return_value={"client_id": "c1", "slice_id": "s1"}), + patch.object(api, "get_telemetry", return_value=({"latency": 10}, 200)), + ): res_sub, code_sub = api.get_subscriptions("c1", "s1") assert code_sub == 200 assert "telemetry" in res_sub @@ -1274,8 +1257,10 @@ class TestApiRequestedMethodsCoverage: assert code_404 == 404 # all subscriptions for client - with patch("src.api.main.get_client_subscriptions", return_value=[{"slice_id": "s1"}]), \ - patch.object(api, "get_telemetry", return_value=({"latency": 10}, 200)): + with ( + patch("src.api.main.get_client_subscriptions", return_value=[{"slice_id": "s1"}]), + patch.object(api, "get_telemetry", return_value=({"latency": 10}, 200)), + ): res_subs_all, code_subs_all = api.get_subscriptions("c1") assert code_subs_all == 200 assert len(res_subs_all["subscriptions"]) == 1 @@ -1289,27 +1274,15 @@ class TestApiRequestedMethodsCoverage: api = Api(controller_with_mocked_db) # Single slice telemetry success - existing_slice = { - "network-slice-services": { - "slice-service": { - "s1": {"slo-sle-template": "tmpl-1"} - } - } - } + existing_slice = {"network-slice-services": {"slice-service": {"s1": {"slo-sle-template": "tmpl-1"}}}} tmpl_data = [ - { - "network-slice-services": { - "slo-sle-templates": { - "slo-sle-template": { - "tmpl-1": {"id": "tmpl-1"} - } - } - } - } + {"network-slice-services": {"slo-sle-templates": {"slo-sle-template": {"tmpl-1": {"id": "tmpl-1"}}}}} ] - with patch("src.api.main.get_data_store", return_value=existing_slice), \ - patch.object(api, "get_slo_sle_templates", return_value=tmpl_data), \ - patch.object(api.slice_service, "monitoring", return_value={"bw": 100}): + with ( + patch("src.api.main.get_data_store", return_value=existing_slice), + patch.object(api, "get_slo_sle_templates", return_value=tmpl_data), + patch.object(api.slice_service, "monitoring", return_value={"bw": 100}), + ): res, code = api.get_telemetry("s1") assert code == 200 assert res == {"bw": 100} @@ -1321,22 +1294,22 @@ class TestApiRequestedMethodsCoverage: # Single slice telemetry - template not found -> 404 empty_tmpl_data = [{"network-slice-services": {"slo-sle-templates": {"slo-sle-template": {}}}}] - with patch("src.api.main.get_data_store", return_value=existing_slice), \ - patch.object(api, "get_slo_sle_templates", return_value=empty_tmpl_data): + with ( + patch("src.api.main.get_data_store", return_value=existing_slice), + patch.object(api, "get_slo_sle_templates", return_value=empty_tmpl_data), + ): res_no_tmpl, code_no_tmpl = api.get_telemetry("s1") assert code_no_tmpl == 404 # All slices telemetry loop (as list and dict) slices_data_dict = { - "network-slice-services": { - "slice-service": { - "s1": {"id": "s1", "slo-sle-template": "tmpl-1"} - } - } + "network-slice-services": {"slice-service": {"s1": {"id": "s1", "slo-sle-template": "tmpl-1"}}} } - with patch.object(api, "get_slice_services", return_value=(slices_data_dict, 200)), \ - patch.object(api, "get_slo_sle_templates", return_value=tmpl_data), \ - patch.object(api.slice_service, "monitoring", return_value={"bw": 100}): + with ( + patch.object(api, "get_slice_services", return_value=(slices_data_dict, 200)), + patch.object(api, "get_slo_sle_templates", return_value=tmpl_data), + patch.object(api.slice_service, "monitoring", return_value={"bw": 100}), + ): res_all, code_all = api.get_telemetry() assert code_all == 200 assert "s1" in res_all @@ -1345,8 +1318,10 @@ class TestApiRequestedMethodsCoverage: api = Api(controller_with_mocked_db) # 1. stream_client_subscriptions success & break via sleep StopAsyncIteration - with patch.object(api, "get_subscriptions", return_value=({"subscriptions": [{"frequency": 1}]}, 200)), \ - patch("asyncio.sleep", side_effect=StopAsyncIteration): + with ( + patch.object(api, "get_subscriptions", return_value=({"subscriptions": [{"frequency": 1}]}, 200)), + patch("asyncio.sleep", side_effect=StopAsyncIteration), + ): gen = api.sync_stream(api.stream_client_subscriptions, "c1") items = list(gen) assert len(items) >= 1 @@ -1367,8 +1342,10 @@ class TestApiRequestedMethodsCoverage: assert "event: error" in items[0] # 4. stream_slice_subscription success - with patch.object(api, "get_subscriptions", return_value=({"frequency": 2}, 200)), \ - patch("asyncio.sleep", side_effect=StopAsyncIteration): + with ( + patch.object(api, "get_subscriptions", return_value=({"frequency": 2}, 200)), + patch("asyncio.sleep", side_effect=StopAsyncIteration), + ): gen = api.sync_stream(api.stream_slice_subscription, "c1", "s1") items = list(gen) assert len(items) >= 1 diff --git a/src/tests/test_database.py b/src/tests/test_database.py index 2729914..8ce9b2f 100644 --- a/src/tests/test_database.py +++ b/src/tests/test_database.py @@ -38,9 +38,9 @@ from src.database.store_data import store_data def test_db(tmp_path): """Fixture to create and cleanup test database.""" test_db_name = str(tmp_path / "test_slice.db") - + # Use test database - with patch('src.database.db.DB_NAME', test_db_name): + with patch("src.database.db.DB_NAME", test_db_name): conn = sqlite3.connect(test_db_name) cursor = conn.cursor() cursor.execute(""" @@ -52,24 +52,26 @@ def test_db(tmp_path): """) conn.commit() conn.close() - + yield test_db_name - + # Cleanup - Close all connections and remove file try: # Force SQLite to release locks - sqlite3.connect(':memory:').execute('VACUUM').close() - + sqlite3.connect(":memory:").execute("VACUUM").close() + # Wait a moment for file locks to release import time + time.sleep(0.1) - + # Remove the file if it exists if os.path.exists(test_db_name): os.remove(test_db_name) except Exception: # On Windows, sometimes files are locked. Try again after a delay import time + time.sleep(0.5) try: if os.path.exists(test_db_name): @@ -83,29 +85,24 @@ def sample_intent(): """Fixture providing sample network slice intent.""" return { "ietf-network-slice-service:network-slice-services": { - "slice-service": [{ - "id": "slice-service-12345", - "description": "Test network slice", - "service-tags": {"tag-type": {"value": "L2VPN"}}, - "sdps": { - "sdp": [{ - "node-id": "node1", - "sdp-ip-address": "10.0.0.1" - }] + "slice-service": [ + { + "id": "slice-service-12345", + "description": "Test network slice", + "service-tags": {"tag-type": {"value": "L2VPN"}}, + "sdps": {"sdp": [{"node-id": "node1", "sdp-ip-address": "10.0.0.1"}]}, } - }], + ], "slo-sle-templates": { - "slo-sle-template": [{ - "id": "profile1", - "slo-policy": { - "metric-bound": [{ - "metric-type": "one-way-bandwidth", - "metric-unit": "kbps", - "bound": 1000 - }] + "slo-sle-template": [ + { + "id": "profile1", + "slo-policy": { + "metric-bound": [{"metric-type": "one-way-bandwidth", "metric-unit": "kbps", "bound": 1000}] + }, } - }] - } + ] + }, } } @@ -113,132 +110,128 @@ def sample_intent(): @pytest.fixture def simple_intent(): """Fixture providing simple intent for basic testing.""" - return { - "bandwidth": "1Gbps", - "latency": "10ms", - "provider": "opensec" - } + return {"bandwidth": "1Gbps", "latency": "10ms", "provider": "opensec"} class TestInitDb: """Tests for database initialization.""" - + def test_init_db_creates_table(self, tmp_path): """Test that init_db creates the slice table.""" test_db = str(tmp_path / "test.db") - - with patch('src.database.db.DB_NAME', test_db): + + with patch("src.database.db.DB_NAME", test_db): init_db() - + conn = sqlite3.connect(test_db) cursor = conn.cursor() cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='slice'") result = cursor.fetchone() conn.close() time.sleep(0.05) # Brief pause for file lock release - + assert result is not None - assert result[0] == 'slice' - + assert result[0] == "slice" + def test_init_db_creates_correct_columns(self, tmp_path): """Test that init_db creates table with correct columns.""" test_db = str(tmp_path / "test.db") - - with patch('src.database.db.DB_NAME', test_db): + + with patch("src.database.db.DB_NAME", test_db): init_db() - + conn = sqlite3.connect(test_db) cursor = conn.cursor() cursor.execute("PRAGMA table_info(slice)") columns = cursor.fetchall() conn.close() time.sleep(0.05) - + column_names = [col[1] for col in columns] assert "slice_id" in column_names assert "intent" in column_names assert "controller" in column_names - + def test_init_db_idempotent(self, tmp_path): """Test that init_db can be called multiple times without error.""" test_db = str(tmp_path / "test.db") - - with patch('src.database.db.DB_NAME', test_db): + + with patch("src.database.db.DB_NAME", test_db): init_db() init_db() # Should not raise error - + conn = sqlite3.connect(test_db) cursor = conn.cursor() cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='slice'") result = cursor.fetchone() conn.close() time.sleep(0.05) - + assert result is not None class TestSaveData: """Tests for save_data function.""" - + def test_save_data_success(self, test_db, simple_intent): """Test successful data saving.""" - with patch('src.database.db.DB_NAME', test_db): + with patch("src.database.db.DB_NAME", test_db): save_data("slice-001", simple_intent, "TFS") - + conn = sqlite3.connect(test_db) cursor = conn.cursor() cursor.execute("SELECT * FROM slice WHERE slice_id = ?", ("slice-001",)) result = cursor.fetchone() conn.close() - + assert result is not None assert result[0] == "slice-001" assert result[2] == "TFS" assert json.loads(result[1]) == simple_intent - + def test_save_data_with_complex_intent(self, test_db, sample_intent): """Test saving complex nested intent structure.""" - with patch('src.database.db.DB_NAME', test_db): + with patch("src.database.db.DB_NAME", test_db): slice_id = sample_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"] save_data(slice_id, sample_intent, "IXIA") - + conn = sqlite3.connect(test_db) cursor = conn.cursor() cursor.execute("SELECT intent FROM slice WHERE slice_id = ?", (slice_id,)) result = cursor.fetchone() conn.close() - + retrieved_intent = json.loads(result[0]) assert retrieved_intent == sample_intent - + def test_save_data_duplicate_slice_id_raises_error(self, test_db, simple_intent): """Test that saving duplicate slice_id raises ValueError.""" - with patch('src.database.db.DB_NAME', test_db): + with patch("src.database.db.DB_NAME", test_db): save_data("slice-001", simple_intent, "TFS") - + with pytest.raises(ValueError, match="already exists"): save_data("slice-001", simple_intent, "TFS") - + def test_save_data_multiple_slices(self, test_db, simple_intent): """Test saving multiple different slices.""" - with patch('src.database.db.DB_NAME', test_db): + with patch("src.database.db.DB_NAME", test_db): save_data("slice-001", simple_intent, "TFS") save_data("slice-002", simple_intent, "IXIA") - + conn = sqlite3.connect(test_db) cursor = conn.cursor() cursor.execute("SELECT COUNT(*) FROM slice") count = cursor.fetchone()[0] conn.close() - + assert count == 2 - + def test_save_data_with_different_controllers(self, test_db, simple_intent): """Test saving data with different controller types.""" - with patch('src.database.db.DB_NAME', test_db): + with patch("src.database.db.DB_NAME", test_db): save_data("slice-tfs", simple_intent, "TFS") save_data("slice-ixia", simple_intent, "IXIA") - + conn = sqlite3.connect(test_db) cursor = conn.cursor() cursor.execute("SELECT controller FROM slice WHERE slice_id = ?", ("slice-tfs",)) @@ -246,98 +239,105 @@ class TestSaveData: cursor.execute("SELECT controller FROM slice WHERE slice_id = ?", ("slice-ixia",)) ixia_result = cursor.fetchone() conn.close() - + assert tfs_result[0] == "TFS" assert ixia_result[0] == "IXIA" class TestUpdateData: """Tests for update_data function.""" - + def test_update_data_success(self, test_db, simple_intent): """Test successful data update.""" - with patch('src.database.db.DB_NAME', test_db): + with patch("src.database.db.DB_NAME", test_db): save_data("slice-001", simple_intent, "TFS") - + updated_intent = {"bandwidth": "2Gbps", "latency": "5ms", "provider": "opensec"} update_data("slice-001", updated_intent, "TFS") - + conn = sqlite3.connect(test_db) cursor = conn.cursor() cursor.execute("SELECT intent FROM slice WHERE slice_id = ?", ("slice-001",)) result = cursor.fetchone() conn.close() - + retrieved_intent = json.loads(result[0]) assert retrieved_intent == updated_intent - + def test_update_data_nonexistent_slice_raises_error(self, test_db, simple_intent): """Test that updating nonexistent slice raises ValueError.""" - with patch('src.database.db.DB_NAME', test_db): + with patch("src.database.db.DB_NAME", test_db): with pytest.raises(ValueError, match="No slice found"): update_data("nonexistent-slice", simple_intent, "TFS") - + def test_update_data_controller_type(self, test_db, simple_intent): """Test updating controller type.""" - with patch('src.database.db.DB_NAME', test_db): + with patch("src.database.db.DB_NAME", test_db): save_data("slice-001", simple_intent, "TFS") update_data("slice-001", simple_intent, "IXIA") - + conn = sqlite3.connect(test_db) cursor = conn.cursor() cursor.execute("SELECT controller FROM slice WHERE slice_id = ?", ("slice-001",)) result = cursor.fetchone() conn.close() - + assert result[0] == "IXIA" - + def test_update_data_complex_intent(self, test_db, sample_intent): """Test updating with complex nested structure.""" - with patch('src.database.db.DB_NAME', test_db): + with patch("src.database.db.DB_NAME", test_db): slice_id = sample_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"] save_data(slice_id, sample_intent, "TFS") - + updated_sample = sample_intent.copy() - updated_sample["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["description"] = "Updated description" - + updated_sample["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["description"] = ( + "Updated description" + ) + update_data(slice_id, updated_sample, "IXIA") - + retrieved = get_data(slice_id) - assert retrieved["intent"]["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["description"] == "Updated description" + assert ( + retrieved["intent"]["ietf-network-slice-service:network-slice-services"]["slice-service"][0][ + "description" + ] + == "Updated description" + ) assert retrieved["controller"] == "IXIA" class TestDeleteData: """Tests for delete_data function.""" - + def test_delete_data_success(self, test_db, simple_intent): """Test successful data deletion.""" - with patch('src.database.db.DB_NAME', test_db): + with patch("src.database.db.DB_NAME", test_db): save_data("slice-001", simple_intent, "TFS") delete_data("slice-001") - + conn = sqlite3.connect(test_db) cursor = conn.cursor() cursor.execute("SELECT * FROM slice WHERE slice_id = ?", ("slice-001",)) result = cursor.fetchone() conn.close() - + assert result is None - + def test_delete_data_nonexistent_slice_raises_error(self, test_db): """Test that deleting nonexistent slice raises ValueError.""" - with patch('src.database.db.DB_NAME', test_db): + with patch("src.database.db.DB_NAME", test_db): with pytest.raises(ValueError, match="No slice found"): delete_data("nonexistent-slice") - + def test_delete_data_multiple_slices(self, test_db, simple_intent): """Test deleting one slice doesn't affect others.""" - with patch('src.database.db.DB_NAME', test_db): + with patch("src.database.db.DB_NAME", test_db): save_data("slice-001", simple_intent, "TFS") save_data("slice-002", simple_intent, "IXIA") - + delete_data("slice-001") - + conn = sqlite3.connect(test_db) cursor = conn.cursor() cursor.execute("SELECT COUNT(*) FROM slice") @@ -345,46 +345,46 @@ class TestDeleteData: cursor.execute("SELECT * FROM slice WHERE slice_id = ?", ("slice-002",)) remaining = cursor.fetchone() conn.close() - + assert count == 1 assert remaining[0] == "slice-002" class TestGetData: """Tests for get_data function.""" - + def test_get_data_success(self, test_db, simple_intent): """Test retrieving existing data.""" - with patch('src.database.db.DB_NAME', test_db): + with patch("src.database.db.DB_NAME", test_db): save_data("slice-001", simple_intent, "TFS") result = get_data("slice-001") - + assert result["slice_id"] == "slice-001" assert result["intent"] == simple_intent assert result["controller"] == "TFS" - + def test_get_data_nonexistent_raises_error(self, test_db): """Test that getting nonexistent slice raises ValueError.""" - with patch('src.database.db.DB_NAME', test_db): + with patch("src.database.db.DB_NAME", test_db): with pytest.raises(ValueError, match="No slice found"): get_data("nonexistent-slice") - + def test_get_data_json_parsing(self, test_db, sample_intent): """Test that returned intent is parsed JSON.""" - with patch('src.database.db.DB_NAME', test_db): + with patch("src.database.db.DB_NAME", test_db): slice_id = sample_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"] save_data(slice_id, sample_intent, "TFS") result = get_data(slice_id) - + assert isinstance(result["intent"], dict) assert result["intent"] == sample_intent - + def test_get_data_returns_all_fields(self, test_db, simple_intent): """Test that get_data returns all fields.""" - with patch('src.database.db.DB_NAME', test_db): + with patch("src.database.db.DB_NAME", test_db): save_data("slice-001", simple_intent, "TFS") result = get_data("slice-001") - + assert "slice_id" in result assert "intent" in result assert "controller" in result @@ -393,58 +393,58 @@ class TestGetData: class TestGetAllData: """Tests for get_all_data function.""" - + def test_get_all_data_empty_database(self, test_db): """Test retrieving all data from empty database.""" - with patch('src.database.db.DB_NAME', test_db): + with patch("src.database.db.DB_NAME", test_db): result = get_all_data() assert result == [] - + def test_get_all_data_single_slice(self, test_db, simple_intent): """Test retrieving all data with single slice.""" - with patch('src.database.db.DB_NAME', test_db): + with patch("src.database.db.DB_NAME", test_db): save_data("slice-001", simple_intent, "TFS") result = get_all_data() - + assert len(result) == 1 assert result[0]["slice_id"] == "slice-001" assert result[0]["intent"] == simple_intent - + def test_get_all_data_multiple_slices(self, test_db, simple_intent): """Test retrieving all data with multiple slices.""" - with patch('src.database.db.DB_NAME', test_db): + with patch("src.database.db.DB_NAME", test_db): save_data("slice-001", simple_intent, "TFS") save_data("slice-002", simple_intent, "IXIA") save_data("slice-003", simple_intent, "TFS") - + result = get_all_data() - + assert len(result) == 3 slice_ids = [slice_data["slice_id"] for slice_data in result] assert "slice-001" in slice_ids assert "slice-002" in slice_ids assert "slice-003" in slice_ids - + def test_get_all_data_json_parsing(self, test_db, sample_intent): """Test that all returned intents are parsed JSON.""" - with patch('src.database.db.DB_NAME', test_db): + with patch("src.database.db.DB_NAME", test_db): slice_id = sample_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"] save_data(slice_id, sample_intent, "TFS") save_data("slice-002", sample_intent, "IXIA") - + result = get_all_data() - + for slice_data in result: assert isinstance(slice_data["intent"], dict) - + def test_get_all_data_includes_all_controllers(self, test_db, simple_intent): """Test that get_all_data includes slices from different controllers.""" - with patch('src.database.db.DB_NAME', test_db): + with patch("src.database.db.DB_NAME", test_db): save_data("slice-tfs", simple_intent, "TFS") save_data("slice-ixia", simple_intent, "IXIA") - + result = get_all_data() - + controllers = [slice_data["controller"] for slice_data in result] assert "TFS" in controllers assert "IXIA" in controllers @@ -452,21 +452,21 @@ class TestGetAllData: class TestDeleteAllData: """Tests for delete_all_data function.""" - + def test_delete_all_data_removes_all_slices(self, test_db, simple_intent): """Test that delete_all_data removes all slices.""" - with patch('src.database.db.DB_NAME', test_db): + with patch("src.database.db.DB_NAME", test_db): save_data("slice-001", simple_intent, "TFS") save_data("slice-002", simple_intent, "IXIA") - + delete_all_data() - + result = get_all_data() assert result == [] - + def test_delete_all_data_empty_database(self, test_db): """Test delete_all_data on empty database doesn't raise error.""" - with patch('src.database.db.DB_NAME', test_db): + with patch("src.database.db.DB_NAME", test_db): delete_all_data() # Should not raise error result = get_all_data() assert result == [] @@ -474,128 +474,133 @@ class TestDeleteAllData: class TestStoreData: """Tests for store_data wrapper function.""" - + def test_store_data_save_new_slice(self, test_db, sample_intent): """Test store_data saves new slice without slice_id.""" - with patch('src.database.db.DB_NAME', test_db): + with patch("src.database.db.DB_NAME", test_db): store_data(sample_intent, None, "TFS") - + slice_id = sample_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"] result = get_data(slice_id) - + assert result["slice_id"] == slice_id assert result["intent"] == sample_intent assert result["controller"] == "TFS" - + def test_store_data_update_existing_slice(self, test_db, sample_intent): """Test store_data updates existing slice when slice_id provided.""" - with patch('src.database.db.DB_NAME', test_db): + with patch("src.database.db.DB_NAME", test_db): slice_id = sample_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"] - + # Save initial data save_data(slice_id, sample_intent, "TFS") - + # Update with store_data updated_intent = sample_intent.copy() - updated_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["description"] = "Updated" + updated_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["description"] = ( + "Updated" + ) store_data(updated_intent, slice_id, "IXIA") - + result = get_data(slice_id) assert result["controller"] == "IXIA" - assert result["intent"]["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["description"] == "Updated" - + assert ( + result["intent"]["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["description"] + == "Updated" + ) + def test_store_data_extracts_slice_id_from_intent(self, test_db, sample_intent): """Test store_data correctly extracts slice_id from intent structure.""" - with patch('src.database.db.DB_NAME', test_db): + with patch("src.database.db.DB_NAME", test_db): store_data(sample_intent, None, "TFS") - + all_data = get_all_data() assert len(all_data) == 1 assert all_data[0]["slice_id"] == "slice-service-12345" - + def test_store_data_with_different_controllers(self, test_db, sample_intent): """Test store_data works with different controller types.""" - with patch('src.database.db.DB_NAME', test_db): + with patch("src.database.db.DB_NAME", test_db): store_data(sample_intent, None, "TFS") - + slice_id = sample_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"] result = get_data(slice_id) - + assert result["controller"] == "TFS" class TestDatabaseIntegration: """Integration tests for database operations.""" - + def test_full_lifecycle_create_read_update_delete(self, test_db, simple_intent): """Test complete slice lifecycle.""" - with patch('src.database.db.DB_NAME', test_db): + with patch("src.database.db.DB_NAME", test_db): # Create save_data("slice-lifecycle", simple_intent, "TFS") - + # Read result = get_data("slice-lifecycle") assert result["slice_id"] == "slice-lifecycle" - + # Update updated_intent = {"bandwidth": "5Gbps", "latency": "2ms", "provider": "opensec"} update_data("slice-lifecycle", updated_intent, "IXIA") - + result = get_data("slice-lifecycle") assert result["intent"] == updated_intent assert result["controller"] == "IXIA" - + # Delete delete_data("slice-lifecycle") - + with pytest.raises(ValueError): get_data("slice-lifecycle") - + def test_concurrent_operations(self, test_db, simple_intent): """Test multiple concurrent database operations.""" - with patch('src.database.db.DB_NAME', test_db): + with patch("src.database.db.DB_NAME", test_db): # Create multiple slices for i in range(5): save_data(f"slice-{i}", simple_intent, "TFS" if i % 2 == 0 else "IXIA") - + # Verify all created all_data = get_all_data() assert len(all_data) == 5 - + # Update some updated_intent = {"updated": True} for i in range(3): update_data(f"slice-{i}", updated_intent, "TFS") - + # Verify updates for i in range(3): result = get_data(f"slice-{i}") assert result["intent"]["updated"] is True - + # Delete some delete_data("slice-0") delete_data("slice-2") - + all_data = get_all_data() assert len(all_data) == 3 - + def test_data_persistence_across_operations(self, test_db, sample_intent): """Test that data persists correctly across multiple operations.""" - with patch('src.database.db.DB_NAME', test_db): + with patch("src.database.db.DB_NAME", test_db): slice_id = sample_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"] - + # Save save_data(slice_id, sample_intent, "TFS") - + # Get all and verify all_before = get_all_data() assert len(all_before) == 1 - + # Save another save_data("slice-other", sample_intent, "IXIA") all_after = get_all_data() assert len(all_after) == 2 - + # Verify first slice still intact first_slice = get_data(slice_id) assert first_slice["intent"] == sample_intent @@ -664,6 +669,8 @@ class TestTelemetryClientDB: assert len(subs) == 1 delete_subscription("c1", "slice-A") + + class TestAlertDB: """Tests for alert_db module.""" @@ -703,9 +710,7 @@ class TestSysrepoStore: libyang_data = { "ietf-network-slice-service:network-slice-services": { - "slice-service": [ - {"id": "slice-1", "description": "Test"} - ] + "slice-service": [{"id": "slice-1", "description": "Test"}] } } normalized = normalize_libyang_data(libyang_data) @@ -735,7 +740,7 @@ class TestSysrepoStore: "int_key": 42, "bool_key": True, "list_key": ["item1", "item2"], - "dict_key": {"inner": "val2"} + "dict_key": {"inner": "val2"}, } _write_dict(mock_sess, "/path", complex_dict) assert mock_sess.set_item.call_count >= 5 @@ -750,8 +755,7 @@ class TestSysrepoStore: s_db = str(tmp_path / "test_service.db") a_db = str(tmp_path / "test_alert.db") - with patch("src.database.service_db.DB_NAME", s_db), \ - patch("src.database.alert_db.DB_NAME", a_db): + with patch("src.database.service_db.DB_NAME", s_db), patch("src.database.alert_db.DB_NAME", a_db): init_service_db() init_alert_db() @@ -764,4 +768,4 @@ class TestSysrepoStore: # Non-existent alert_id error branch with pytest.raises(ValueError, match="No alert found"): - update_alert("nonexistent-alert", {"data": 1}) \ No newline at end of file + update_alert("nonexistent-alert", {"data": 1}) diff --git a/src/tests/test_e2e.py b/src/tests/test_e2e.py index c5a28b3..3535c9e 100644 --- a/src/tests/test_e2e.py +++ b/src/tests/test_e2e.py @@ -1,266 +1,286 @@ -# Copyright 2022-2026 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 json -from itertools import product -from pathlib import Path -from unittest.mock import MagicMock - -import pytest - -from app import create_app -from src.api.main import Api -from src.main import NSController - -# Folder where request JSON files are located -REQUESTS_DIR = Path(__file__).parent / "requests" - -# Namespaces to test -NAMESPACES = ["tfs", "ixia", "e2e", "restconf"] - - -# Flag configurations covering all .env flags and their possible values -FLAG_OPTIONS = { - "DUMMY_MODE": [True, False], - "WEBUI_DEPLOY": [True, False], - "DUMP_TEMPLATES": [True, False], - "NRP_ENABLED": [True, False], - "PLANNER_ENABLED": [True, False], - "PCE_EXTERNAL": [True, False], - "PLANNER_TYPE": ["ENERGY", "HRAT", "E2E_OPTICAL"], - "UPLOAD_TYPE": ["NBI", "WEBUI"], - "TFS_L2VPN_SUPPORT": [True, False], - "SDN_CONTROLLER_TYPE": ["TFS", "IXIA"], - "DATAPLANE_SUPPORT": ["CISCO", "FRR"], - "SUBSCRIBE_ALERTS": [True, False], -} - - -def generate_flag_combinations(): - """Generates all Cartesian product combinations of configuration flags.""" - keys = list(FLAG_OPTIONS.keys()) - values = list(FLAG_OPTIONS.values()) - for prod in product(*values): - combo = dict(zip(keys, prod)) - combo.update({ - "SUBSCRIBE_ALERTS_URL": "http://127.0.0.1:8085/alert", - "HRAT_IP": "10.0.0.1", - "E2E_OPTICAL_IP": "127.0.0.1", - "TFS_IP": "127.0.0.1", - "IXIA_IP": "127.0.0.1", - "TFS_E2E_IP": "127.0.0.1", - "RESTCONF_IP": "192.168.27.189", - "API_USERNAME": "admin", - "API_PASSWORD": "password", - }) - yield combo - - -class MockResponse: - """Mock response object for HTTP and controller connectors.""" - def __init__(self, status_code=200, text="OK", json_data=None): - self.status_code = status_code - self.text = text - self.ok = status_code < 400 - self._json_data = json_data if json_data is not None else { - "success": True, - "tapi-notification:output": { - "subscription-id": "mock-sub-123" - } - } - - def json(self): - return self._json_data - - def raise_for_status(self): - if not self.ok: - raise Exception(f"HTTP Error {self.status_code}") - - -@pytest.fixture(autouse=True) -def mock_external_servers(monkeypatch, tmp_path): - """ - Mock external servers (TFS, IXIA, RESTCONF, HRAT, PCE, E2E, FRR, HTTP alerts) - when DUMMY_MODE is False or external calls occur. Assumes good/successful responses. - """ - temp_templates = tmp_path / "templates" - temp_templates.mkdir(exist_ok=True) - monkeypatch.setattr("src.utils.dump_templates.TEMPLATES_PATH", str(temp_templates)) - - def mock_http_call(*args, **kwargs): - return MockResponse(200, text="OK") - - # Patch requests module methods - monkeypatch.setattr("requests.get", mock_http_call) - monkeypatch.setattr("requests.post", mock_http_call) - monkeypatch.setattr("requests.put", mock_http_call) - monkeypatch.setattr("requests.delete", mock_http_call) - monkeypatch.setattr("requests.request", mock_http_call) - - class MockSession: - def __init__(self): - self.auth = None - def get(self, *args, **kwargs): - return MockResponse(200, text='') - def post(self, *args, **kwargs): - return MockResponse(200, text="OK") - def put(self, *args, **kwargs): - return MockResponse(200, text="OK") - def delete(self, *args, **kwargs): - return MockResponse(200, text="OK") - - monkeypatch.setattr("requests.Session", MockSession) - - # Patch TFS connectors - try: - from src.realizer.tfs.helpers.tfs_connector import tfs_connector - monkeypatch.setattr(tfs_connector, "webui_post", lambda self, *a, **kw: MockResponse(200, "OK")) - monkeypatch.setattr(tfs_connector, "nbi_post", lambda self, *a, **kw: MockResponse(200, "OK")) - monkeypatch.setattr(tfs_connector, "nbi_delete", lambda self, *a, **kw: MockResponse(200, "OK")) - monkeypatch.setattr(tfs_connector, "ipowdm_post", lambda self, *a, **kw: MockResponse(200, "OK")) - monkeypatch.setattr(tfs_connector, "ipowdm_put", lambda self, *a, **kw: MockResponse(200, "OK")) - monkeypatch.setattr(tfs_connector, "get_network_topology", lambda self, *a, **kw: ([], MockResponse(200, "OK"))) - except Exception: - pass - - try: - from src.realizer.restconf.connectors.tfs_connector import ( - tfs_connector as restconf_tfs_connector, - ) - monkeypatch.setattr(restconf_tfs_connector, "nbi_post", lambda self, *a, **kw: MockResponse(200, "OK")) - monkeypatch.setattr(restconf_tfs_connector, "nbi_delete", lambda self, *a, **kw: MockResponse(200, "OK")) - except Exception: - pass - - # Patch IXIA controller - try: - from src.realizer.ixia.helpers.NEII_V4 import NEII_controller - monkeypatch.setattr(NEII_controller, "nscNEII", lambda self, *a, **kw: MockResponse(200, "OK")) - except Exception: - pass - - # Patch FRR and Cisco connectors / Netmiko - try: - from src.realizer.restconf.connectors.frr_connector import frr_connector - monkeypatch.setattr(frr_connector, "execute_commands", lambda self, commands: None) - except Exception: - pass - - try: - from src.realizer.tfs.helpers.cisco_connector import cisco_connector - monkeypatch.setattr(cisco_connector, "execute_commands", lambda self, commands: None) - except Exception: - pass - - try: - import netmiko - monkeypatch.setattr(netmiko, "ConnectHandler", lambda **kw: MagicMock()) - except Exception: - pass - - -@pytest.fixture -def app(temp_sqlite_db): - """Creates the Flask app with default configuration.""" - app = create_app() - return app - - -@pytest.fixture -def set_flags(app): - """Directly updates configuration flags in app.config.""" - def _set(flags: dict): - for k, v in flags.items(): - app.config[k] = v - return _set - - -def load_request_files(): - """Recursively loads all JSON request files from subdirectories under requests/.""" - test_cases = [] - # Search all .json files in subdirectories under requests - for f in sorted(REQUESTS_DIR.rglob("*.json")): - try: - with open(f, "r", encoding="utf-8") as file: - json_data = json.load(file) - rel_path = f.relative_to(REQUESTS_DIR).as_posix() - test_cases.append((rel_path, json_data)) - except Exception: - pass - return test_cases - - -def generate_test_cases(): - """Generates all 6,144 flag combinations paired across all request files and namespaces.""" - requests = load_request_files() - if not requests: - return - flag_combos = list(generate_flag_combinations()) - num_reqs = len(requests) - num_ns = len(NAMESPACES) - - for i, flags in enumerate(flag_combos): - rel_path, json_data = requests[i % num_reqs] - namespace = NAMESPACES[i % num_ns] - yield (rel_path, json_data, namespace, flags) - - -@pytest.mark.parametrize( - "rel_path, json_data, namespace, flags", - list(generate_test_cases()), - ids=lambda param: param if isinstance(param, str) else (param.get("PLANNER_TYPE", "") if isinstance(param, dict) else None) -) -def test_add_and_delete_flow(app, rel_path, json_data, namespace, flags, set_flags, temp_sqlite_db): - with app.app_context(): - set_flags(flags) - - controller_type = namespace.upper() - controller = NSController(controller_type=controller_type) - api = Api(controller) - - # Execute creation based on namespace - if namespace in ["tfs", "ixia", "e2e"]: - data, code = api.add_flow(json_data) - elif namespace == "restconf": - data, code = api.add_network_slice_service(json_data) - else: - pytest.fail(f"Unsupported namespace: {namespace}") - - if namespace in ["tfs", "ixia", "e2e"]: - assert code in [200, 201], f"Creation failed for request '{rel_path}' in namespace '{namespace}' with flags: {flags}. Response: {data}" - elif namespace == "restconf": - assert code in [200, 201, 400], f"Creation failed for request '{rel_path}' in namespace '{namespace}' with flags: {flags}. Response: {data}" - - # Delete flow if created - if code in [200, 201]: - slice_id = None - if isinstance(data, dict): - # Check data payload for slice_id - payload_data = data.get("data") - if isinstance(payload_data, dict): - slices = payload_data.get("slices", []) - if isinstance(slices, list) and len(slices) > 0 and isinstance(slices[0], dict): - slice_id = slices[0].get("id") - - if namespace in ["tfs", "ixia", "e2e"]: - if slice_id: - _, delete_code = api.delete_flows(slice_id=slice_id) - else: - _, delete_code = api.delete_flows() - elif namespace == "restconf": - _, delete_code = api.delete_slice_services() - - assert delete_code in [200, 204, 404], f"Deletion failed for slice '{slice_id}' in namespace '{namespace}'" \ No newline at end of file +# Copyright 2022-2026 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 json +from itertools import product +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from app import create_app +from src.api.main import Api +from src.main import NSController + +# Folder where request JSON files are located +REQUESTS_DIR = Path(__file__).parent / "requests" + +# Namespaces to test +NAMESPACES = ["tfs", "ixia", "e2e", "restconf"] + + +# Flag configurations covering all .env flags and their possible values +FLAG_OPTIONS = { + "DUMMY_MODE": [True, False], + "WEBUI_DEPLOY": [True, False], + "DUMP_TEMPLATES": [True, False], + "NRP_ENABLED": [True, False], + "PLANNER_ENABLED": [True, False], + "PCE_EXTERNAL": [True, False], + "PLANNER_TYPE": ["ENERGY", "HRAT", "E2E_OPTICAL"], + "UPLOAD_TYPE": ["NBI", "WEBUI"], + "TFS_L2VPN_SUPPORT": [True, False], + "SDN_CONTROLLER_TYPE": ["TFS", "IXIA"], + "DATAPLANE_SUPPORT": ["CISCO", "FRR"], + "SUBSCRIBE_ALERTS": [True, False], +} + + +def generate_flag_combinations(): + """Generates all Cartesian product combinations of configuration flags.""" + keys = list(FLAG_OPTIONS.keys()) + values = list(FLAG_OPTIONS.values()) + for prod in product(*values): + combo = dict(zip(keys, prod)) + combo.update( + { + "SUBSCRIBE_ALERTS_URL": "http://127.0.0.1:8085/alert", + "HRAT_IP": "10.0.0.1", + "E2E_OPTICAL_IP": "127.0.0.1", + "TFS_IP": "127.0.0.1", + "IXIA_IP": "127.0.0.1", + "TFS_E2E_IP": "127.0.0.1", + "RESTCONF_IP": "192.168.27.189", + "API_USERNAME": "admin", + "API_PASSWORD": "password", + } + ) + yield combo + + +class MockResponse: + """Mock response object for HTTP and controller connectors.""" + + def __init__(self, status_code=200, text="OK", json_data=None): + self.status_code = status_code + self.text = text + self.ok = status_code < 400 + self._json_data = ( + json_data + if json_data is not None + else {"success": True, "tapi-notification:output": {"subscription-id": "mock-sub-123"}} + ) + + def json(self): + return self._json_data + + def raise_for_status(self): + if not self.ok: + raise Exception(f"HTTP Error {self.status_code}") + + +@pytest.fixture(autouse=True) +def mock_external_servers(monkeypatch, tmp_path): + """ + Mock external servers (TFS, IXIA, RESTCONF, HRAT, PCE, E2E, FRR, HTTP alerts) + when DUMMY_MODE is False or external calls occur. Assumes good/successful responses. + """ + temp_templates = tmp_path / "templates" + temp_templates.mkdir(exist_ok=True) + monkeypatch.setattr("src.utils.dump_templates.TEMPLATES_PATH", str(temp_templates)) + + def mock_http_call(*args, **kwargs): + return MockResponse(200, text="OK") + + # Patch requests module methods + monkeypatch.setattr("requests.get", mock_http_call) + monkeypatch.setattr("requests.post", mock_http_call) + monkeypatch.setattr("requests.put", mock_http_call) + monkeypatch.setattr("requests.delete", mock_http_call) + monkeypatch.setattr("requests.request", mock_http_call) + + class MockSession: + def __init__(self): + self.auth = None + + def get(self, *args, **kwargs): + return MockResponse(200, text='') + + def post(self, *args, **kwargs): + return MockResponse(200, text="OK") + + def put(self, *args, **kwargs): + return MockResponse(200, text="OK") + + def delete(self, *args, **kwargs): + return MockResponse(200, text="OK") + + monkeypatch.setattr("requests.Session", MockSession) + + # Patch TFS connectors + try: + from src.realizer.tfs.helpers.tfs_connector import tfs_connector + + monkeypatch.setattr(tfs_connector, "webui_post", lambda self, *a, **kw: MockResponse(200, "OK")) + monkeypatch.setattr(tfs_connector, "nbi_post", lambda self, *a, **kw: MockResponse(200, "OK")) + monkeypatch.setattr(tfs_connector, "nbi_delete", lambda self, *a, **kw: MockResponse(200, "OK")) + monkeypatch.setattr(tfs_connector, "ipowdm_post", lambda self, *a, **kw: MockResponse(200, "OK")) + monkeypatch.setattr(tfs_connector, "ipowdm_put", lambda self, *a, **kw: MockResponse(200, "OK")) + monkeypatch.setattr(tfs_connector, "get_network_topology", lambda self, *a, **kw: ([], MockResponse(200, "OK"))) + except Exception: + pass + + try: + from src.realizer.restconf.connectors.tfs_connector import ( + tfs_connector as restconf_tfs_connector, + ) + + monkeypatch.setattr(restconf_tfs_connector, "nbi_post", lambda self, *a, **kw: MockResponse(200, "OK")) + monkeypatch.setattr(restconf_tfs_connector, "nbi_delete", lambda self, *a, **kw: MockResponse(200, "OK")) + except Exception: + pass + + # Patch IXIA controller + try: + from src.realizer.ixia.helpers.NEII_V4 import NEII_controller + + monkeypatch.setattr(NEII_controller, "nscNEII", lambda self, *a, **kw: MockResponse(200, "OK")) + except Exception: + pass + + # Patch FRR and Cisco connectors / Netmiko + try: + from src.realizer.restconf.connectors.frr_connector import frr_connector + + monkeypatch.setattr(frr_connector, "execute_commands", lambda self, commands: None) + except Exception: + pass + + try: + from src.realizer.tfs.helpers.cisco_connector import cisco_connector + + monkeypatch.setattr(cisco_connector, "execute_commands", lambda self, commands: None) + except Exception: + pass + + try: + import netmiko + + monkeypatch.setattr(netmiko, "ConnectHandler", lambda **kw: MagicMock()) + except Exception: + pass + + +@pytest.fixture +def app(temp_sqlite_db): + """Creates the Flask app with default configuration.""" + app = create_app() + return app + + +@pytest.fixture +def set_flags(app): + """Directly updates configuration flags in app.config.""" + + def _set(flags: dict): + for k, v in flags.items(): + app.config[k] = v + + return _set + + +def load_request_files(): + """Recursively loads all JSON request files from subdirectories under requests/.""" + test_cases = [] + # Search all .json files in subdirectories under requests + for f in sorted(REQUESTS_DIR.rglob("*.json")): + try: + with open(f, encoding="utf-8") as file: + json_data = json.load(file) + rel_path = f.relative_to(REQUESTS_DIR).as_posix() + test_cases.append((rel_path, json_data)) + except Exception: + pass + return test_cases + + +def generate_test_cases(): + """Generates all 6,144 flag combinations paired across all request files and namespaces.""" + requests = load_request_files() + if not requests: + return + flag_combos = list(generate_flag_combinations()) + num_reqs = len(requests) + num_ns = len(NAMESPACES) + + for i, flags in enumerate(flag_combos): + rel_path, json_data = requests[i % num_reqs] + namespace = NAMESPACES[i % num_ns] + yield (rel_path, json_data, namespace, flags) + + +@pytest.mark.parametrize( + "rel_path, json_data, namespace, flags", + list(generate_test_cases()), + ids=lambda param: ( + param if isinstance(param, str) else (param.get("PLANNER_TYPE", "") if isinstance(param, dict) else None) + ), +) +def test_add_and_delete_flow(app, rel_path, json_data, namespace, flags, set_flags, temp_sqlite_db): + with app.app_context(): + set_flags(flags) + + controller_type = namespace.upper() + controller = NSController(controller_type=controller_type) + api = Api(controller) + + # Execute creation based on namespace + if namespace in ["tfs", "ixia", "e2e"]: + data, code = api.add_flow(json_data) + elif namespace == "restconf": + data, code = api.add_network_slice_service(json_data) + else: + pytest.fail(f"Unsupported namespace: {namespace}") + + if namespace in ["tfs", "ixia", "e2e"]: + assert code in [200, 201], ( + f"Creation failed for request '{rel_path}' in namespace '{namespace}' with flags: {flags}. Response: {data}" + ) + elif namespace == "restconf": + assert code in [200, 201, 400], ( + f"Creation failed for request '{rel_path}' in namespace '{namespace}' with flags: {flags}. Response: {data}" + ) + + # Delete flow if created + if code in [200, 201]: + slice_id = None + if isinstance(data, dict): + # Check data payload for slice_id + payload_data = data.get("data") + if isinstance(payload_data, dict): + slices = payload_data.get("slices", []) + if isinstance(slices, list) and len(slices) > 0 and isinstance(slices[0], dict): + slice_id = slices[0].get("id") + + if namespace in ["tfs", "ixia", "e2e"]: + if slice_id: + _, delete_code = api.delete_flows(slice_id=slice_id) + else: + _, delete_code = api.delete_flows() + elif namespace == "restconf": + _, delete_code = api.delete_slice_services() + + assert delete_code in [200, 204, 404], f"Deletion failed for slice '{slice_id}' in namespace '{namespace}'" diff --git a/src/tests/test_initialization.py b/src/tests/test_initialization.py index 1378bd0..d32783b 100644 --- a/src/tests/test_initialization.py +++ b/src/tests/test_initialization.py @@ -88,19 +88,15 @@ def test_controller_monitoring(): slice_id = "slice-mon-1" slo_sle_template = {"slo-policy": {"metric-bound": []}} - with patch("src.main.realizer") as mock_realizer, \ - patch("src.main.mapper") as mock_mapper: + with patch("src.main.realizer") as mock_realizer, patch("src.main.mapper") as mock_mapper: mock_mapper.return_value = {"slice_id": slice_id, "is_compliant": True} res = controller.monitoring(slice_id, slo_sle_template) assert res == {"slice_id": slice_id, "is_compliant": True} mock_realizer.assert_called_once_with( - {"slice_id": slice_id, "slo_sle_template": slo_sle_template}, - action="MONITOR", - controller_type="RESTCONF" + {"slice_id": slice_id, "slo_sle_template": slo_sle_template}, action="MONITOR", controller_type="RESTCONF" ) mock_mapper.assert_called_once_with( - {"slice_id": slice_id, "slo_sle_template": slo_sle_template}, - action="MONITOR" + {"slice_id": slice_id, "slo_sle_template": slo_sle_template}, action="MONITOR" ) diff --git a/src/tests/test_mapper.py b/src/tests/test_mapper.py index f724fbe..4f7e28a 100644 --- a/src/tests/test_mapper.py +++ b/src/tests/test_mapper.py @@ -1,1042 +1,837 @@ -# Copyright 2022-2026 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. - -from unittest.mock import MagicMock, call, patch - -import pytest -from flask import Flask - -from src.mapper.main import mapper -from src.mapper.slo_viability import slo_viability - - -@pytest.fixture -def sample_ietf_intent(): - """Fixture providing sample IETF network slice intent.""" - return { - "ietf-network-slice-service:network-slice-services": { - "slice-service": [{ - "id": "slice-service-12345", - "description": "Test network slice", - "service-tags": {"tag-type": {"value": "L2VPN"}} - }], - "slo-sle-templates": { - "slo-sle-template": [{ - "id": "profile1", - "slo-policy": { - "metric-bound": [ - { - "metric-type": "one-way-bandwidth", - "metric-unit": "kbps", - "bound": 1000 - }, - { - "metric-type": "one-way-delay-maximum", - "metric-unit": "milliseconds", - "bound": 10 - } - ] - } - }] - } - } - } - - -@pytest.fixture -def sample_nrp_view(): - """Fixture providing sample NRP view.""" - return [ - { - "id": "nrp-1", - "available": True, - "slices": [], - "slos": [ - { - "metric-type": "one-way-bandwidth", - "bound": 1500 - }, - { - "metric-type": "one-way-delay-maximum", - "bound": 8 - } - ] - }, - { - "id": "nrp-2", - "available": True, - "slices": [], - "slos": [ - { - "metric-type": "one-way-bandwidth", - "bound": 500 - }, - { - "metric-type": "one-way-delay-maximum", - "bound": 15 - } - ] - }, - { - "id": "nrp-3", - "available": False, - "slices": [], - "slos": [ - { - "metric-type": "one-way-bandwidth", - "bound": 2000 - }, - { - "metric-type": "one-way-delay-maximum", - "bound": 5 - } - ] - } - ] - - -@pytest.fixture -def mock_app(): - """Fixture providing mock Flask app context.""" - app = Flask(__name__) - app.config = { - "NRP_ENABLED": False, - "PLANNER_ENABLED": False, - "SERVER_NAME": "localhost", - "APPLICATION_ROOT": "/", - "PREFERRED_URL_SCHEME": "http" - } - return app - - -@pytest.fixture -def app_context(mock_app): - """Fixture providing Flask application context.""" - with mock_app.app_context(): - yield mock_app - - -class TestSloViability: - """Tests for slo_viability function.""" - - def test_slo_viability_meets_all_requirements(self): - """Test when NRP meets all SLO requirements.""" - slice_slos = [ - { - "metric-type": "one-way-bandwidth", - "bound": 1000 - }, - { - "metric-type": "one-way-delay-maximum", - "bound": 10 - } - ] - - nrp_slos = { - "slos": [ - { - "metric-type": "one-way-bandwidth", - "bound": 1500 - }, - { - "metric-type": "one-way-delay-maximum", - "bound": 8 - } - ] - } - - viable, score = slo_viability(slice_slos, nrp_slos) - - assert viable is True - assert score > 0 - - def test_slo_viability_fails_bandwidth_minimum(self): - """Test when NRP doesn't meet minimum bandwidth requirement.""" - slice_slos = [ - { - "metric-type": "one-way-bandwidth", - "bound": 1000 - } - ] - - nrp_slos = { - "slos": [ - { - "metric-type": "one-way-bandwidth", - "bound": 500 # Less than required - } - ] - } - - viable, score = slo_viability(slice_slos, nrp_slos) - - assert viable is False - assert score == 0 - - def test_slo_viability_fails_delay_maximum(self): - """Test when NRP doesn't meet maximum delay requirement.""" - slice_slos = [ - { - "metric-type": "one-way-delay-maximum", - "bound": 10 - } - ] - - nrp_slos = { - "slos": [ - { - "metric-type": "one-way-delay-maximum", - "bound": 15 # Greater than maximum allowed - } - ] - } - - viable, score = slo_viability(slice_slos, nrp_slos) - - assert viable is False - assert score == 0 - - def test_slo_viability_multiple_metrics_partial_failure(self): - """Test when one metric fails in a multi-metric comparison.""" - slice_slos = [ - { - "metric-type": "one-way-bandwidth", - "bound": 1000 - }, - { - "metric-type": "one-way-delay-maximum", - "bound": 10 - } - ] - - nrp_slos = { - "slos": [ - { - "metric-type": "one-way-bandwidth", - "bound": 1500 # OK - }, - { - "metric-type": "one-way-delay-maximum", - "bound": 15 # NOT OK - } - ] - } - - viable, score = slo_viability(slice_slos, nrp_slos) - - assert viable is False - assert score == 0 - - def test_slo_viability_flexibility_score_calculation(self): - """Test flexibility score calculation.""" - slice_slos = [ - { - "metric-type": "one-way-bandwidth", - "bound": 1000 - } - ] - - nrp_slos = { - "slos": [ - { - "metric-type": "one-way-bandwidth", - "bound": 2000 # 100% better than requirement - } - ] - } - - viable, score = slo_viability(slice_slos, nrp_slos) - - assert viable is True - # Flexibility = (2000 - 1000) / 1000 = 1.0 - assert score == 1.0 - - def test_slo_viability_empty_slos(self): - """Test with empty SLO list.""" - slice_slos = [] - nrp_slos = {"slos": []} - - viable, score = slo_viability(slice_slos, nrp_slos) - - assert viable is True - assert score == 0 - - def test_slo_viability_no_matching_metrics(self): - """Test when there are no matching metric types.""" - slice_slos = [ - { - "metric-type": "one-way-bandwidth", - "bound": 1000 - } - ] - - nrp_slos = { - "slos": [ - { - "metric-type": "two-way-bandwidth", - "bound": 1500 - } - ] - } - - viable, score = slo_viability(slice_slos, nrp_slos) - - # Should still return True as no metrics failed - assert viable is True - assert score == 0 - - def test_slo_viability_packet_loss_maximum_type(self): - """Test packet loss as maximum constraint type.""" - slice_slos = [ - { - "metric-type": "one-way-packet-loss", - "bound": 0.01 # 1% maximum acceptable - } - ] - - nrp_slos = { - "slos": [ - { - "metric-type": "one-way-packet-loss", - "bound": 0.005 # 0.5% NRP loss - } - ] - } - - viable, score = slo_viability(slice_slos, nrp_slos) - - assert viable is True - assert score > 0 - - -class TestMapper: - """Tests for mapper function.""" - - def test_mapper_with_nrp_disabled_and_planner_disabled(self, app_context, sample_ietf_intent): - """Test mapper when both NRP and Planner are disabled.""" - app_context.config = { - "NRP_ENABLED": False, - "PLANNER_ENABLED": False - } - - payload = { - "intent": sample_ietf_intent - } - - result = mapper(payload) - - 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): - """Test mapper when Planner is enabled.""" - app_context.config = { - "NRP_ENABLED": False, - "PLANNER_ENABLED": True, - "PLANNER_TYPE":"ENERGY" - } - - mock_planner_instance = MagicMock() - mock_planner_instance.planner.return_value = {"path": "node1->node2->node3"} - mock_planner_class.return_value = mock_planner_instance - - payload = { - "intent": sample_ietf_intent - } - - result = mapper(payload) - - assert result == ([sample_ietf_intent], {"path": "node1->node2->node3"}) - mock_planner_instance.planner.assert_called_once_with(sample_ietf_intent, "ENERGY", is_update=False) - - @patch('src.mapper.main.realizer') - def test_mapper_with_nrp_enabled_finds_best_nrp(self, mock_realizer, app_context, sample_ietf_intent, sample_nrp_view): - """Test mapper with NRP enabled finds the best NRP.""" - app_context.config = { - "NRP_ENABLED": True, - "PLANNER_ENABLED": False, - } - - mock_realizer.return_value = sample_nrp_view - - payload = { - "intent": sample_ietf_intent - } - result = mapper(payload) - - # Verify realizer was called to READ NRP view - assert mock_realizer.call_args_list[0] == call(None, True, "READ") - 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): - """Test mapper when no viable NRPs are found.""" - app_context.config = { - "NRP_ENABLED": True, - "PLANNER_ENABLED": False - } - - # All NRPs are unavailable - nrp_view = [ - { - "id": "nrp-1", - "available": False, - "slices": [], - "slos": [ - { - "metric-type": "one-way-bandwidth", - "bound": 500 - } - ] - } - ] - - mock_realizer.return_value = nrp_view - - payload = { - "intent": sample_ietf_intent - } - - result = mapper(payload) - - 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): - """Test mapper creates new NRP when no suitable candidate exists.""" - app_context.config = { - "NRP_ENABLED": True, - "PLANNER_ENABLED": False - } - - # No viable NRPs - nrp_view = [] - - mock_realizer.side_effect = [nrp_view, None] # First call returns empty, second for CREATE - - payload = { - "intent": sample_ietf_intent - } - - result = mapper(payload) - - # Verify CREATE was called - create_call = [c for c in mock_realizer.call_args_list if len(c[0]) > 2 and c[0][2] == "CREATE"] - assert len(create_call) > 0 - - @patch('src.mapper.main.realizer') - def test_mapper_with_nrp_and_planner_both_enabled(self, mock_realizer, app_context, sample_ietf_intent, sample_nrp_view): - """Test mapper when both NRP and Planner are enabled.""" - app_context.config = { - "NRP_ENABLED": True, - "PLANNER_ENABLED": True, - "PLANNER_TYPE":"ENERGY" - } - - mock_realizer.return_value = sample_nrp_view - - with patch('src.mapper.main.Planner') as mock_planner_class: - mock_planner_instance = MagicMock() - mock_planner_instance.planner.return_value = {"path": "optimized_path"} - mock_planner_class.return_value = mock_planner_instance - - payload = { - "intent": sample_ietf_intent - } - result = mapper(payload) - - # Planner should be called and return the result - 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): - """Test mapper updates best NRP with new slice.""" - app_context.config = { - "NRP_ENABLED": True, - "PLANNER_ENABLED": False - } - - mock_realizer.return_value = sample_nrp_view - - payload = { - "intent": sample_ietf_intent - } - result = mapper(payload) - - # Verify UPDATE was called - update_calls = [c for c in mock_realizer.call_args_list if len(c[0]) > 2 and c[0][2] == "UPDATE"] - assert len(update_calls) > 0 - - @patch('src.mapper.main.realizer') - def test_mapper_extracts_slos_correctly(self, mock_realizer, app_context, sample_ietf_intent): - """Test that mapper correctly extracts SLOs from intent.""" - app_context.config = { - "NRP_ENABLED": True, - "PLANNER_ENABLED": False - } - - mock_realizer.return_value = [] - - payload = { - "intent": sample_ietf_intent - } - mapper(payload) - - # Verify the function processed the intent - assert mock_realizer.called - - @patch('src.mapper.main.logging') - def test_mapper_logs_debug_info(self, mock_logging, app_context, sample_ietf_intent, sample_nrp_view): - """Test mapper logs debug information.""" - app_context.config = { - "NRP_ENABLED": True, - "PLANNER_ENABLED": False - } - - with patch('src.mapper.main.realizer') as mock_realizer: - mock_realizer.return_value = sample_nrp_view - - payload = { - "intent": sample_ietf_intent - } - mapper(payload) - - # Verify debug logging was called - assert mock_logging.debug.called - - -class TestMapperIntegration: - """Integration tests for mapper functionality.""" - - def test_mapper_complete_nrp_workflow(self, app_context, sample_ietf_intent, sample_nrp_view): - """Test complete NRP mapping workflow.""" - app_context.config = { - "NRP_ENABLED": True, - "PLANNER_ENABLED": False - } - - with patch('src.mapper.main.realizer') as mock_realizer: - mock_realizer.return_value = sample_nrp_view - - payload = { - "intent": sample_ietf_intent - } - result = mapper(payload) - - # Verify the workflow sequence - assert mock_realizer.call_count >= 1 - first_call = mock_realizer.call_args_list[0] - assert first_call[0][1] is True # need_nrp parameter - assert first_call[0][2] == "READ" # READ operation - - def test_mapper_complete_planner_workflow(self, app_context, sample_ietf_intent): - """Test complete Planner workflow.""" - app_context.config = { - "NRP_ENABLED": False, - "PLANNER_ENABLED": True, - "PLANNER_TYPE":"ENERGY" - } - - expected_path = { - "path": "node1->node2->node3", - "cost": 10, - "latency": 5 - } - - with patch('src.mapper.main.Planner') as mock_planner_class: - mock_planner_instance = MagicMock() - mock_planner_instance.planner.return_value = expected_path - mock_planner_class.return_value = mock_planner_instance - - payload = { - "intent": sample_ietf_intent - } - result = mapper(payload) - - 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): - """Test mapper behavior with invalid NRP response.""" - app_context.config = { - "NRP_ENABLED": True, - "PLANNER_ENABLED": False - } - - # Invalid NRP without expected fields - invalid_nrp = { - "id": "nrp-invalid" - # Missing 'available' and 'slos' fields - } - - with patch('src.mapper.main.realizer') as mock_realizer: - mock_realizer.return_value = [invalid_nrp] - - # Should handle gracefully - try: - payload = { - "intent": sample_ietf_intent - } - result = mapper(payload) - except (KeyError, TypeError): - # Expected to fail gracefully - pass - - def test_mapper_with_missing_slos_in_intent(self, app_context): - """Test mapper behavior when intent has no SLOs.""" - app_context.config = { - "NRP_ENABLED": True, - "PLANNER_ENABLED": False - } - - invalid_intent = { - "ietf-network-slice-service:network-slice-services": { - "slice-service": [{ - "id": "slice-1" - }], - "slo-sle-templates": { - "slo-sle-template": [{ - "id": "profile1", - "slo-policy": { - # No metric-bound key - } - }] - } - } - } - - payload = { - "intent": invalid_intent - } - try: - mapper(payload) - except (KeyError, TypeError): - # Expected behavior - pass - - -class TestSloViabilityEdgeCases: - """Edge case tests for slo_viability function.""" - - def test_slo_viability_with_zero_bound(self): - """Test handling of zero bounds in SLO.""" - slice_slos = [ - { - "metric-type": "one-way-bandwidth", - "bound": 0 - } - ] - - nrp_slos = { - "slos": [ - { - "metric-type": "one-way-bandwidth", - "bound": 100 - } - ] - } - - # Should handle zero division gracefully or fail as expected - try: - viable, score = slo_viability(slice_slos, nrp_slos) - except (ZeroDivisionError, ValueError): - pass - - def test_slo_viability_with_very_large_bounds(self): - """Test handling of very large SLO bounds.""" - slice_slos = [ - { - "metric-type": "one-way-bandwidth", - "bound": 1e10 - } - ] - - nrp_slos = { - "slos": [ - { - "metric-type": "one-way-bandwidth", - "bound": 2e10 - } - ] - } - - viable, score = slo_viability(slice_slos, nrp_slos) - - assert viable is True - assert isinstance(score, (int, float)) - - def test_slo_viability_all_delay_types(self): - """Test handling of all delay metric types.""" - delay_types = [ - "one-way-delay-maximum", - "two-way-delay-maximum", - "one-way-delay-percentile", - "two-way-delay-percentile", - "one-way-delay-variation-maximum", - "two-way-delay-variation-maximum" - ] - - for delay_type in delay_types: - slice_slos = [{"metric-type": delay_type, "bound": 10}] - nrp_slos = {"slos": [{"metric-type": delay_type, "bound": 8}]} - - viable, score = slo_viability(slice_slos, nrp_slos) - - assert viable is True - assert score >= 0 - - -class TestMapperSubmodules: - """Tests for mapper submodules: extract_sdp_info, process_connectivity, aggregate_monitoring, get_service_template.""" - - def test_extract_sdp_info(self): - from src.mapper.extract_sdp_info import extract_sdp_info - - slice_service = { - "sdps": { - "sdp": [ - { - "id": "sdp-1", - "sdp-ip-address": "10.0.0.1", - "service-match-criteria": { - "match-criterion": [{"target-connection-group-id": "cg-1"}] - } - } - ] - } - } - sdp, mc = extract_sdp_info("sdp-1", slice_service, "cg-1", None) - assert sdp["id"] == "sdp-1" - assert mc is not None - - def test_process_connectivity(self): - from src.mapper.process_connnectivity import process_connectivity - - slice_service = { - "sdps": { - "sdp": [ - {"id": "sdp-1", "sdp-ip-address": "10.0.0.1"}, - {"id": "sdp-2", "sdp-ip-address": "10.0.0.2"} - ] - } - } - conn_construct = {"a2a-sdp": ["sdp-1", "sdp-2"]} - res = process_connectivity("cg-1", "ietf-vpn-common:any-to-any", conn_construct, "cc-1", slice_service) - assert isinstance(res, list) - assert len(res) == 2 - - def test_aggregate_monitoring(self, flask_app): - from src.mapper.aggregate_monitoring import aggregate_monitoring - - flask_app.config["TELEMETRY_CACHE"] = { - "slice-1": { - "A-B": {"bandwidth": 1000, "latency": 5} - } - } - slo_template = { - "slo-policy": { - "metric-bound": [ - {"metric-type": "ietf-network-slice-service:two-way-bandwidth", "bound": 500}, - {"metric-type": "ietf-network-slice-service:two-way-delay-maximum", "bound": 10} - ] - } - } - with flask_app.app_context(), patch("src.mapper.aggregate_monitoring.upsert_telemetry"): - aggregate_monitoring("slice-1", slo_template) - - def test_get_service_template_and_get_template(self): - from src.mapper.get_service_template import get_service_template - from src.mapper.get_template import get_template - - available = [{"id": "tmpl-1", "slo-policy": {}}] - elem = {"slo-sle-template": "tmpl-1"} - res = get_service_template(elem, available) - assert res == {"id": "tmpl-1", "slo-policy": {}} - - tmpl = get_template("tmpl-1", available) - assert tmpl["id"] == "tmpl-1" - - def test_process_connectivity_hub_spoke_and_p2p(self): - from src.mapper.process_connnectivity import process_connectivity - - slice_service = { - "sdps": { - "sdp": [ - {"id": "sdp-1", "sdp-ip-address": "10.0.0.1"}, - {"id": "sdp-2", "sdp-ip-address": "10.0.0.2"} - ] - } - } - - # Hub-Spoke - hs_construct = {"p2mp-sender-sdp": "sdp-1", "p2mp-receiver-sdp": ["sdp-2"]} - res_hs = process_connectivity("cg-1", "ietf-vpn-common:hub-spoke", hs_construct, "cc-1", slice_service) - assert len(res_hs) == 2 - assert res_hs[0]["type"] == "sender" - assert res_hs[1]["type"] == "receiver" - - # Point-to-Point - p2p_construct = {"p2p-sender-sdp": "sdp-1", "p2p-receiver-sdp": "sdp-2"} - res_p2p = process_connectivity("cg-1", "point-to-point", p2p_construct, "cc-1", slice_service) - assert len(res_p2p) == 2 - assert res_p2p[0]["type"] == "sender" - assert res_p2p[1]["type"] == "receiver" - - -class TestMapperMonitoring: - """Comprehensive tests for MONITOR action in mapper and aggregate_monitoring.""" - - def test_mapper_monitor_action(self, flask_app): - payload = { - "slice_id": "slice-mon-100", - "slo_sle_template": { - "slo-policy": { - "metric-bound": [ - {"metric-type": "ietf-network-slice-service:two-way-bandwidth", "bound": 100}, - {"metric-type": "ietf-network-slice-service:two-way-delay-maximum", "bound": 20} - ] - } - } - } - flask_app.config["TELEMETRY_CACHE"]["slice-mon-100"] = { - "link-1": {"bandwidth": 150, "latency": 15} - } - - with flask_app.app_context(), \ - patch("src.mapper.aggregate_monitoring.upsert_telemetry") as mock_upsert: - metrics = mapper(payload, action="MONITOR") - assert metrics["slice_id"] == "slice-mon-100" - assert metrics["slo_sle_compliance"]["is_compliant"] is True - assert metrics["slo_sle_compliance"]["violated_metrics"] == [] - mock_upsert.assert_called_once() - - def test_aggregate_monitoring_latency_and_bandwidth_violations(self, flask_app): - from src.mapper.aggregate_monitoring import aggregate_monitoring - - slice_id = "slice-mon-violations" - slo_template = { - "slo-policy": { - "metric-bound": [ - {"metric-type": "ietf-network-slice-service:two-way-bandwidth", "bound": 500}, - {"metric-type": "ietf-network-slice-service:two-way-delay-maximum", "bound": 10} - ] - } - } - - # Case 1: Latency violation only - flask_app.config["TELEMETRY_CACHE"][slice_id] = { - "link-1": {"bandwidth": 600, "latency": 15} - } - with flask_app.app_context(), patch("src.mapper.aggregate_monitoring.upsert_telemetry"): - res_lat = aggregate_monitoring(slice_id, slo_template) - assert res_lat["slo_sle_compliance"]["is_compliant"] is False - assert res_lat["slo_sle_compliance"]["violated_metrics"] == ["latency"] - - # Case 2: Bandwidth violation only - flask_app.config["TELEMETRY_CACHE"][slice_id] = { - "link-1": {"bandwidth": 400, "latency": 5} - } - with flask_app.app_context(), patch("src.mapper.aggregate_monitoring.upsert_telemetry"): - res_bw = aggregate_monitoring(slice_id, slo_template) - assert res_bw["slo_sle_compliance"]["is_compliant"] is False - assert res_bw["slo_sle_compliance"]["violated_metrics"] == ["bandwidth"] - - # Case 3: Both violated - flask_app.config["TELEMETRY_CACHE"][slice_id] = { - "link-1": {"bandwidth": 300, "latency": 25} - } - with flask_app.app_context(), patch("src.mapper.aggregate_monitoring.upsert_telemetry"): - res_both = aggregate_monitoring(slice_id, slo_template) - assert res_both["slo_sle_compliance"]["is_compliant"] is False - assert set(res_both["slo_sle_compliance"]["violated_metrics"]) == {"latency", "bandwidth"} - - def test_aggregate_monitoring_empty_links_error(self, flask_app): - from src.mapper.aggregate_monitoring import aggregate_monitoring - - flask_app.config["TELEMETRY_CACHE"]["slice-empty"] = {} - with flask_app.app_context(): - with pytest.raises(Exception, match="No telemetry data available for slice"): - aggregate_monitoring("slice-empty", {}) - - def test_aggregate_monitoring_cache_not_initialized_error(self, flask_app): - from src.mapper.aggregate_monitoring import aggregate_monitoring - - flask_app.config["TELEMETRY_CACHE"].pop("slice-missing", None) - with flask_app.app_context(): - with pytest.raises(Exception, match="Telemetry cache for slice .* is not initialized"): - aggregate_monitoring("slice-missing", {}) - - def test_mapper_connection_groups_and_template_overrides(self, flask_app): - from src.mapper.main import mapper - - flask_app.config["DUMMY_MODE"] = False - - intent_full = { - "ietf-network-slice-service:network-slice-services": { - "slo-sle-templates": { - "slo-sle-template": [ - {"id": "service-tmpl", "bound": 10}, - {"id": "group-tmpl", "bound": 20}, - {"id": "construct-tmpl", "bound": 30} - ] - }, - "slice-service": [ - { - "id": "slice-full-1", - "slo-sle-template": "service-tmpl", - "service-tags": { - "tag-type": { - "ietf-network-slice-service:service": { - "tag-type-value": ["L2"] - } - } - }, - "sdps": { - "sdp": [ - {"id": "sdp-1", "node-id": "N1"}, - {"id": "sdp-2", "node-id": "N2"} - ] - }, - "connection-groups": { - "connection-group": [ - { - "id": "cg-1", - "slo-sle-template": "group-tmpl", - "connectivity-type": "point-to-point", - "connectivity-construct": [ - { - "id": "cc-1", - "slo-sle-template": "construct-tmpl", - "p2p-sender-sdp": "sdp-1", - "p2p-receiver-sdp": "sdp-2" - }, - { - "id": "cc-2", - "p2p-sender-sdp": "sdp-1", - "p2p-receiver-sdp": "sdp-2" - } - ] - } - ] - } - } - ] - } - } - - payload = {"intent": intent_full, "is_update": False} - - with flask_app.app_context(), \ - patch("src.mapper.main.get_data_store", return_value=None), \ - patch("src.mapper.main.normalize_libyang_data", side_effect=lambda x: x), \ - patch("src.mapper.main.save_data") as mock_save_data: - services, rules = mapper(payload, controller_type="RESTCONF") - assert len(services) == 1 - assert services[0]["id"] == "slice-full-1-cg-1-cc-1" - assert services[0]["template"] == {"id": "construct-tmpl", "bound": 30} - assert services[0]["connectivity_type"] == "point-to-point" - mock_save_data.assert_called_once() - - def test_mapper_non_p2p_and_group_template_inheritance(self, flask_app): - from src.mapper.main import mapper - - flask_app.config["DUMMY_MODE"] = True - - intent_non_p2p = { - "ietf-network-slice-service:network-slice-services": { - "slo-sle-templates": { - "slo-sle-template": [ - {"id": "service-tmpl", "bound": 10}, - {"id": "group-tmpl", "bound": 20} - ] - }, - "slice-service": [ - { - "id": "slice-non-p2p", - "slo-sle-template": "service-tmpl", - "sdps": { - "sdp": [ - {"id": "sdp-1", "node-id": "N1"}, - {"id": "sdp-2", "node-id": "N2"} - ] - }, - "connection-groups": { - "connection-group": [ - { - "id": "cg-1", - "slo-sle-template": "group-tmpl", - "connectivity-type": "ietf-vpn-common:any-to-any", - "connectivity-construct": [ - { - "id": "cc-1", - "a2a-sdp": ["sdp-1", "sdp-2"] - }, - { - "id": "cc-2", - "a2a-sdp": ["sdp-1", "sdp-2"] - } - ] - } - ] - } - } - ] - } - } - - payload = {"intent": intent_non_p2p} - - with flask_app.app_context(), \ - patch("src.mapper.main.get_data_store", return_value=None), \ - patch("src.mapper.main.normalize_libyang_data", side_effect=lambda x: x): - services, _ = mapper(payload, controller_type="RESTCONF") - assert len(services) == 2 - assert services[0]["template"] == {"id": "group-tmpl", "bound": 20} - assert services[1]["template"] == {"id": "group-tmpl", "bound": 20} - - def test_mapper_empty_sdps_skipped(self, flask_app): - from src.mapper.main import mapper - - flask_app.config["DUMMY_MODE"] = True - - intent_empty_sdp = { - "ietf-network-slice-service:network-slice-services": { - "slice-service": [ - { - "id": "slice-empty-sdps", - "connection-groups": { - "connection-group": [ - { - "id": "cg-1", - "connectivity-type": "point-to-point", - "connectivity-construct": [ - {"id": "cc-1"} - ] - } - ] - } - } - ] - } - } - - payload = {"intent": intent_empty_sdp} - - with flask_app.app_context(), \ - patch("src.mapper.main.get_data_store", return_value=None), \ - patch("src.mapper.main.process_connectivity", return_value=[]): - services, _ = mapper(payload, controller_type="RESTCONF") - assert services == [] \ No newline at end of file +# Copyright 2022-2026 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. + +from unittest.mock import MagicMock, call, patch + +import pytest +from flask import Flask + +from src.mapper.main import mapper +from src.mapper.slo_viability import slo_viability + + +@pytest.fixture +def sample_ietf_intent(): + """Fixture providing sample IETF network slice intent.""" + return { + "ietf-network-slice-service:network-slice-services": { + "slice-service": [ + { + "id": "slice-service-12345", + "description": "Test network slice", + "service-tags": {"tag-type": {"value": "L2VPN"}}, + } + ], + "slo-sle-templates": { + "slo-sle-template": [ + { + "id": "profile1", + "slo-policy": { + "metric-bound": [ + {"metric-type": "one-way-bandwidth", "metric-unit": "kbps", "bound": 1000}, + {"metric-type": "one-way-delay-maximum", "metric-unit": "milliseconds", "bound": 10}, + ] + }, + } + ] + }, + } + } + + +@pytest.fixture +def sample_nrp_view(): + """Fixture providing sample NRP view.""" + return [ + { + "id": "nrp-1", + "available": True, + "slices": [], + "slos": [ + {"metric-type": "one-way-bandwidth", "bound": 1500}, + {"metric-type": "one-way-delay-maximum", "bound": 8}, + ], + }, + { + "id": "nrp-2", + "available": True, + "slices": [], + "slos": [ + {"metric-type": "one-way-bandwidth", "bound": 500}, + {"metric-type": "one-way-delay-maximum", "bound": 15}, + ], + }, + { + "id": "nrp-3", + "available": False, + "slices": [], + "slos": [ + {"metric-type": "one-way-bandwidth", "bound": 2000}, + {"metric-type": "one-way-delay-maximum", "bound": 5}, + ], + }, + ] + + +@pytest.fixture +def mock_app(): + """Fixture providing mock Flask app context.""" + app = Flask(__name__) + app.config = { + "NRP_ENABLED": False, + "PLANNER_ENABLED": False, + "SERVER_NAME": "localhost", + "APPLICATION_ROOT": "/", + "PREFERRED_URL_SCHEME": "http", + } + return app + + +@pytest.fixture +def app_context(mock_app): + """Fixture providing Flask application context.""" + with mock_app.app_context(): + yield mock_app + + +class TestSloViability: + """Tests for slo_viability function.""" + + def test_slo_viability_meets_all_requirements(self): + """Test when NRP meets all SLO requirements.""" + slice_slos = [ + {"metric-type": "one-way-bandwidth", "bound": 1000}, + {"metric-type": "one-way-delay-maximum", "bound": 10}, + ] + + nrp_slos = { + "slos": [ + {"metric-type": "one-way-bandwidth", "bound": 1500}, + {"metric-type": "one-way-delay-maximum", "bound": 8}, + ] + } + + viable, score = slo_viability(slice_slos, nrp_slos) + + assert viable is True + assert score > 0 + + def test_slo_viability_fails_bandwidth_minimum(self): + """Test when NRP doesn't meet minimum bandwidth requirement.""" + slice_slos = [{"metric-type": "one-way-bandwidth", "bound": 1000}] + + nrp_slos = { + "slos": [ + { + "metric-type": "one-way-bandwidth", + "bound": 500, # Less than required + } + ] + } + + viable, score = slo_viability(slice_slos, nrp_slos) + + assert viable is False + assert score == 0 + + def test_slo_viability_fails_delay_maximum(self): + """Test when NRP doesn't meet maximum delay requirement.""" + slice_slos = [{"metric-type": "one-way-delay-maximum", "bound": 10}] + + nrp_slos = { + "slos": [ + { + "metric-type": "one-way-delay-maximum", + "bound": 15, # Greater than maximum allowed + } + ] + } + + viable, score = slo_viability(slice_slos, nrp_slos) + + assert viable is False + assert score == 0 + + def test_slo_viability_multiple_metrics_partial_failure(self): + """Test when one metric fails in a multi-metric comparison.""" + slice_slos = [ + {"metric-type": "one-way-bandwidth", "bound": 1000}, + {"metric-type": "one-way-delay-maximum", "bound": 10}, + ] + + nrp_slos = { + "slos": [ + { + "metric-type": "one-way-bandwidth", + "bound": 1500, # OK + }, + { + "metric-type": "one-way-delay-maximum", + "bound": 15, # NOT OK + }, + ] + } + + viable, score = slo_viability(slice_slos, nrp_slos) + + assert viable is False + assert score == 0 + + def test_slo_viability_flexibility_score_calculation(self): + """Test flexibility score calculation.""" + slice_slos = [{"metric-type": "one-way-bandwidth", "bound": 1000}] + + nrp_slos = { + "slos": [ + { + "metric-type": "one-way-bandwidth", + "bound": 2000, # 100% better than requirement + } + ] + } + + viable, score = slo_viability(slice_slos, nrp_slos) + + assert viable is True + # Flexibility = (2000 - 1000) / 1000 = 1.0 + assert score == 1.0 + + def test_slo_viability_empty_slos(self): + """Test with empty SLO list.""" + slice_slos = [] + nrp_slos = {"slos": []} + + viable, score = slo_viability(slice_slos, nrp_slos) + + assert viable is True + assert score == 0 + + def test_slo_viability_no_matching_metrics(self): + """Test when there are no matching metric types.""" + slice_slos = [{"metric-type": "one-way-bandwidth", "bound": 1000}] + + nrp_slos = {"slos": [{"metric-type": "two-way-bandwidth", "bound": 1500}]} + + viable, score = slo_viability(slice_slos, nrp_slos) + + # Should still return True as no metrics failed + assert viable is True + assert score == 0 + + def test_slo_viability_packet_loss_maximum_type(self): + """Test packet loss as maximum constraint type.""" + slice_slos = [ + { + "metric-type": "one-way-packet-loss", + "bound": 0.01, # 1% maximum acceptable + } + ] + + nrp_slos = { + "slos": [ + { + "metric-type": "one-way-packet-loss", + "bound": 0.005, # 0.5% NRP loss + } + ] + } + + viable, score = slo_viability(slice_slos, nrp_slos) + + assert viable is True + assert score > 0 + + +class TestMapper: + """Tests for mapper function.""" + + def test_mapper_with_nrp_disabled_and_planner_disabled(self, app_context, sample_ietf_intent): + """Test mapper when both NRP and Planner are disabled.""" + app_context.config = {"NRP_ENABLED": False, "PLANNER_ENABLED": False} + + payload = {"intent": sample_ietf_intent} + + result = mapper(payload) + + 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): + """Test mapper when Planner is enabled.""" + app_context.config = {"NRP_ENABLED": False, "PLANNER_ENABLED": True, "PLANNER_TYPE": "ENERGY"} + + mock_planner_instance = MagicMock() + mock_planner_instance.planner.return_value = {"path": "node1->node2->node3"} + mock_planner_class.return_value = mock_planner_instance + + payload = {"intent": sample_ietf_intent} + + result = mapper(payload) + + assert result == ([sample_ietf_intent], {"path": "node1->node2->node3"}) + mock_planner_instance.planner.assert_called_once_with(sample_ietf_intent, "ENERGY", is_update=False) + + @patch("src.mapper.main.realizer") + def test_mapper_with_nrp_enabled_finds_best_nrp( + self, mock_realizer, app_context, sample_ietf_intent, sample_nrp_view + ): + """Test mapper with NRP enabled finds the best NRP.""" + app_context.config = { + "NRP_ENABLED": True, + "PLANNER_ENABLED": False, + } + + mock_realizer.return_value = sample_nrp_view + + payload = {"intent": sample_ietf_intent} + result = mapper(payload) + + # Verify realizer was called to READ NRP view + assert mock_realizer.call_args_list[0] == call(None, True, "READ") + 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): + """Test mapper when no viable NRPs are found.""" + app_context.config = {"NRP_ENABLED": True, "PLANNER_ENABLED": False} + + # All NRPs are unavailable + nrp_view = [ + { + "id": "nrp-1", + "available": False, + "slices": [], + "slos": [{"metric-type": "one-way-bandwidth", "bound": 500}], + } + ] + + mock_realizer.return_value = nrp_view + + payload = {"intent": sample_ietf_intent} + + result = mapper(payload) + + 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): + """Test mapper creates new NRP when no suitable candidate exists.""" + app_context.config = {"NRP_ENABLED": True, "PLANNER_ENABLED": False} + + # No viable NRPs + nrp_view = [] + + mock_realizer.side_effect = [nrp_view, None] # First call returns empty, second for CREATE + + payload = {"intent": sample_ietf_intent} + + result = mapper(payload) + + # Verify CREATE was called + create_call = [c for c in mock_realizer.call_args_list if len(c[0]) > 2 and c[0][2] == "CREATE"] + assert len(create_call) > 0 + + @patch("src.mapper.main.realizer") + def test_mapper_with_nrp_and_planner_both_enabled( + self, mock_realizer, app_context, sample_ietf_intent, sample_nrp_view + ): + """Test mapper when both NRP and Planner are enabled.""" + app_context.config = {"NRP_ENABLED": True, "PLANNER_ENABLED": True, "PLANNER_TYPE": "ENERGY"} + + mock_realizer.return_value = sample_nrp_view + + with patch("src.mapper.main.Planner") as mock_planner_class: + mock_planner_instance = MagicMock() + mock_planner_instance.planner.return_value = {"path": "optimized_path"} + mock_planner_class.return_value = mock_planner_instance + + payload = {"intent": sample_ietf_intent} + result = mapper(payload) + + # Planner should be called and return the result + 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): + """Test mapper updates best NRP with new slice.""" + app_context.config = {"NRP_ENABLED": True, "PLANNER_ENABLED": False} + + mock_realizer.return_value = sample_nrp_view + + payload = {"intent": sample_ietf_intent} + result = mapper(payload) + + # Verify UPDATE was called + update_calls = [c for c in mock_realizer.call_args_list if len(c[0]) > 2 and c[0][2] == "UPDATE"] + assert len(update_calls) > 0 + + @patch("src.mapper.main.realizer") + def test_mapper_extracts_slos_correctly(self, mock_realizer, app_context, sample_ietf_intent): + """Test that mapper correctly extracts SLOs from intent.""" + app_context.config = {"NRP_ENABLED": True, "PLANNER_ENABLED": False} + + mock_realizer.return_value = [] + + payload = {"intent": sample_ietf_intent} + mapper(payload) + + # Verify the function processed the intent + assert mock_realizer.called + + @patch("src.mapper.main.logging") + def test_mapper_logs_debug_info(self, mock_logging, app_context, sample_ietf_intent, sample_nrp_view): + """Test mapper logs debug information.""" + app_context.config = {"NRP_ENABLED": True, "PLANNER_ENABLED": False} + + with patch("src.mapper.main.realizer") as mock_realizer: + mock_realizer.return_value = sample_nrp_view + + payload = {"intent": sample_ietf_intent} + mapper(payload) + + # Verify debug logging was called + assert mock_logging.debug.called + + +class TestMapperIntegration: + """Integration tests for mapper functionality.""" + + def test_mapper_complete_nrp_workflow(self, app_context, sample_ietf_intent, sample_nrp_view): + """Test complete NRP mapping workflow.""" + app_context.config = {"NRP_ENABLED": True, "PLANNER_ENABLED": False} + + with patch("src.mapper.main.realizer") as mock_realizer: + mock_realizer.return_value = sample_nrp_view + + payload = {"intent": sample_ietf_intent} + result = mapper(payload) + + # Verify the workflow sequence + assert mock_realizer.call_count >= 1 + first_call = mock_realizer.call_args_list[0] + assert first_call[0][1] is True # need_nrp parameter + assert first_call[0][2] == "READ" # READ operation + + def test_mapper_complete_planner_workflow(self, app_context, sample_ietf_intent): + """Test complete Planner workflow.""" + app_context.config = {"NRP_ENABLED": False, "PLANNER_ENABLED": True, "PLANNER_TYPE": "ENERGY"} + + expected_path = {"path": "node1->node2->node3", "cost": 10, "latency": 5} + + with patch("src.mapper.main.Planner") as mock_planner_class: + mock_planner_instance = MagicMock() + mock_planner_instance.planner.return_value = expected_path + mock_planner_class.return_value = mock_planner_instance + + payload = {"intent": sample_ietf_intent} + result = mapper(payload) + + 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): + """Test mapper behavior with invalid NRP response.""" + app_context.config = {"NRP_ENABLED": True, "PLANNER_ENABLED": False} + + # Invalid NRP without expected fields + invalid_nrp = { + "id": "nrp-invalid" + # Missing 'available' and 'slos' fields + } + + with patch("src.mapper.main.realizer") as mock_realizer: + mock_realizer.return_value = [invalid_nrp] + + # Should handle gracefully + try: + payload = {"intent": sample_ietf_intent} + result = mapper(payload) + except (KeyError, TypeError): + # Expected to fail gracefully + pass + + def test_mapper_with_missing_slos_in_intent(self, app_context): + """Test mapper behavior when intent has no SLOs.""" + app_context.config = {"NRP_ENABLED": True, "PLANNER_ENABLED": False} + + invalid_intent = { + "ietf-network-slice-service:network-slice-services": { + "slice-service": [{"id": "slice-1"}], + "slo-sle-templates": { + "slo-sle-template": [ + { + "id": "profile1", + "slo-policy": { + # No metric-bound key + }, + } + ] + }, + } + } + + payload = {"intent": invalid_intent} + try: + mapper(payload) + except (KeyError, TypeError): + # Expected behavior + pass + + +class TestSloViabilityEdgeCases: + """Edge case tests for slo_viability function.""" + + def test_slo_viability_with_zero_bound(self): + """Test handling of zero bounds in SLO.""" + slice_slos = [{"metric-type": "one-way-bandwidth", "bound": 0}] + + nrp_slos = {"slos": [{"metric-type": "one-way-bandwidth", "bound": 100}]} + + # Should handle zero division gracefully or fail as expected + try: + viable, score = slo_viability(slice_slos, nrp_slos) + except (ZeroDivisionError, ValueError): + pass + + def test_slo_viability_with_very_large_bounds(self): + """Test handling of very large SLO bounds.""" + slice_slos = [{"metric-type": "one-way-bandwidth", "bound": 1e10}] + + nrp_slos = {"slos": [{"metric-type": "one-way-bandwidth", "bound": 2e10}]} + + viable, score = slo_viability(slice_slos, nrp_slos) + + assert viable is True + assert isinstance(score, (int, float)) + + def test_slo_viability_all_delay_types(self): + """Test handling of all delay metric types.""" + delay_types = [ + "one-way-delay-maximum", + "two-way-delay-maximum", + "one-way-delay-percentile", + "two-way-delay-percentile", + "one-way-delay-variation-maximum", + "two-way-delay-variation-maximum", + ] + + for delay_type in delay_types: + slice_slos = [{"metric-type": delay_type, "bound": 10}] + nrp_slos = {"slos": [{"metric-type": delay_type, "bound": 8}]} + + viable, score = slo_viability(slice_slos, nrp_slos) + + assert viable is True + assert score >= 0 + + +class TestMapperSubmodules: + """Tests for mapper submodules: extract_sdp_info, process_connectivity, aggregate_monitoring, get_service_template.""" + + def test_extract_sdp_info(self): + from src.mapper.extract_sdp_info import extract_sdp_info + + slice_service = { + "sdps": { + "sdp": [ + { + "id": "sdp-1", + "sdp-ip-address": "10.0.0.1", + "service-match-criteria": {"match-criterion": [{"target-connection-group-id": "cg-1"}]}, + } + ] + } + } + sdp, mc = extract_sdp_info("sdp-1", slice_service, "cg-1", None) + assert sdp["id"] == "sdp-1" + assert mc is not None + + def test_process_connectivity(self): + from src.mapper.process_connnectivity import process_connectivity + + slice_service = { + "sdps": { + "sdp": [{"id": "sdp-1", "sdp-ip-address": "10.0.0.1"}, {"id": "sdp-2", "sdp-ip-address": "10.0.0.2"}] + } + } + conn_construct = {"a2a-sdp": ["sdp-1", "sdp-2"]} + res = process_connectivity("cg-1", "ietf-vpn-common:any-to-any", conn_construct, "cc-1", slice_service) + assert isinstance(res, list) + assert len(res) == 2 + + def test_aggregate_monitoring(self, flask_app): + from src.mapper.aggregate_monitoring import aggregate_monitoring + + flask_app.config["TELEMETRY_CACHE"] = {"slice-1": {"A-B": {"bandwidth": 1000, "latency": 5}}} + slo_template = { + "slo-policy": { + "metric-bound": [ + {"metric-type": "ietf-network-slice-service:two-way-bandwidth", "bound": 500}, + {"metric-type": "ietf-network-slice-service:two-way-delay-maximum", "bound": 10}, + ] + } + } + with flask_app.app_context(), patch("src.mapper.aggregate_monitoring.upsert_telemetry"): + aggregate_monitoring("slice-1", slo_template) + + def test_get_service_template_and_get_template(self): + from src.mapper.get_service_template import get_service_template + from src.mapper.get_template import get_template + + available = [{"id": "tmpl-1", "slo-policy": {}}] + elem = {"slo-sle-template": "tmpl-1"} + res = get_service_template(elem, available) + assert res == {"id": "tmpl-1", "slo-policy": {}} + + tmpl = get_template("tmpl-1", available) + assert tmpl["id"] == "tmpl-1" + + def test_process_connectivity_hub_spoke_and_p2p(self): + from src.mapper.process_connnectivity import process_connectivity + + slice_service = { + "sdps": { + "sdp": [{"id": "sdp-1", "sdp-ip-address": "10.0.0.1"}, {"id": "sdp-2", "sdp-ip-address": "10.0.0.2"}] + } + } + + # Hub-Spoke + hs_construct = {"p2mp-sender-sdp": "sdp-1", "p2mp-receiver-sdp": ["sdp-2"]} + res_hs = process_connectivity("cg-1", "ietf-vpn-common:hub-spoke", hs_construct, "cc-1", slice_service) + assert len(res_hs) == 2 + assert res_hs[0]["type"] == "sender" + assert res_hs[1]["type"] == "receiver" + + # Point-to-Point + p2p_construct = {"p2p-sender-sdp": "sdp-1", "p2p-receiver-sdp": "sdp-2"} + res_p2p = process_connectivity("cg-1", "point-to-point", p2p_construct, "cc-1", slice_service) + assert len(res_p2p) == 2 + assert res_p2p[0]["type"] == "sender" + assert res_p2p[1]["type"] == "receiver" + + +class TestMapperMonitoring: + """Comprehensive tests for MONITOR action in mapper and aggregate_monitoring.""" + + def test_mapper_monitor_action(self, flask_app): + payload = { + "slice_id": "slice-mon-100", + "slo_sle_template": { + "slo-policy": { + "metric-bound": [ + {"metric-type": "ietf-network-slice-service:two-way-bandwidth", "bound": 100}, + {"metric-type": "ietf-network-slice-service:two-way-delay-maximum", "bound": 20}, + ] + } + }, + } + flask_app.config["TELEMETRY_CACHE"]["slice-mon-100"] = {"link-1": {"bandwidth": 150, "latency": 15}} + + with flask_app.app_context(), patch("src.mapper.aggregate_monitoring.upsert_telemetry") as mock_upsert: + metrics = mapper(payload, action="MONITOR") + assert metrics["slice_id"] == "slice-mon-100" + assert metrics["slo_sle_compliance"]["is_compliant"] is True + assert metrics["slo_sle_compliance"]["violated_metrics"] == [] + mock_upsert.assert_called_once() + + def test_aggregate_monitoring_latency_and_bandwidth_violations(self, flask_app): + from src.mapper.aggregate_monitoring import aggregate_monitoring + + slice_id = "slice-mon-violations" + slo_template = { + "slo-policy": { + "metric-bound": [ + {"metric-type": "ietf-network-slice-service:two-way-bandwidth", "bound": 500}, + {"metric-type": "ietf-network-slice-service:two-way-delay-maximum", "bound": 10}, + ] + } + } + + # Case 1: Latency violation only + flask_app.config["TELEMETRY_CACHE"][slice_id] = {"link-1": {"bandwidth": 600, "latency": 15}} + with flask_app.app_context(), patch("src.mapper.aggregate_monitoring.upsert_telemetry"): + res_lat = aggregate_monitoring(slice_id, slo_template) + assert res_lat["slo_sle_compliance"]["is_compliant"] is False + assert res_lat["slo_sle_compliance"]["violated_metrics"] == ["latency"] + + # Case 2: Bandwidth violation only + flask_app.config["TELEMETRY_CACHE"][slice_id] = {"link-1": {"bandwidth": 400, "latency": 5}} + with flask_app.app_context(), patch("src.mapper.aggregate_monitoring.upsert_telemetry"): + res_bw = aggregate_monitoring(slice_id, slo_template) + assert res_bw["slo_sle_compliance"]["is_compliant"] is False + assert res_bw["slo_sle_compliance"]["violated_metrics"] == ["bandwidth"] + + # Case 3: Both violated + flask_app.config["TELEMETRY_CACHE"][slice_id] = {"link-1": {"bandwidth": 300, "latency": 25}} + with flask_app.app_context(), patch("src.mapper.aggregate_monitoring.upsert_telemetry"): + res_both = aggregate_monitoring(slice_id, slo_template) + assert res_both["slo_sle_compliance"]["is_compliant"] is False + assert set(res_both["slo_sle_compliance"]["violated_metrics"]) == {"latency", "bandwidth"} + + def test_aggregate_monitoring_empty_links_error(self, flask_app): + from src.mapper.aggregate_monitoring import aggregate_monitoring + + flask_app.config["TELEMETRY_CACHE"]["slice-empty"] = {} + with flask_app.app_context(): + with pytest.raises(Exception, match="No telemetry data available for slice"): + aggregate_monitoring("slice-empty", {}) + + def test_aggregate_monitoring_cache_not_initialized_error(self, flask_app): + from src.mapper.aggregate_monitoring import aggregate_monitoring + + flask_app.config["TELEMETRY_CACHE"].pop("slice-missing", None) + with flask_app.app_context(): + with pytest.raises(Exception, match="Telemetry cache for slice .* is not initialized"): + aggregate_monitoring("slice-missing", {}) + + def test_mapper_connection_groups_and_template_overrides(self, flask_app): + from src.mapper.main import mapper + + flask_app.config["DUMMY_MODE"] = False + + intent_full = { + "ietf-network-slice-service:network-slice-services": { + "slo-sle-templates": { + "slo-sle-template": [ + {"id": "service-tmpl", "bound": 10}, + {"id": "group-tmpl", "bound": 20}, + {"id": "construct-tmpl", "bound": 30}, + ] + }, + "slice-service": [ + { + "id": "slice-full-1", + "slo-sle-template": "service-tmpl", + "service-tags": { + "tag-type": {"ietf-network-slice-service:service": {"tag-type-value": ["L2"]}} + }, + "sdps": {"sdp": [{"id": "sdp-1", "node-id": "N1"}, {"id": "sdp-2", "node-id": "N2"}]}, + "connection-groups": { + "connection-group": [ + { + "id": "cg-1", + "slo-sle-template": "group-tmpl", + "connectivity-type": "point-to-point", + "connectivity-construct": [ + { + "id": "cc-1", + "slo-sle-template": "construct-tmpl", + "p2p-sender-sdp": "sdp-1", + "p2p-receiver-sdp": "sdp-2", + }, + {"id": "cc-2", "p2p-sender-sdp": "sdp-1", "p2p-receiver-sdp": "sdp-2"}, + ], + } + ] + }, + } + ], + } + } + + payload = {"intent": intent_full, "is_update": False} + + with ( + flask_app.app_context(), + patch("src.mapper.main.get_data_store", return_value=None), + patch("src.mapper.main.normalize_libyang_data", side_effect=lambda x: x), + patch("src.mapper.main.save_data") as mock_save_data, + ): + services, rules = mapper(payload, controller_type="RESTCONF") + assert len(services) == 1 + assert services[0]["id"] == "slice-full-1-cg-1-cc-1" + assert services[0]["template"] == {"id": "construct-tmpl", "bound": 30} + assert services[0]["connectivity_type"] == "point-to-point" + mock_save_data.assert_called_once() + + def test_mapper_non_p2p_and_group_template_inheritance(self, flask_app): + from src.mapper.main import mapper + + flask_app.config["DUMMY_MODE"] = True + + intent_non_p2p = { + "ietf-network-slice-service:network-slice-services": { + "slo-sle-templates": { + "slo-sle-template": [{"id": "service-tmpl", "bound": 10}, {"id": "group-tmpl", "bound": 20}] + }, + "slice-service": [ + { + "id": "slice-non-p2p", + "slo-sle-template": "service-tmpl", + "sdps": {"sdp": [{"id": "sdp-1", "node-id": "N1"}, {"id": "sdp-2", "node-id": "N2"}]}, + "connection-groups": { + "connection-group": [ + { + "id": "cg-1", + "slo-sle-template": "group-tmpl", + "connectivity-type": "ietf-vpn-common:any-to-any", + "connectivity-construct": [ + {"id": "cc-1", "a2a-sdp": ["sdp-1", "sdp-2"]}, + {"id": "cc-2", "a2a-sdp": ["sdp-1", "sdp-2"]}, + ], + } + ] + }, + } + ], + } + } + + payload = {"intent": intent_non_p2p} + + with ( + flask_app.app_context(), + patch("src.mapper.main.get_data_store", return_value=None), + patch("src.mapper.main.normalize_libyang_data", side_effect=lambda x: x), + ): + services, _ = mapper(payload, controller_type="RESTCONF") + assert len(services) == 2 + assert services[0]["template"] == {"id": "group-tmpl", "bound": 20} + assert services[1]["template"] == {"id": "group-tmpl", "bound": 20} + + def test_mapper_empty_sdps_skipped(self, flask_app): + from src.mapper.main import mapper + + flask_app.config["DUMMY_MODE"] = True + + intent_empty_sdp = { + "ietf-network-slice-service:network-slice-services": { + "slice-service": [ + { + "id": "slice-empty-sdps", + "connection-groups": { + "connection-group": [ + { + "id": "cg-1", + "connectivity-type": "point-to-point", + "connectivity-construct": [{"id": "cc-1"}], + } + ] + }, + } + ] + } + } + + payload = {"intent": intent_empty_sdp} + + with ( + flask_app.app_context(), + patch("src.mapper.main.get_data_store", return_value=None), + patch("src.mapper.main.process_connectivity", return_value=[]), + ): + services, _ = mapper(payload, controller_type="RESTCONF") + assert services == [] diff --git a/src/tests/test_namespaces.py b/src/tests/test_namespaces.py index b070022..014cdf2 100644 --- a/src/tests/test_namespaces.py +++ b/src/tests/test_namespaces.py @@ -22,13 +22,14 @@ from unittest.mock import patch # 1. Tests for Basic Auth Enforcement Across Namespaces # ============================================================================= + def test_unauthenticated_request_rejected(client): """Test requests without Basic Auth headers return 401 Unauthorized.""" endpoints = [ "/tfs/slice", "/ixia/slice", "/e2e/slice", - "/restconf/data/ietf-network-slice-service:network-slice-services" + "/restconf/data/ietf-network-slice-service:network-slice-services", ] for endpoint in endpoints: resp = client.get(endpoint) @@ -39,6 +40,7 @@ def test_unauthenticated_request_rejected(client): # 2. Tests for TFS Namespace (/tfs) # ============================================================================= + def test_tfs_slice_get(client, auth_headers, temp_sqlite_db): """Test GET /tfs/slice retrieves slices.""" resp = client.get("/tfs/slice", headers=auth_headers) @@ -47,30 +49,28 @@ def test_tfs_slice_get(client, auth_headers, temp_sqlite_db): def test_tfs_slice_post_file(client, auth_headers, sample_ietf_intent, temp_sqlite_db): """Test POST /tfs/slice with uploaded JSON file.""" - data = { - 'file': (io.BytesIO(json.dumps(sample_ietf_intent).encode('utf-8')), 'intent.json') - } - with patch("src.main.send_controller", return_value=True), \ - patch("src.realizer.send_controller.send_controller", return_value=True): + data = {"file": (io.BytesIO(json.dumps(sample_ietf_intent).encode("utf-8")), "intent.json")} + with ( + patch("src.main.send_controller", return_value=True), + patch("src.realizer.send_controller.send_controller", return_value=True), + ): resp = client.post( "/tfs/slice", headers={"Authorization": auth_headers["Authorization"]}, data=data, - content_type='multipart/form-data' + content_type="multipart/form-data", ) assert resp.status_code in [200, 201] def test_tfs_slice_post_invalid_file_extension(client, auth_headers): """Test POST /tfs/slice rejects non-JSON uploaded files.""" - data = { - 'file': (io.BytesIO(b"hello world"), 'intent.txt') - } + data = {"file": (io.BytesIO(b"hello world"), "intent.txt")} resp = client.post( "/tfs/slice", headers={"Authorization": auth_headers["Authorization"]}, data=data, - content_type='multipart/form-data' + content_type="multipart/form-data", ) assert resp.status_code == 400 res_data = resp.get_json() @@ -79,14 +79,12 @@ def test_tfs_slice_post_invalid_file_extension(client, auth_headers): def test_tfs_slice_post_invalid_json_string(client, auth_headers): """Test POST /tfs/slice rejects malformed JSON string in form data.""" - data = { - 'json_data': '{invalid_json: true' - } + data = {"json_data": "{invalid_json: true"} resp = client.post( "/tfs/slice", headers={"Authorization": auth_headers["Authorization"]}, data=data, - content_type='multipart/form-data' + content_type="multipart/form-data", ) assert resp.status_code == 400 res_data = resp.get_json() @@ -97,6 +95,7 @@ def test_tfs_slice_post_invalid_json_string(client, auth_headers): # 3. Tests for IXIA Namespace (/ixia) # ============================================================================= + def test_ixia_slice_get(client, auth_headers, temp_sqlite_db): """Test GET /ixia/slice retrieves slices.""" resp = client.get("/ixia/slice", headers=auth_headers) @@ -105,16 +104,16 @@ def test_ixia_slice_get(client, auth_headers, temp_sqlite_db): def test_ixia_slice_post_form_data(client, auth_headers, sample_ietf_intent, temp_sqlite_db): """Test POST /ixia/slice with JSON string in form data.""" - data = { - 'json_data': json.dumps(sample_ietf_intent) - } - with patch("src.main.send_controller", return_value=True), \ - patch("src.realizer.send_controller.send_controller", return_value=True): + data = {"json_data": json.dumps(sample_ietf_intent)} + with ( + patch("src.main.send_controller", return_value=True), + patch("src.realizer.send_controller.send_controller", return_value=True), + ): resp = client.post( "/ixia/slice", headers={"Authorization": auth_headers["Authorization"]}, data=data, - content_type='multipart/form-data' + content_type="multipart/form-data", ) assert resp.status_code in [200, 201] @@ -123,6 +122,7 @@ def test_ixia_slice_post_form_data(client, auth_headers, sample_ietf_intent, tem # 4. Tests for E2E Namespace (/e2e) # ============================================================================= + def test_e2e_slice_get(client, auth_headers, temp_sqlite_db): """Test GET /e2e/slice retrieves slices.""" resp = client.get("/e2e/slice", headers=auth_headers) @@ -137,25 +137,18 @@ def test_e2e_alerts_post(client, auth_headers, temp_sqlite_db): "tapi-notification:notification": { "uuid": "alert-uuid-123", "notification-type": "ALARM_EVENT", - "event-time-stamp": "2026-08-04T12:00:00Z" + "event-time-stamp": "2026-08-04T12:00:00Z", } } ] } - resp = client.post( - "/e2e/alert", - headers=auth_headers, - data=json.dumps(alert_payload) - ) + resp = client.post("/e2e/alert", headers=auth_headers, data=json.dumps(alert_payload)) assert resp.status_code in [200, 201, 400] def test_e2e_alerts_post_missing_uuid(client, auth_headers): """Test POST /e2e/alert handles alert payloads.""" - resp = client.get( - "/e2e/alert", - headers=auth_headers - ) + resp = client.get("/e2e/alert", headers=auth_headers) assert resp.status_code in [200, 404] @@ -163,20 +156,18 @@ def test_e2e_alerts_post_missing_uuid(client, auth_headers): # 5. Tests for RESTCONF Namespace (/restconf) # ============================================================================= + def test_restconf_services_crud(client, auth_headers, sample_ietf_intent, temp_sqlite_db): """Test GET, POST, PUT operations on RESTCONF network slice services endpoint.""" # GET (empty DB) - get_resp = client.get( - "/restconf/data/ietf-network-slice-service:network-slice-services", - headers=auth_headers - ) + get_resp = client.get("/restconf/data/ietf-network-slice-service:network-slice-services", headers=auth_headers) assert get_resp.status_code in [200, 404] # POST (create) post_resp = client.post( "/restconf/data/ietf-network-slice-service:network-slice-services", headers=auth_headers, - data=json.dumps(sample_ietf_intent) + data=json.dumps(sample_ietf_intent), ) assert post_resp.status_code in [200, 201, 500] @@ -184,15 +175,17 @@ def test_restconf_services_crud(client, auth_headers, sample_ietf_intent, temp_s put_resp = client.put( "/restconf/data/ietf-network-slice-service:network-slice-services", headers=auth_headers, - data=json.dumps(sample_ietf_intent) + data=json.dumps(sample_ietf_intent), ) assert put_resp.status_code in [200, 404, 500] def test_delete_slice_endpoints(client, auth_headers): """Test DELETE endpoints on /tfs, /ixia, and /e2e controllers.""" - with patch("src.main.send_controller", return_value=True), \ - patch("src.realizer.send_controller.send_controller", return_value=True): + with ( + patch("src.main.send_controller", return_value=True), + patch("src.realizer.send_controller.send_controller", return_value=True), + ): res_tfs = client.delete("/tfs/slice/slice-1", headers=auth_headers) assert res_tfs.status_code in [200, 204, 404, 500] @@ -206,22 +199,17 @@ def test_delete_slice_endpoints(client, auth_headers): def test_restconf_detailed_resources(client, auth_headers): """Test RESTCONF specific resource endpoints.""" # Delete all slice services - del_all = client.delete( - "/restconf/data/ietf-network-slice-service:network-slice-services", - headers=auth_headers - ) + del_all = client.delete("/restconf/data/ietf-network-slice-service:network-slice-services", headers=auth_headers) assert del_all.status_code in [200, 204, 404, 500] # Specific slice service GET & DELETE get_spec = client.get( - "/restconf/data/ietf-network-slice-service:network-slice-services/slice-service=slice-123", - headers=auth_headers + "/restconf/data/ietf-network-slice-service:network-slice-services/slice-service=slice-123", headers=auth_headers ) assert get_spec.status_code in [200, 404] del_spec = client.delete( - "/restconf/data/ietf-network-slice-service:network-slice-services/slice-service=slice-123", - headers=auth_headers + "/restconf/data/ietf-network-slice-service:network-slice-services/slice-service=slice-123", headers=auth_headers ) assert del_spec.status_code in [200, 204, 404, 500] @@ -230,6 +218,7 @@ def test_restconf_detailed_resources(client, auth_headers): # 6. Extended Tests for IXIA and RESTCONF Namespaces # ============================================================================= + def test_ixia_namespace_extended(client, auth_headers): """Test IXIA delete all, get slice by id, and put (modify) slice by id.""" # DELETE all slices @@ -244,11 +233,7 @@ def test_ixia_namespace_extended(client, auth_headers): # PUT (modify) slice by id with patch("src.api.main.Api.modify_flow", return_value=({"id": "slice-1"}, 200)): - resp_put = client.put( - "/ixia/slice/slice-1", - headers=auth_headers, - data=json.dumps({"intent": "modified"}) - ) + resp_put = client.put("/ixia/slice/slice-1", headers=auth_headers, data=json.dumps({"intent": "modified"})) assert resp_put.status_code in [200, 400, 404, 500] @@ -257,8 +242,7 @@ def test_restconf_slice_services_extended(client, auth_headers): # GET /slice-service with patch("src.api.main.Api.get_slice_services", return_value=([], 200)): res_get_list = client.get( - "/restconf/data/ietf-network-slice-service:network-slice-services/slice-service", - headers=auth_headers + "/restconf/data/ietf-network-slice-service:network-slice-services/slice-service", headers=auth_headers ) assert res_get_list.status_code in [200, 404] @@ -267,15 +251,14 @@ def test_restconf_slice_services_extended(client, auth_headers): res_post_list = client.post( "/restconf/data/ietf-network-slice-service:network-slice-services/slice-service", headers=auth_headers, - data=json.dumps({"id": "slice-1"}) + data=json.dumps({"id": "slice-1"}), ) assert res_post_list.status_code in [200, 201, 409, 500] # DELETE /slice-service with patch("src.api.main.Api.delete_slice_services", return_value=({}, 204)): res_del_list = client.delete( - "/restconf/data/ietf-network-slice-service:network-slice-services/slice-service", - headers=auth_headers + "/restconf/data/ietf-network-slice-service:network-slice-services/slice-service", headers=auth_headers ) assert res_del_list.status_code in [200, 204, 500] @@ -284,7 +267,7 @@ def test_restconf_slice_services_extended(client, auth_headers): res_put_spec = client.put( "/restconf/data/ietf-network-slice-service:network-slice-services/slice-service=slice-1", headers=auth_headers, - data=json.dumps({"id": "slice-1"}) + data=json.dumps({"id": "slice-1"}), ) assert res_put_spec.status_code in [200, 201, 404, 500] @@ -312,7 +295,7 @@ def test_restconf_slo_sle_templates_extended(client, auth_headers): with patch("src.api.main.Api.get_slo_sle_templates", return_value=({"id": "tmpl-1"}, 200)): res_get_spec = client.get( "/restconf/data/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template=tmpl-1", - headers=auth_headers + headers=auth_headers, ) assert res_get_spec.status_code in [200, 404] @@ -321,7 +304,7 @@ def test_restconf_slo_sle_templates_extended(client, auth_headers): res_put_spec = client.put( "/restconf/data/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template=tmpl-1", headers=auth_headers, - data=json.dumps({"id": "tmpl-1"}) + data=json.dumps({"id": "tmpl-1"}), ) assert res_put_spec.status_code in [200, 404] @@ -329,7 +312,7 @@ def test_restconf_slo_sle_templates_extended(client, auth_headers): with patch("src.api.main.Api.delete_slo_sle_templates", return_value=({}, 204)): res_del_spec = client.delete( "/restconf/data/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template=tmpl-1", - headers=auth_headers + headers=auth_headers, ) assert res_del_spec.status_code in [200, 204] @@ -411,7 +394,7 @@ def test_restconf_telemetry_subscriptions_extended(client, auth_headers): res_post = client.post( "/restconf/operations/telemetry/subscription/cli-1", headers=auth_headers, - data=json.dumps({"slice_id": "slice-1", "frequency": 5}) + data=json.dumps({"slice_id": "slice-1", "frequency": 5}), ) assert res_post.status_code in [200, 201, 400, 500] @@ -422,7 +405,9 @@ def test_restconf_telemetry_subscriptions_extended(client, auth_headers): # GET specific subscription with patch("src.api.main.Api.get_subscriptions", return_value=({}, 200)): - res_get_spec = client.get("/restconf/operations/telemetry/subscription/cli-1/slice/slice-1", headers=auth_headers) + res_get_spec = client.get( + "/restconf/operations/telemetry/subscription/cli-1/slice/slice-1", headers=auth_headers + ) assert res_get_spec.status_code in [200, 500] # PUT update subscription @@ -430,13 +415,15 @@ def test_restconf_telemetry_subscriptions_extended(client, auth_headers): res_put_spec = client.put( "/restconf/operations/telemetry/subscription/cli-1/slice/slice-1", headers=auth_headers, - data=json.dumps({"frequency": 10}) + data=json.dumps({"frequency": 10}), ) assert res_put_spec.status_code in [200, 201, 400, 500] # DELETE specific subscription with patch("src.api.main.Api.delete_subscriptions", return_value=({}, 204)): - res_del_spec = client.delete("/restconf/operations/telemetry/subscription/cli-1/slice/slice-1", headers=auth_headers) + res_del_spec = client.delete( + "/restconf/operations/telemetry/subscription/cli-1/slice/slice-1", headers=auth_headers + ) assert res_del_spec.status_code in [200, 204, 500] @@ -444,7 +431,9 @@ def test_restconf_telemetry_stream_and_slice(client, auth_headers): """Test telemetry streaming endpoint and telemetry slice list / slice ID endpoints.""" # Stream endpoint with patch("src.api.main.Api.sync_stream", return_value=iter(["data: {}\n\n"])): - res_stream = client.get("/restconf/operations/telemetry/subscription/cli-1/slice/slice-1/stream", headers=auth_headers) + res_stream = client.get( + "/restconf/operations/telemetry/subscription/cli-1/slice/slice-1/stream", headers=auth_headers + ) assert res_stream.status_code in [200, 500] # GET telemetry for all slices @@ -457,3 +446,86 @@ def test_restconf_telemetry_stream_and_slice(client, auth_headers): res_telem_spec = client.get("/restconf/operations/telemetry/slice/slice-1", headers=auth_headers) assert res_telem_spec.status_code in [200, 404, 500] + +# ============================================================================= +# 7. Tests for Swagger Helpers (extract_json_payload) +# ============================================================================= + + +def test_extract_json_payload_direct(): + """Test extract_json_payload helper function under all branches.""" + from unittest.mock import MagicMock + + from swagger.helpers import extract_json_payload + + # 1. Valid uploaded JSON file + req_file = MagicMock() + mock_file = MagicMock() + mock_file.filename = "intent.json" + req_file.files = {"file": io.BytesIO(b'{"key": "value"}')} + req_file.files["file"].filename = "intent.json" + req_file.form = {} + req_file.get_json.return_value = None + payload, err = extract_json_payload(req_file) + assert payload == {"key": "value"} + assert err is None + + # 2. Invalid extension file + req_invalid_ext = MagicMock() + req_invalid_ext.files = {"file": io.BytesIO(b"data")} + req_invalid_ext.files["file"].filename = "intent.xml" + req_invalid_ext.form = {} + req_invalid_ext.get_json.return_value = None + payload, err = extract_json_payload(req_invalid_ext) + assert payload is None + assert err[1] == 400 + assert err[0]["error"] == "Only JSON files allowed" + + # 3. Malformed JSON file + req_corrupt_file = MagicMock() + req_corrupt_file.files = {"file": io.BytesIO(b"{bad json")} + req_corrupt_file.files["file"].filename = "intent.json" + req_corrupt_file.form = {} + req_corrupt_file.get_json.return_value = None + payload, err = extract_json_payload(req_corrupt_file) + assert payload is None + assert err[1] == 400 + assert err[0]["error"] == "JSON file not valid" + + # 4. Valid form json_data + req_form = MagicMock() + req_form.files = {} + req_form.form = {"json_data": '{"slice": "active"}'} + req_form.get_json.return_value = None + payload, err = extract_json_payload(req_form) + assert payload == {"slice": "active"} + assert err is None + + # 5. Malformed form json_data + req_bad_form = MagicMock() + req_bad_form.files = {} + req_bad_form.form = {"json_data": "{invalid"} + req_bad_form.get_json.return_value = None + payload, err = extract_json_payload(req_bad_form) + assert payload is None + assert err[1] == 400 + assert err[0]["error"] == "JSON file not valid" + + # 6. JSON body + req_body = MagicMock() + req_body.files = {} + req_body.form = {} + req_body.get_json.return_value = {"body": True} + payload, err = extract_json_payload(req_body) + assert payload == {"body": True} + assert err is None + + # 7. No data sent + req_empty = MagicMock() + req_empty.files = {} + req_empty.form = {} + req_empty.get_json.return_value = None + payload, err = extract_json_payload(req_empty) + assert payload is None + assert err[1] == 400 + assert err[0]["error"] == "No data sent" diff --git a/src/tests/test_nbi_processor.py b/src/tests/test_nbi_processor.py index d1d37b0..90d7a30 100644 --- a/src/tests/test_nbi_processor.py +++ b/src/tests/test_nbi_processor.py @@ -24,49 +24,48 @@ from src.nbi_processor.translator import translator # ---------- Tests detect_format ---------- + def test_detect_format_ietf(): data = {"ietf-network-slice-service:network-slice-services": {}} assert detect_format(data) == "IETF" + def test_detect_format_3gpp_variants(): assert detect_format({"RANSliceSubnet1": {}}) == "3GPP" assert detect_format({"NetworkSlice1": {}}) == "3GPP" assert detect_format({"TopSliceSubnet1": {}}) == "3GPP" assert detect_format({"CNSliceSubnet1": {}}) == "3GPP" + def test_detect_format_none(): assert detect_format({"foo": "bar"}) is None # ---------- Fixtures ---------- + @pytest.fixture def ietf_intent(): return {"ietf-network-slice-service:network-slice-services": {"foo": "bar"}} + @pytest.fixture def gpp_intent(): # Minimum structure consistent with translator return { - "RANSliceSubnet1": { - "networkSliceSubnetRef": ["subnetA", "subnetB"] - }, + "RANSliceSubnet1": {"networkSliceSubnetRef": ["subnetA", "subnetB"]}, "subnetA": { "EpTransport": ["EpTransport ep1", "EpTransport ep2"], - "SliceProfileList": [{ - "RANSliceSubnetProfile": { - "dLThptPerSliceSubnet": { - "GuaThpt": 1, - "MaxThpt": 2 - }, - "uLThptPerSliceSubnet": { - "GuaThpt": 1, - "MaxThpt": 2 - }, - "dLLatency": 20, - "uLLatency": 20 + "SliceProfileList": [ + { + "RANSliceSubnetProfile": { + "dLThptPerSliceSubnet": {"GuaThpt": 1, "MaxThpt": 2}, + "uLThptPerSliceSubnet": {"GuaThpt": 1, "MaxThpt": 2}, + "dLLatency": 20, + "uLLatency": 20, + } } - }], + ], }, "subnetB": { "EpTransport": ["EpTransport ep3", "EpTransport ep4"], @@ -87,8 +86,20 @@ def gpp_intent(): }, "EP_N2 epRef1": {"localAddress": "10.0.0.1", "remoteAddress": "11.1.1.1", "epTransportRef": "ep1"}, "EP_N2 epRef2": {"localAddress": "10.0.0.2", "remoteAddress": "11.1.1.2", "epTransportRef": "ep2"}, - "EpTransport ep3": {"qosProfile": "qosC", "EpApplicationRef": ["EP_N2 epRef3"], "logicalInterfaceInfo": {"logicalInterfaceType": "typeC", "logicalInterfaceId": "idC"}, "IpAddress": "3.3.3.3", "NextHopInfo": "NH3"}, - "EpTransport ep4": {"qosProfile": "qosD", "EpApplicationRef": ["EP_N2 epRef4"], "logicalInterfaceInfo": {"logicalInterfaceType": "typeD", "logicalInterfaceId": "idD"}, "IpAddress": "4.4.4.4", "NextHopInfo": "NH4"}, + "EpTransport ep3": { + "qosProfile": "qosC", + "EpApplicationRef": ["EP_N2 epRef3"], + "logicalInterfaceInfo": {"logicalInterfaceType": "typeC", "logicalInterfaceId": "idC"}, + "IpAddress": "3.3.3.3", + "NextHopInfo": "NH3", + }, + "EpTransport ep4": { + "qosProfile": "qosD", + "EpApplicationRef": ["EP_N2 epRef4"], + "logicalInterfaceInfo": {"logicalInterfaceType": "typeD", "logicalInterfaceId": "idD"}, + "IpAddress": "4.4.4.4", + "NextHopInfo": "NH4", + }, "EP_N2 epRef3": {"localAddress": "10.0.0.3", "remoteAddress": "11.1.1.3", "epTransportRef": "ep3"}, "EP_N2 epRef4": {"localAddress": "10.0.0.4", "remoteAddress": "11.1.1.4", "epTransportRef": "ep4"}, } @@ -99,20 +110,24 @@ def fake_template(): # Minimum template for translator to work return { "ietf-network-slice-service:network-slice-services": { - "slo-sle-templates": { - "slo-sle-template": [ - {"id": "", "slo-policy": {"metric-bound": []}} - ] - }, + "slo-sle-templates": {"slo-sle-template": [{"id": "", "slo-policy": {"metric-bound": []}}]}, "slice-service": [ { "id": "", "description": "", "slo-sle-template": "", - "sdps": {"sdp": [ - {"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": {}}]}} - ]}, + "sdps": { + "sdp": [ + { + "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": [{}]}, } ], @@ -122,11 +137,13 @@ def fake_template(): # ---------- Tests nbi_processor ---------- + def test_nbi_processor_ietf(ietf_intent): result = nbi_processor(ietf_intent) assert isinstance(result, list) assert result[0] == ietf_intent + @patch("src.nbi_processor.main.translator") def test_nbi_processor_3gpp(mock_translator, gpp_intent): mock_translator.return_value = {"ietf-network-slice-service:network-slice-services": {}} @@ -135,10 +152,12 @@ def test_nbi_processor_3gpp(mock_translator, gpp_intent): assert len(result) == 2 # Dos subnets procesados assert all("ietf-network-slice-service:network-slice-services" in r for r in result) + def test_nbi_processor_unrecognized(): with pytest.raises(ValueError): nbi_processor({"foo": "bar"}) + def test_nbi_processor_empty(): with pytest.raises(ValueError): nbi_processor({}) @@ -146,6 +165,7 @@ def test_nbi_processor_empty(): # ---------- Tests translator ---------- + @patch("src.nbi_processor.translator.load_template") def test_translator_basic(mock_load_template, gpp_intent, fake_template): mock_load_template.return_value = fake_template @@ -160,31 +180,34 @@ def test_translator_basic(mock_load_template, gpp_intent, fake_template): assert "description" in slice_service assert slice_service["slo-sle-template"] == "qosA" # viene del ep1 + import re # ---------- Extra detect_format ---------- -@pytest.mark.parametrize("data", [ - None, - [], - "", - 123, -]) + +@pytest.mark.parametrize( + "data", + [ + None, + [], + "", + 123, + ], +) def test_detect_format_invalid_types(data): assert detect_format(data if isinstance(data, dict) else {}) in (None, "IETF", "3GPP") def test_detect_format_multiple_keys(): # If it has IETF and 3GPP, should prioritize IETF - data = { - "ietf-network-slice-service:network-slice-services": {}, - "RANSliceSubnet1": {} - } + data = {"ietf-network-slice-service:network-slice-services": {}, "RANSliceSubnet1": {}} assert detect_format(data) == "IETF" # ---------- Extra nbi_processor ---------- + def test_nbi_processor_gpp_missing_refs(gpp_intent): # Removing networkSliceSubnetRef should cause ValueError in translator loop broken = gpp_intent.copy() @@ -195,12 +218,15 @@ def test_nbi_processor_gpp_missing_refs(gpp_intent): # ---------- Extra translator ---------- + @patch("src.nbi_processor.translator.load_template") def test_translator_maps_metrics(mock_load_template, gpp_intent, fake_template): mock_load_template.return_value = fake_template result = translator(gpp_intent, "subnetA") - metrics = result["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["slo-policy"]["metric-bound"] + metrics = result["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0][ + "slo-policy" + ]["metric-bound"] metric_types = {m["metric-type"] for m in metrics} assert "one-way-delay-maximum" in metric_types assert "one-way-bandwidth" in metric_types @@ -211,9 +237,12 @@ def test_translator_empty_profile(mock_load_template, gpp_intent, fake_template) mock_load_template.return_value = fake_template gpp_intent["subnetA"]["SliceProfileList"] = [{}] # empty result = translator(gpp_intent, "subnetA") - metrics = result["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["slo-policy"]["metric-bound"] + metrics = result["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0][ + "slo-policy" + ]["metric-bound"] assert metrics == [] # should not add anything + @patch("src.nbi_processor.translator.load_template") def test_translator_sdps_are_populated(mock_load_template, gpp_intent, fake_template): mock_load_template.return_value = fake_template diff --git a/src/tests/test_planner.py b/src/tests/test_planner.py index f5cda75..d362d70 100644 --- a/src/tests/test_planner.py +++ b/src/tests/test_planner.py @@ -1,485 +1,469 @@ -# Copyright 2022-2026 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. - -from unittest.mock import MagicMock, patch - -import pytest -import requests - -from src.api.main import Api -from src.main import NSController -from src.planner.change_scheduler_planner.change_scheduler import ( - _find_link_info, - change_scheduler_planner, -) -from src.planner.e2e_optical_planner.e2e_optical import e2e_optical_planner -from src.planner.energy_planner.energy import ( - energy_planner, - retrieve_energy, - retrieve_topology, -) -from src.planner.hrat_planner.hrat import hrat_planner -from src.planner.planner import Planner -from src.planner.shortest_path import get_shortest_path, normalize_node_id - -# ============================================================================= -# 1. Tests for main Planner Dispatcher (src/planner/planner.py) -# ============================================================================= - -def test_planner_dispatch_energy(flask_app, sample_ietf_intent): - """Test Planner.planner dispatches to energy_planner when type is ENERGY.""" - planner = Planner() - with patch("src.planner.planner.energy_planner") as mock_energy: - mock_energy.return_value = ["A", "B"] - with flask_app.app_context(): - res = planner.planner(sample_ietf_intent, type="ENERGY") - assert res == ["A", "B"] - mock_energy.assert_called_once_with(sample_ietf_intent) - - -def test_planner_dispatch_hrat(flask_app, sample_ietf_intent): - """Test Planner.planner dispatches to hrat_planner when type is HRAT.""" - planner = Planner() - with patch("src.planner.planner.hrat_planner") as mock_hrat: - mock_hrat.return_value = {"viability": True} - with flask_app.app_context(): - res = planner.planner(sample_ietf_intent, type="HRAT") - assert res == {"viability": True} - mock_hrat.assert_called_once_with(sample_ietf_intent, "10.0.0.1") - - -def test_planner_dispatch_e2e_optical(flask_app, sample_ietf_intent): - """Test Planner.planner dispatches to e2e_optical_planner when type is E2E_OPTICAL.""" - planner = Planner() - with patch("src.planner.planner.e2e_optical_planner") as mock_opt: - mock_opt.return_value = {"path": ["A", "B"]} - with flask_app.app_context(): - res = planner.planner(sample_ietf_intent, type="E2E_OPTICAL", is_update=False) - assert res == {"path": ["A", "B"]} - - res_update = planner.planner(sample_ietf_intent, type="E2E_OPTICAL", is_update=True) - assert res_update == {"path": ["A", "B"]} - assert mock_opt.call_count == 2 - - -def test_planner_dispatch_invalid(flask_app, sample_ietf_intent): - """Test Planner.planner returns None for unknown strategy types.""" - planner = Planner() - with flask_app.app_context(): - res = planner.planner(sample_ietf_intent, type="INVALID_STRATEGY") - assert res is None - - -# ============================================================================= -# 2. Tests for Shortest Path Algorithm (src/planner/shortest_path.py) -# ============================================================================= - -def test_normalize_node_id(): - """Test normalization of node URN identifiers.""" - assert normalize_node_id("urn:tfs:node:A") == "A" - assert normalize_node_id("B") == "B" - assert normalize_node_id(123) == 123 - - -def test_get_shortest_path_success(): - """Test successful shortest path computation on undirected graph.""" - network = { - "node": [ - {"node-id": "urn:tfs:node:A"}, - {"node-id": "urn:tfs:node:B"}, - {"node-id": "urn:tfs:node:C"}, - ], - "ietf-network-topology:link": [ - { - "source": {"source-node": "urn:tfs:node:A"}, - "destination": {"dest-node": "urn:tfs:node:B"} - }, - { - "source": {"source-node": "urn:tfs:node:B"}, - "destination": {"dest-node": "urn:tfs:node:C"} - } - ] - } - path, code = get_shortest_path(network, "urn:tfs:node:A", "urn:tfs:node:C", directed_graph=False) - assert code == 200 - assert path == ["A", "B", "C"] - - -def test_get_shortest_path_missing_source(): - """Test error handling when source node is absent.""" - network = { - "node": [{"node-id": "B"}], - "ietf-network-topology:link": [] - } - res, code = get_shortest_path(network, "A", "B") - assert code == 404 - assert res == {"message": "Source node 'A' not found"} - - -def test_get_shortest_path_missing_destination(): - """Test error handling when destination node is absent.""" - network = { - "node": [{"node-id": "A"}], - "ietf-network-topology:link": [] - } - res, code = get_shortest_path(network, "A", "B") - assert code == 404 - assert res == {"message": "Destination node 'B' not found"} - - -def test_get_shortest_path_no_path(): - """Test error handling when destination is disconnected from source.""" - network = { - "node": [{"node-id": "A"}, {"node-id": "B"}], - "ietf-network-topology:link": [] - } - res, code = get_shortest_path(network, "A", "B") - assert code == 404 - assert res == {"message": "No path found"} - - -def test_get_shortest_path_directed(): - """Test shortest path on a directed graph.""" - network = { - "node": [{"node-id": "A"}, {"node-id": "B"}], - "ietf-network-topology:link": [ - { - "source": {"source-node": "A"}, - "destination": {"dest-node": "B"} - } - ] - } - # Path A -> B should succeed - path, code = get_shortest_path(network, "A", "B", directed_graph=True) - assert code == 200 - assert path == ["A", "B"] - - # Path B -> A should fail on directed graph - res, code = get_shortest_path(network, "B", "A", directed_graph=True) - assert code == 404 - assert res == {"message": "No path found"} - - -# ============================================================================= -# 3. Tests for Energy Planner (src/planner/energy_planner/energy.py) -# ============================================================================= - -def test_retrieve_energy_and_topology(flask_app): - """Test metric and topology dataset loading functions.""" - energy = retrieve_energy() - with flask_app.app_context(): - topology = retrieve_topology() - assert isinstance(topology, dict) - assert isinstance(energy, list) - - -def test_energy_planner_invalid_nodes(flask_app): - """Test energy planner returns None when source/dest nodes are outside allowed set.""" - intent = { - "ietf-network-slice-service:network-slice-services": { - "slice-service": [{ - "sdps": { - "sdp": [ - {"node-id": "NODE_X"}, - {"node-id": "NODE_Y"} - ] - } - }] - } - } - with flask_app.app_context(): - res = energy_planner(intent) - assert res is None - - -def test_energy_planner_internal(flask_app, sample_ietf_intent): - """Test internal Dijkstra-based energy planner execution.""" - flask_app.config["PCE_EXTERNAL"] = False - with flask_app.app_context(): - path = energy_planner(sample_ietf_intent) - assert path is not None - assert isinstance(path, list) - assert path[0] == "A" - assert path[-1] == "B" - - -def test_energy_planner_pce_external(flask_app, sample_ietf_intent): - """Test external PCE energy planner path computation.""" - flask_app.config["PCE_EXTERNAL"] = True - with flask_app.app_context(): - path = energy_planner(sample_ietf_intent) - assert path is not None - assert isinstance(path, list) - - -# ============================================================================= -# 4. Tests for HRAT Planner (src/planner/hrat_planner/hrat.py) -# ============================================================================= - -@patch("requests.post") -def test_hrat_planner_create_success(mock_post): - """Test HRAT create action success path.""" - mock_resp = MagicMock() - mock_resp.ok = True - mock_resp.json.return_value = {"network-slice-uuid": "test-uuid", "viability": True} - mock_post.return_value = mock_resp - - res = hrat_planner(data={"test": "payload"}, ip="10.0.0.1", action="create") - assert res == {"network-slice-uuid": "test-uuid", "viability": True} - mock_post.assert_called_once() - - -@patch("requests.delete") -def test_hrat_planner_delete_success(mock_delete): - """Test HRAT delete action success path.""" - mock_resp = MagicMock() - mock_resp.ok = True - mock_resp.json.return_value = {"network-slice-uuid": "slice-1", "status": "deleted"} - mock_delete.return_value = mock_resp - - res = hrat_planner(data="slice-1", ip="10.0.0.1", action="delete") - assert res == {"network-slice-uuid": "slice-1", "status": "deleted"} - mock_delete.assert_called_once() - - -def test_hrat_planner_invalid_action(): - """Test HRAT planner fallback on invalid action.""" - res = hrat_planner(data={}, ip="10.0.0.1", action="invalid_action") - assert "network-slice-uuid" in res - assert res["viability"] is True - - -@patch("requests.post") -def test_hrat_planner_http_error(mock_post): - """Test HRAT planner handles HTTP failure by returning fallback data.""" - mock_post.side_effect = requests.exceptions.RequestException("Connection refused") - res = hrat_planner(data={}, ip="10.0.0.1", action="create") - assert "network-slice-uuid" in res - assert res["viability"] is True - - -# ============================================================================= -# 5. Tests for E2E Optical Planner (src/planner/e2e_optical_planner/e2e_optical.py) -# ============================================================================= - -@patch("requests.post") -def test_e2e_optical_planner_create_success(mock_post): - """Test E2E Optical planner path creation success.""" - mock_resp = MagicMock() - mock_resp.status_code = 200 - mock_resp.json.return_value = {"path_id": "opt-1", "nodes": ["A", "B"]} - mock_post.return_value = mock_resp - - res = e2e_optical_planner(intent={"test": 1}, ip="10.0.0.1", action="create") - assert res == {"path_id": "opt-1", "nodes": ["A", "B"]} - assert "e2e_path_computation" in mock_post.call_args[0][0] - - -@patch("requests.post") -def test_e2e_optical_planner_update(mock_post): - """Test E2E Optical planner path recomputation update action.""" - mock_resp = MagicMock() - mock_resp.status_code = 201 - mock_resp.json.return_value = {"path_id": "opt-1", "updated": True} - mock_post.return_value = mock_resp - - res = e2e_optical_planner(intent={"test": 1}, ip="10.0.0.1", action="update") - assert res == {"path_id": "opt-1", "updated": True} - assert "recompute_optical_path" in mock_post.call_args[0][0] - - -@patch("requests.post") -def test_e2e_optical_planner_failure(mock_post): - """Test E2E Optical planner returns None on request failure or exception.""" - mock_resp = MagicMock() - mock_resp.status_code = 500 - mock_resp.text = "Internal Server Error" - mock_post.return_value = mock_resp - - res = e2e_optical_planner(intent={}, ip="10.0.0.1", action="create") - assert res is None - - mock_post.side_effect = requests.exceptions.Timeout("Timed out") - res_timeout = e2e_optical_planner(intent={}, ip="10.0.0.1", action="create") - assert res_timeout is None - - -# ============================================================================= -# 6. Tests for Change Scheduler Planner -# ============================================================================= - -@pytest.fixture -def change_scheduler_sample_network(): - return { - "node": [ - {"node-id": "urn:tfs:node:xrv11", "ietf-l3-unicast-topology:l3-node-attributes": {"name": "xrv11"}}, - {"node-id": "urn:tfs:node:xrv12", "ietf-l3-unicast-topology:l3-node-attributes": {"name": "xrv12"}}, - {"node-id": "urn:tfs:node:xrv13", "ietf-l3-unicast-topology:l3-node-attributes": {"name": "xrv13"}}, - {"node-id": "urn:tfs:node:xrv14", "ietf-l3-unicast-topology:l3-node-attributes": {"name": "xrv14"}}, - {"node-id": "urn:tfs:node:xrv15", "ietf-l3-unicast-topology:l3-node-attributes": {"name": "xrv15"}}, - ], - "ietf-network-topology:link": [ - { - "link-id": "xrv11-Gi0/0/0/1-xrv12-Gi0/0/0/1", - "source": {"source-node": "urn:tfs:node:xrv11"}, - "destination": {"dest-node": "urn:tfs:node:xrv12"} - }, - { - "link-id": "xrv12-Gi0/0/0/1-xrv13-Gi0/0/0/1", - "source": {"source-node": "urn:tfs:node:xrv12"}, - "destination": {"dest-node": "urn:tfs:node:xrv13"} - }, - { - "link-id": "xrv13-Gi0/0/0/1-xrv14-Gi0/0/0/1", - "source": {"source-node": "urn:tfs:node:xrv13"}, - "destination": {"dest-node": "urn:tfs:node:xrv14"} - }, - { - "link-id": "xrv11-Gi0/0/0/1-xrv15-Gi0/0/0/1", - "source": {"source-node": "urn:tfs:node:xrv11"}, - "destination": {"dest-node": "urn:tfs:node:xrv15"} - }, - { - "link-id": "xrv15-Gi0/0/0/1-xrv14-Gi0/0/0/1", - "source": {"source-node": "urn:tfs:node:xrv15"}, - "destination": {"dest-node": "urn:tfs:node:xrv14"} - }, - ] - } - - -def test_find_link_info(change_scheduler_sample_network): - underlay_links = change_scheduler_sample_network["ietf-network-topology:link"] - src_node, link_id = _find_link_info("xrv13", "xrv14", underlay_links) - assert src_node == "urn:tfs:node:xrv13" - assert link_id == "xrv13-Gi0/0/0/1-xrv14-Gi0/0/0/1" - - src_node_unk, link_id_unk = _find_link_info("A", "B", underlay_links) - assert src_node_unk == "A" - assert link_id_unk == "A-B" - - -def test_change_scheduler_planner_success(flask_app, change_scheduler_sample_network): - with patch("src.planner.change_scheduler_planner.change_scheduler.get_data_by_slice_id") as mock_db, \ - patch("src.planner.change_scheduler_planner.change_scheduler.tfs_connector") as mock_conn_cls, \ - patch("src.planner.change_scheduler_planner.change_scheduler.get_shortest_path") as mock_sp, \ - patch("requests.post") as mock_post: - - mock_db.return_value = [{"service_id": "svc-100", "slice_id": "slice-1"}] - - mock_conn = MagicMock() - mock_conn.get_service_path.return_value = (["xrv11", "xrv12", "xrv13", "xrv14"], 200) - mock_conn.get_network_topology.return_value = (change_scheduler_sample_network, 200) - mock_conn_cls.return_value = mock_conn - - mock_sp.return_value = (["xrv11", "xrv15", "xrv14"], 200) - - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.headers = {"content-type": "application/json"} - mock_response.json.return_value = {"status": "SCHEDULED"} - mock_post.return_value = mock_response - res = change_scheduler_planner("slice-1", change_scheduler_url="http://127.0.0.1:8090/change-scheduler/request") - - assert res["slice_id"] == "slice-1" - assert res["new_path"] == ["xrv11", "xrv15", "xrv14"] - - payload = res["request_payload"] - assert "ietf-tvr-topology:topology-schedule" in payload - schedule = payload["ietf-tvr-topology:topology-schedule"] - - assert "links" in schedule - assert "nodes" in schedule - - link_availabilities = [link["available"]["default-link-available"] for link in schedule["links"]] - assert "false" in link_availabilities - assert "true" in link_availabilities - - node_availabilities = {node["node-id"]: node["available"]["default-node-available"] for node in schedule["nodes"]} - assert node_availabilities.get("xrv12") == "false" - assert node_availabilities.get("xrv13") == "false" - assert node_availabilities.get("xrv15") == "true" - - mock_post.assert_called_once() - call_url = mock_post.call_args[0][0] - assert call_url == "http://127.0.0.1:8090/change-scheduler/request" - - -def test_change_scheduler_planner_no_services(): - with patch("src.planner.change_scheduler_planner.change_scheduler.get_data_by_slice_id") as mock_db: - mock_db.side_effect = ValueError("No services found") - - with pytest.raises(ValueError) as exc: - change_scheduler_planner("nonexistent-slice") - assert "No services found" in str(exc.value) - - -def test_change_scheduler_planner_service_path_failure(flask_app): - with patch("src.planner.change_scheduler_planner.change_scheduler.get_data_by_slice_id") as mock_db, \ - patch("src.planner.change_scheduler_planner.change_scheduler.tfs_connector") as mock_conn_cls: - - mock_db.return_value = [{"service_id": "svc-100", "slice_id": "slice-1"}] - mock_conn = MagicMock() - mock_conn.get_service_path.return_value = ([], 404) - mock_conn_cls.return_value = mock_conn - - with pytest.raises(Exception) as exc: - change_scheduler_planner("slice-1") - assert "Could not retrieve service path" in str(exc.value) - - -def test_reconfig_slice_main(flask_app): - with patch("src.main.realizer") as mock_realizer, \ - patch("src.planner.planner.change_scheduler_planner") as mock_planner: - mock_realizer.return_value = { - "slice_id": "slice-123", - "service_path": ["xrv11", "xrv12"], - "network_topology": {} - } - mock_planner.return_value = {"slice_id": "slice-123", "new_path": ["xrv11"]} - - nsc = NSController(controller_type="TFS") - res = nsc.reconfig_slice("slice-123") - - assert res["slice_id"] == "slice-123" - mock_realizer.assert_called_once_with({"slice_id": "slice-123"}, action="RECONFIG", controller_type="TFS") - mock_planner.assert_called_once() - - -def test_planner_class_change_scheduler(): - with patch("src.planner.planner.change_scheduler_planner") as mock_planner: - mock_planner.return_value = {"slice_id": "slice-999", "new_path": ["A", "B"]} - - p = Planner() - res = p.planner("slice-999", type="CHANGE_SCHEDULER") - - assert res == {"slice_id": "slice-999", "new_path": ["A", "B"]} - mock_planner.assert_called_once_with("slice-999", current_path=None, network=None) - - -def test_api_reconfig_slice(flask_app): - with patch("src.main.NSController.reconfig_slice") as mock_nsc: - mock_nsc.return_value = {"slice_id": "slice-1", "new_path": ["xrv11", "xrv15"]} - - nsc = NSController(controller_type="TFS") - api = Api(nsc) - res, code = api.reconfig_slice("slice-1") - - assert code == 200 - assert res["success"] is True - assert res["data"] == {"slice_id": "slice-1", "new_path": ["xrv11", "xrv15"]} - - +# Copyright 2022-2026 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. + +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from src.api.main import Api +from src.main import NSController +from src.planner.change_scheduler_planner.change_scheduler import ( + _find_link_info, + change_scheduler_planner, +) +from src.planner.e2e_optical_planner.e2e_optical import e2e_optical_planner +from src.planner.energy_planner.energy import ( + energy_planner, + retrieve_energy, + retrieve_topology, +) +from src.planner.hrat_planner.hrat import hrat_planner +from src.planner.planner import Planner +from src.planner.shortest_path import get_shortest_path, normalize_node_id + +# ============================================================================= +# 1. Tests for main Planner Dispatcher (src/planner/planner.py) +# ============================================================================= + + +def test_planner_dispatch_energy(flask_app, sample_ietf_intent): + """Test Planner.planner dispatches to energy_planner when type is ENERGY.""" + planner = Planner() + with patch("src.planner.planner.energy_planner") as mock_energy: + mock_energy.return_value = ["A", "B"] + with flask_app.app_context(): + res = planner.planner(sample_ietf_intent, type="ENERGY") + assert res == ["A", "B"] + mock_energy.assert_called_once_with(sample_ietf_intent) + + +def test_planner_dispatch_hrat(flask_app, sample_ietf_intent): + """Test Planner.planner dispatches to hrat_planner when type is HRAT.""" + planner = Planner() + with patch("src.planner.planner.hrat_planner") as mock_hrat: + mock_hrat.return_value = {"viability": True} + with flask_app.app_context(): + res = planner.planner(sample_ietf_intent, type="HRAT") + assert res == {"viability": True} + mock_hrat.assert_called_once_with(sample_ietf_intent, "10.0.0.1") + + +def test_planner_dispatch_e2e_optical(flask_app, sample_ietf_intent): + """Test Planner.planner dispatches to e2e_optical_planner when type is E2E_OPTICAL.""" + planner = Planner() + with patch("src.planner.planner.e2e_optical_planner") as mock_opt: + mock_opt.return_value = {"path": ["A", "B"]} + with flask_app.app_context(): + res = planner.planner(sample_ietf_intent, type="E2E_OPTICAL", is_update=False) + assert res == {"path": ["A", "B"]} + + res_update = planner.planner(sample_ietf_intent, type="E2E_OPTICAL", is_update=True) + assert res_update == {"path": ["A", "B"]} + assert mock_opt.call_count == 2 + + +def test_planner_dispatch_invalid(flask_app, sample_ietf_intent): + """Test Planner.planner returns None for unknown strategy types.""" + planner = Planner() + with flask_app.app_context(): + res = planner.planner(sample_ietf_intent, type="INVALID_STRATEGY") + assert res is None + + +# ============================================================================= +# 2. Tests for Shortest Path Algorithm (src/planner/shortest_path.py) +# ============================================================================= + + +def test_normalize_node_id(): + """Test normalization of node URN identifiers.""" + assert normalize_node_id("urn:tfs:node:A") == "A" + assert normalize_node_id("B") == "B" + assert normalize_node_id(123) == 123 + + +def test_get_shortest_path_success(): + """Test successful shortest path computation on undirected graph.""" + network = { + "node": [ + {"node-id": "urn:tfs:node:A"}, + {"node-id": "urn:tfs:node:B"}, + {"node-id": "urn:tfs:node:C"}, + ], + "ietf-network-topology:link": [ + {"source": {"source-node": "urn:tfs:node:A"}, "destination": {"dest-node": "urn:tfs:node:B"}}, + {"source": {"source-node": "urn:tfs:node:B"}, "destination": {"dest-node": "urn:tfs:node:C"}}, + ], + } + path, code = get_shortest_path(network, "urn:tfs:node:A", "urn:tfs:node:C", directed_graph=False) + assert code == 200 + assert path == ["A", "B", "C"] + + +def test_get_shortest_path_missing_source(): + """Test error handling when source node is absent.""" + network = {"node": [{"node-id": "B"}], "ietf-network-topology:link": []} + res, code = get_shortest_path(network, "A", "B") + assert code == 404 + assert res == {"message": "Source node 'A' not found"} + + +def test_get_shortest_path_missing_destination(): + """Test error handling when destination node is absent.""" + network = {"node": [{"node-id": "A"}], "ietf-network-topology:link": []} + res, code = get_shortest_path(network, "A", "B") + assert code == 404 + assert res == {"message": "Destination node 'B' not found"} + + +def test_get_shortest_path_no_path(): + """Test error handling when destination is disconnected from source.""" + network = {"node": [{"node-id": "A"}, {"node-id": "B"}], "ietf-network-topology:link": []} + res, code = get_shortest_path(network, "A", "B") + assert code == 404 + assert res == {"message": "No path found"} + + +def test_get_shortest_path_directed(): + """Test shortest path on a directed graph.""" + network = { + "node": [{"node-id": "A"}, {"node-id": "B"}], + "ietf-network-topology:link": [{"source": {"source-node": "A"}, "destination": {"dest-node": "B"}}], + } + # Path A -> B should succeed + path, code = get_shortest_path(network, "A", "B", directed_graph=True) + assert code == 200 + assert path == ["A", "B"] + + # Path B -> A should fail on directed graph + res, code = get_shortest_path(network, "B", "A", directed_graph=True) + assert code == 404 + assert res == {"message": "No path found"} + + +# ============================================================================= +# 3. Tests for Energy Planner (src/planner/energy_planner/energy.py) +# ============================================================================= + + +def test_retrieve_energy_and_topology(flask_app): + """Test metric and topology dataset loading functions.""" + energy = retrieve_energy() + with flask_app.app_context(): + topology = retrieve_topology() + assert isinstance(topology, dict) + assert isinstance(energy, list) + + +def test_energy_planner_invalid_nodes(flask_app): + """Test energy planner returns None when source/dest nodes are outside allowed set.""" + intent = { + "ietf-network-slice-service:network-slice-services": { + "slice-service": [{"sdps": {"sdp": [{"node-id": "NODE_X"}, {"node-id": "NODE_Y"}]}}] + } + } + with flask_app.app_context(): + res = energy_planner(intent) + assert res is None + + +def test_energy_planner_internal(flask_app, sample_ietf_intent): + """Test internal Dijkstra-based energy planner execution.""" + flask_app.config["PCE_EXTERNAL"] = False + with flask_app.app_context(): + path = energy_planner(sample_ietf_intent) + assert path is not None + assert isinstance(path, list) + assert path[0] == "A" + assert path[-1] == "B" + + +def test_energy_planner_pce_external(flask_app, sample_ietf_intent): + """Test external PCE energy planner path computation.""" + flask_app.config["PCE_EXTERNAL"] = True + with flask_app.app_context(): + path = energy_planner(sample_ietf_intent) + assert path is not None + assert isinstance(path, list) + + +# ============================================================================= +# 4. Tests for HRAT Planner (src/planner/hrat_planner/hrat.py) +# ============================================================================= + + +@patch("requests.post") +def test_hrat_planner_create_success(mock_post): + """Test HRAT create action success path.""" + mock_resp = MagicMock() + mock_resp.ok = True + mock_resp.json.return_value = {"network-slice-uuid": "test-uuid", "viability": True} + mock_post.return_value = mock_resp + + res = hrat_planner(data={"test": "payload"}, ip="10.0.0.1", action="create") + assert res == {"network-slice-uuid": "test-uuid", "viability": True} + mock_post.assert_called_once() + + +@patch("requests.delete") +def test_hrat_planner_delete_success(mock_delete): + """Test HRAT delete action success path.""" + mock_resp = MagicMock() + mock_resp.ok = True + mock_resp.json.return_value = {"network-slice-uuid": "slice-1", "status": "deleted"} + mock_delete.return_value = mock_resp + + res = hrat_planner(data="slice-1", ip="10.0.0.1", action="delete") + assert res == {"network-slice-uuid": "slice-1", "status": "deleted"} + mock_delete.assert_called_once() + + +def test_hrat_planner_invalid_action(): + """Test HRAT planner fallback on invalid action.""" + res = hrat_planner(data={}, ip="10.0.0.1", action="invalid_action") + assert "network-slice-uuid" in res + assert res["viability"] is True + + +@patch("requests.post") +def test_hrat_planner_http_error(mock_post): + """Test HRAT planner handles HTTP failure by returning fallback data.""" + mock_post.side_effect = requests.exceptions.RequestException("Connection refused") + res = hrat_planner(data={}, ip="10.0.0.1", action="create") + assert "network-slice-uuid" in res + assert res["viability"] is True + + +# ============================================================================= +# 5. Tests for E2E Optical Planner (src/planner/e2e_optical_planner/e2e_optical.py) +# ============================================================================= + + +@patch("requests.post") +def test_e2e_optical_planner_create_success(mock_post): + """Test E2E Optical planner path creation success.""" + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = {"path_id": "opt-1", "nodes": ["A", "B"]} + mock_post.return_value = mock_resp + + res = e2e_optical_planner(intent={"test": 1}, ip="10.0.0.1", action="create") + assert res == {"path_id": "opt-1", "nodes": ["A", "B"]} + assert "e2e_path_computation" in mock_post.call_args[0][0] + + +@patch("requests.post") +def test_e2e_optical_planner_update(mock_post): + """Test E2E Optical planner path recomputation update action.""" + mock_resp = MagicMock() + mock_resp.status_code = 201 + mock_resp.json.return_value = {"path_id": "opt-1", "updated": True} + mock_post.return_value = mock_resp + + res = e2e_optical_planner(intent={"test": 1}, ip="10.0.0.1", action="update") + assert res == {"path_id": "opt-1", "updated": True} + assert "recompute_optical_path" in mock_post.call_args[0][0] + + +@patch("requests.post") +def test_e2e_optical_planner_failure(mock_post): + """Test E2E Optical planner returns None on request failure or exception.""" + mock_resp = MagicMock() + mock_resp.status_code = 500 + mock_resp.text = "Internal Server Error" + mock_post.return_value = mock_resp + + res = e2e_optical_planner(intent={}, ip="10.0.0.1", action="create") + assert res is None + + mock_post.side_effect = requests.exceptions.Timeout("Timed out") + res_timeout = e2e_optical_planner(intent={}, ip="10.0.0.1", action="create") + assert res_timeout is None + + +# ============================================================================= +# 6. Tests for Change Scheduler Planner +# ============================================================================= + + +@pytest.fixture +def change_scheduler_sample_network(): + return { + "node": [ + {"node-id": "urn:tfs:node:xrv11", "ietf-l3-unicast-topology:l3-node-attributes": {"name": "xrv11"}}, + {"node-id": "urn:tfs:node:xrv12", "ietf-l3-unicast-topology:l3-node-attributes": {"name": "xrv12"}}, + {"node-id": "urn:tfs:node:xrv13", "ietf-l3-unicast-topology:l3-node-attributes": {"name": "xrv13"}}, + {"node-id": "urn:tfs:node:xrv14", "ietf-l3-unicast-topology:l3-node-attributes": {"name": "xrv14"}}, + {"node-id": "urn:tfs:node:xrv15", "ietf-l3-unicast-topology:l3-node-attributes": {"name": "xrv15"}}, + ], + "ietf-network-topology:link": [ + { + "link-id": "xrv11-Gi0/0/0/1-xrv12-Gi0/0/0/1", + "source": {"source-node": "urn:tfs:node:xrv11"}, + "destination": {"dest-node": "urn:tfs:node:xrv12"}, + }, + { + "link-id": "xrv12-Gi0/0/0/1-xrv13-Gi0/0/0/1", + "source": {"source-node": "urn:tfs:node:xrv12"}, + "destination": {"dest-node": "urn:tfs:node:xrv13"}, + }, + { + "link-id": "xrv13-Gi0/0/0/1-xrv14-Gi0/0/0/1", + "source": {"source-node": "urn:tfs:node:xrv13"}, + "destination": {"dest-node": "urn:tfs:node:xrv14"}, + }, + { + "link-id": "xrv11-Gi0/0/0/1-xrv15-Gi0/0/0/1", + "source": {"source-node": "urn:tfs:node:xrv11"}, + "destination": {"dest-node": "urn:tfs:node:xrv15"}, + }, + { + "link-id": "xrv15-Gi0/0/0/1-xrv14-Gi0/0/0/1", + "source": {"source-node": "urn:tfs:node:xrv15"}, + "destination": {"dest-node": "urn:tfs:node:xrv14"}, + }, + ], + } + + +def test_find_link_info(change_scheduler_sample_network): + underlay_links = change_scheduler_sample_network["ietf-network-topology:link"] + src_node, link_id = _find_link_info("xrv13", "xrv14", underlay_links) + assert src_node == "urn:tfs:node:xrv13" + assert link_id == "xrv13-Gi0/0/0/1-xrv14-Gi0/0/0/1" + + src_node_unk, link_id_unk = _find_link_info("A", "B", underlay_links) + assert src_node_unk == "A" + assert link_id_unk == "A-B" + + +def test_change_scheduler_planner_success(flask_app, change_scheduler_sample_network): + with ( + patch("src.planner.change_scheduler_planner.change_scheduler.get_data_by_slice_id") as mock_db, + patch("src.planner.change_scheduler_planner.change_scheduler.tfs_connector") as mock_conn_cls, + patch("src.planner.change_scheduler_planner.change_scheduler.get_shortest_path") as mock_sp, + patch("requests.post") as mock_post, + ): + mock_db.return_value = [{"service_id": "svc-100", "slice_id": "slice-1"}] + + mock_conn = MagicMock() + mock_conn.get_service_path.return_value = (["xrv11", "xrv12", "xrv13", "xrv14"], 200) + mock_conn.get_network_topology.return_value = (change_scheduler_sample_network, 200) + mock_conn_cls.return_value = mock_conn + + mock_sp.return_value = (["xrv11", "xrv15", "xrv14"], 200) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.headers = {"content-type": "application/json"} + mock_response.json.return_value = {"status": "SCHEDULED"} + mock_post.return_value = mock_response + + res = change_scheduler_planner("slice-1", change_scheduler_url="http://127.0.0.1:8090/change-scheduler/request") + + assert res["slice_id"] == "slice-1" + assert res["new_path"] == ["xrv11", "xrv15", "xrv14"] + + payload = res["request_payload"] + assert "ietf-tvr-topology:topology-schedule" in payload + schedule = payload["ietf-tvr-topology:topology-schedule"] + + assert "links" in schedule + assert "nodes" in schedule + + link_availabilities = [link["available"]["default-link-available"] for link in schedule["links"]] + assert "false" in link_availabilities + assert "true" in link_availabilities + + node_availabilities = { + node["node-id"]: node["available"]["default-node-available"] for node in schedule["nodes"] + } + assert node_availabilities.get("xrv12") == "false" + assert node_availabilities.get("xrv13") == "false" + assert node_availabilities.get("xrv15") == "true" + + mock_post.assert_called_once() + call_url = mock_post.call_args[0][0] + assert call_url == "http://127.0.0.1:8090/change-scheduler/request" + + +def test_change_scheduler_planner_no_services(): + with patch("src.planner.change_scheduler_planner.change_scheduler.get_data_by_slice_id") as mock_db: + mock_db.side_effect = ValueError("No services found") + + with pytest.raises(ValueError) as exc: + change_scheduler_planner("nonexistent-slice") + assert "No services found" in str(exc.value) + + +def test_change_scheduler_planner_service_path_failure(flask_app): + with ( + patch("src.planner.change_scheduler_planner.change_scheduler.get_data_by_slice_id") as mock_db, + patch("src.planner.change_scheduler_planner.change_scheduler.tfs_connector") as mock_conn_cls, + ): + mock_db.return_value = [{"service_id": "svc-100", "slice_id": "slice-1"}] + mock_conn = MagicMock() + mock_conn.get_service_path.return_value = ([], 404) + mock_conn_cls.return_value = mock_conn + + with pytest.raises(Exception) as exc: + change_scheduler_planner("slice-1") + assert "Could not retrieve service path" in str(exc.value) + + +def test_reconfig_slice_main(flask_app): + with ( + patch("src.main.realizer") as mock_realizer, + patch("src.planner.planner.change_scheduler_planner") as mock_planner, + ): + mock_realizer.return_value = { + "slice_id": "slice-123", + "service_path": ["xrv11", "xrv12"], + "network_topology": {}, + } + mock_planner.return_value = {"slice_id": "slice-123", "new_path": ["xrv11"]} + + nsc = NSController(controller_type="TFS") + res = nsc.reconfig_slice("slice-123") + + assert res["slice_id"] == "slice-123" + mock_realizer.assert_called_once_with({"slice_id": "slice-123"}, action="RECONFIG", controller_type="TFS") + mock_planner.assert_called_once() + + +def test_planner_class_change_scheduler(): + with patch("src.planner.planner.change_scheduler_planner") as mock_planner: + mock_planner.return_value = {"slice_id": "slice-999", "new_path": ["A", "B"]} + + p = Planner() + res = p.planner("slice-999", type="CHANGE_SCHEDULER") + + assert res == {"slice_id": "slice-999", "new_path": ["A", "B"]} + mock_planner.assert_called_once_with("slice-999", current_path=None, network=None) + + +def test_api_reconfig_slice(flask_app): + with patch("src.main.NSController.reconfig_slice") as mock_nsc: + mock_nsc.return_value = {"slice_id": "slice-1", "new_path": ["xrv11", "xrv15"]} + + nsc = NSController(controller_type="TFS") + api = Api(nsc) + res, code = api.reconfig_slice("slice-1") + + assert code == 200 + assert res["success"] is True + assert res["data"] == {"slice_id": "slice-1", "new_path": ["xrv11", "xrv15"]} diff --git a/src/tests/test_realizer.py b/src/tests/test_realizer.py index a57b792..3c81cc2 100644 --- a/src/tests/test_realizer.py +++ b/src/tests/test_realizer.py @@ -28,6 +28,7 @@ from src.realizer.send_controller import send_controller # 1. Tests for Realizer Entrypoint (src/realizer/main.py) # ============================================================================= + def test_realizer_create_with_nrp(): """Test realizer CREATE flow when need_nrp is True.""" with patch("src.realizer.main.nrp_handler") as mock_nrp: @@ -37,20 +38,23 @@ def test_realizer_create_with_nrp(): mock_nrp.assert_called_once_with("READ", {"id": "nrp-1"}) -@pytest.mark.parametrize("rule_type,expected_way", [ - ("XR_AGENT_ACTIVATE_TRANSCEIVER", "L3oWDM"), - ("PROVISION_MEDIA_CHANNEL_AND_CONFIG_VPNL3", "L3oWDM"), - ("PROVISION_MEDIA_CHANNEL_AND_CONFIG_VPNL2", "L2oWDM"), - ("PROVISION_MEDIA_CHANNEL", "OPTIC"), - ("CONFIG_VPNL3", "L3VPN"), - ("CONFIG_VPNL2", "L2VPN"), - ("DEACTIVATE_XR_AGENT_TRANSCEIVER", "DEL_L3oWDM"), - ("DEPROVISION_OPTICAL_RESOURCE_AND_REMOVE_VPNL3", "DEL_L3oWDM"), - ("DEPROVISION_OPTICAL_RESOURCE_AND_REMOVE_VPNL2", "DEL_L2oWDM"), - ("DEPROVISION_OPTICAL_RESOURCE", "DEL_OPTIC"), - ("REMOVE_VPNL3", "DEL_L3VPN"), - ("REMOVE_VPNL2", "DEL_L2VPN"), -]) +@pytest.mark.parametrize( + "rule_type,expected_way", + [ + ("XR_AGENT_ACTIVATE_TRANSCEIVER", "L3oWDM"), + ("PROVISION_MEDIA_CHANNEL_AND_CONFIG_VPNL3", "L3oWDM"), + ("PROVISION_MEDIA_CHANNEL_AND_CONFIG_VPNL2", "L2oWDM"), + ("PROVISION_MEDIA_CHANNEL", "OPTIC"), + ("CONFIG_VPNL3", "L3VPN"), + ("CONFIG_VPNL2", "L2VPN"), + ("DEACTIVATE_XR_AGENT_TRANSCEIVER", "DEL_L3oWDM"), + ("DEPROVISION_OPTICAL_RESOURCE_AND_REMOVE_VPNL3", "DEL_L3oWDM"), + ("DEPROVISION_OPTICAL_RESOURCE_AND_REMOVE_VPNL2", "DEL_L2oWDM"), + ("DEPROVISION_OPTICAL_RESOURCE", "DEL_OPTIC"), + ("REMOVE_VPNL3", "DEL_L3VPN"), + ("REMOVE_VPNL2", "DEL_L2VPN"), + ], +) def test_realizer_create_e2e_rules(rule_type, expected_way): """Test E2E rule resolution maps action types to correct realization ways.""" rules = [] @@ -104,13 +108,12 @@ def test_realizer_create_service_tag_way(sample_ietf_intent): def test_realizer_monitor_success(flask_app): """Test realizer MONITOR action succeeds when service_id and service path are retrieved.""" - payload = { - "slice_id": "slice-1" - } - with patch("src.realizer.main.get_data_by_slice_id") as mock_db, \ - patch("src.realizer.main.tfs_connector") as mock_tfs_cls, \ - patch("src.realizer.main.get_metrics") as mock_gm: - + payload = {"slice_id": "slice-1"} + with ( + patch("src.realizer.main.get_data_by_slice_id") as mock_db, + patch("src.realizer.main.tfs_connector") as mock_tfs_cls, + patch("src.realizer.main.get_metrics") as mock_gm, + ): mock_db.return_value = [{"service_id": "svc-1", "slice_id": "slice-1"}] mock_conn = MagicMock() mock_conn.get_service_path.return_value = (["A", "B"], 200) @@ -125,12 +128,11 @@ def test_realizer_monitor_success(flask_app): def test_realizer_monitor_service_path_failure(flask_app): """Test realizer MONITOR action raises exception when service path retrieval fails.""" - payload = { - "slice_id": "slice-1" - } - with patch("src.realizer.main.get_data_by_slice_id") as mock_db, \ - patch("src.realizer.main.tfs_connector") as mock_tfs_cls: - + payload = {"slice_id": "slice-1"} + with ( + patch("src.realizer.main.get_data_by_slice_id") as mock_db, + patch("src.realizer.main.tfs_connector") as mock_tfs_cls, + ): mock_db.return_value = [{"service_id": "svc-1", "slice_id": "slice-1"}] mock_conn = MagicMock() mock_conn.get_service_path.return_value = ([], 500) @@ -143,13 +145,11 @@ def test_realizer_monitor_service_path_failure(flask_app): def test_realizer_monitor_multiple_services_failure(flask_app): """Test realizer MONITOR action raises ValueError when slice has multiple associated services.""" - payload = { - "slice_id": "slice-multi" - } + payload = {"slice_id": "slice-multi"} with patch("src.realizer.main.get_data_by_slice_id") as mock_db: mock_db.return_value = [ {"service_id": "svc-1", "slice_id": "slice-multi"}, - {"service_id": "svc-2", "slice_id": "slice-multi"} + {"service_id": "svc-2", "slice_id": "slice-multi"}, ] with flask_app.app_context(): @@ -161,6 +161,7 @@ def test_realizer_monitor_multiple_services_failure(flask_app): # 2. Tests for Select Way Dispatcher (src/realizer/select_way.py) # ============================================================================= + @patch("src.realizer.select_way.tfs") @patch("src.realizer.select_way.ixia") @patch("src.realizer.select_way.e2e") @@ -188,6 +189,7 @@ def test_select_way_dispatchers(mock_restconf, mock_e2e, mock_ixia, mock_tfs): # 3. Tests for Send Controller Dispatcher (src/realizer/send_controller.py) # ============================================================================= + def test_send_controller_dummy_mode(flask_app): """Test send_controller returns True when DUMMY_MODE is enabled.""" flask_app.config["DUMMY_MODE"] = True @@ -231,6 +233,7 @@ def test_send_controller_non_dummy(mock_rc, mock_e2e, mock_ixia, mock_tfs, flask # 4. Tests for NRP Handler (src/realizer/nrp_handler.py) # ============================================================================= + def test_nrp_handler_operations(tmp_path, monkeypatch): """Test NRP handler database READ, CREATE, UPDATE operations.""" nrp_file = tmp_path / "nrp_ddbb.json" @@ -254,6 +257,7 @@ def test_nrp_handler_operations(tmp_path, monkeypatch): # 5. Tests for Get Metrics Streamer (src/realizer/get_metrics.py) # ============================================================================= + def test_get_metrics(flask_app): """Test telemetry metrics stream initialization and cache skipping.""" flask_app.config["TELEMETRY_CACHE"] = {} @@ -291,6 +295,7 @@ def test_get_metrics(flask_app): # 6. Tests for RESTCONF Service Types & Builders # ============================================================================= + class TestRestconfServiceTypesAndBuilders: """Full coverage unit tests for RESTCONF service types (l2vpn, l3vpn) and builders.""" @@ -313,14 +318,10 @@ class TestRestconfServiceTypesAndBuilders: "sdp": { "id": "sdp-1", "node-id": "N1", - "attachment-circuits": { - "attachment-circuit": [ - {"ac-node-id": "R1", "ac-tp-id": "Eth1"} - ] - } + "attachment-circuits": {"attachment-circuit": [{"ac-node-id": "R1", "ac-tp-id": "Eth1"}]}, } } - ] + ], } res = l2vpn(intent) assert res is not None @@ -340,12 +341,17 @@ class TestRestconfServiceTypesAndBuilders: "node-id": "N1", "attachment-circuits": { "attachment-circuit": [ - {"ac-node-id": "R1", "ac-tp-id": "Eth1", "ac-ipv4-address": "10.0.0.1", "ac-ipv4-prefix-length": 24} + { + "ac-node-id": "R1", + "ac-tp-id": "Eth1", + "ac-ipv4-address": "10.0.0.1", + "ac-ipv4-prefix-length": 24, + } ] - } + }, } } - ] + ], } res = l3vpn(intent) assert res is not None @@ -397,7 +403,10 @@ class TestRestconfServiceTypesAndBuilders: configure_match_criteria, ) - net_access = {"service": {"qos": {"qos-classification-policy": {"rule": []}}}, "connection": {"tagged-interface": {"dot1q-vlan-tagged": {}}}} + net_access = { + "service": {"qos": {"qos-classification-policy": {"rule": []}}}, + "connection": {"tagged-interface": {"dot1q-vlan-tagged": {}}}, + } site = {} # None match criteria @@ -410,11 +419,20 @@ class TestRestconfServiceTypesAndBuilders: # L3 vlan match criteria -> static routing protocol sdp_vlan = { "match_criteria": {"index": 1, "match-type": [{"type": "vlan", "vlan": [100]}]}, - "sdp": {"attachment-circuits": {"attachment-circuit": [{"ac-ipv4-address": "10.0.0.1", "ac-ipv4-prefix-length": 24}]}} + "sdp": { + "attachment-circuits": { + "attachment-circuit": [{"ac-ipv4-address": "10.0.0.1", "ac-ipv4-prefix-length": 24}] + } + }, } configure_match_criteria(net_access, site, sdp_vlan, "l3") assert "routing-protocols" in site - assert site["routing-protocols"]["routing-protocol"][0]["static"]["cascaded-lan-prefixes"]["ipv4-lan-prefixes"][0]["lan-tag"] == 100 + assert ( + site["routing-protocols"]["routing-protocol"][0]["static"]["cascaded-lan-prefixes"]["ipv4-lan-prefixes"][0][ + "lan-tag" + ] + == 100 + ) # L2 vlan match criteria configure_match_criteria(net_access, site, sdp_vlan, "l2") @@ -424,10 +442,20 @@ class TestRestconfServiceTypesAndBuilders: sdp_dscp = {"match_criteria": {"index": 2, "match-type": [{"type": "dscp", "dscp": [46]}]}} configure_match_criteria(net_access, site, sdp_dscp, "l3") - sdp_src_ip = {"match_criteria": {"index": 3, "match-type": [{"type": "source-ip-prefix", "source-ip-prefix": ["192.168.1.0/24"]}]}} + sdp_src_ip = { + "match_criteria": { + "index": 3, + "match-type": [{"type": "source-ip-prefix", "source-ip-prefix": ["192.168.1.0/24"]}], + } + } configure_match_criteria(net_access, site, sdp_src_ip, "l3") - sdp_dst_ip = {"match_criteria": {"index": 4, "match-type": [{"type": "destination-ip-prefix", "destination-ip-prefix": ["10.1.1.0/24"]}]}} + sdp_dst_ip = { + "match_criteria": { + "index": 4, + "match-type": [{"type": "destination-ip-prefix", "destination-ip-prefix": ["10.1.1.0/24"]}], + } + } configure_match_criteria(net_access, site, sdp_dst_ip, "l3") sdp_unknown = {"match_criteria": {"index": 5, "match-type": [{"type": "invalid-type", "invalid-type": [1]}]}} @@ -438,17 +466,7 @@ class TestRestconfServiceTypesAndBuilders: configure_slos, ) - net_access_l2 = { - "service": { - "qos": { - "qos-profile": { - "classes": { - "class": [{"class-id": "qos-realtime"}] - } - } - } - } - } + net_access_l2 = {"service": {"qos": {"qos-profile": {"classes": {"class": [{"class-id": "qos-realtime"}]}}}}} intent_full = { "id": "vpn-1", "template": { @@ -459,27 +477,17 @@ class TestRestconfServiceTypesAndBuilders: {"metric-type": "two-way-bandwidth", "bound": 10, "metric-unit": "Mbps"}, {"metric-type": "two-way-delay-maximum", "bound": 20}, {"metric-type": "two-way-delay-variation-maximum", "bound": 5}, - {"metric-type": "two-way-packet-loss", "bound": 0.01} - ] + {"metric-type": "two-way-packet-loss", "bound": 0.01}, + ], } - } + }, } configure_slos(net_access_l2, intent_full, "l2") assert net_access_l2["service"]["svc-mtu"] == 1400 assert net_access_l2["service"]["svc-bandwidth"]["bandwidth"][0]["cir"] == 10_000_000 # Test L3 constraints and defaults - net_access_l3 = { - "service": { - "qos": { - "qos-profile": { - "classes": { - "class": [{"class-id": "qos-realtime"}] - } - } - } - } - } + net_access_l3 = {"service": {"qos": {"qos-profile": {"classes": {"class": [{"class-id": "qos-realtime"}]}}}}} intent_units = { "id": "vpn-2", "template": { @@ -489,14 +497,17 @@ class TestRestconfServiceTypesAndBuilders: {"metric-type": "two-way-bandwidth", "bound": 500, "metric-unit": "kbps"}, {"metric-type": "two-way-bandwidth", "bound": 100, "metric-unit": "bps"}, {"metric-type": "two-way-delay-maximum", "bound": 15}, - {"metric-type": "two-way-delay-variation-maximum", "bound": 3} + {"metric-type": "two-way-delay-variation-maximum", "bound": 3}, ] } - } + }, } configure_slos(net_access_l3, intent_units, "l3") assert net_access_l3["service"]["svc-mtu"] == 1500 - assert net_access_l3["service"]["qos"]["qos-profile"]["classes"]["class"][0]["bandwidth"]["guaranteed-bw-percent"] == 0 + assert ( + net_access_l3["service"]["qos"]["qos-profile"]["classes"]["class"][0]["bandwidth"]["guaranteed-bw-percent"] + == 0 + ) class TestRestconfConnect: @@ -508,19 +519,12 @@ class TestRestconfConnect: ) flask_app.config["SDN_CONTROLLER_TYPE"] = "TFS" - requests_payload = { - "services": [ - { - "ietf-l2vpn-svc:l2vpn-svc": {"vpn-services": {}} - } - ] - } + requests_payload = {"services": [{"ietf-l2vpn-svc:l2vpn-svc": {"vpn-services": {}}}]} mock_resp = MagicMock() mock_resp.ok = True - with flask_app.app_context(), \ - patch("src.realizer.restconf.restconf_connect.tfs_connector") as mock_conn_cls: + with flask_app.app_context(), patch("src.realizer.restconf.restconf_connect.tfs_connector") as mock_conn_cls: mock_conn = MagicMock() mock_conn.nbi_post.return_value = mock_resp mock_conn_cls.return_value = mock_conn @@ -537,17 +541,16 @@ class TestRestconfConnect: flask_app.config["SDN_CONTROLLER_TYPE"] = "TFS" flask_app.config["DATAPLANE_SUPPORT"] = "FRR" - l3_service = { - "ietf-l3vpn-svc:l3vpn-svc": {} - } + l3_service = {"ietf-l3vpn-svc:l3vpn-svc": {}} mock_resp = MagicMock() mock_resp.ok = True - with flask_app.app_context(), \ - patch("src.realizer.restconf.restconf_connect.safe_get", return_value=46), \ - patch("src.realizer.restconf.restconf_connect.tfs_connector") as mock_conn_cls: - + with ( + flask_app.app_context(), + patch("src.realizer.restconf.restconf_connect.safe_get", return_value=46), + patch("src.realizer.restconf.restconf_connect.tfs_connector") as mock_conn_cls, + ): mock_conn = MagicMock() mock_conn.nbi_post.return_value = mock_resp mock_conn_cls.return_value = mock_conn @@ -558,26 +561,32 @@ class TestRestconfConnect: assert res_429[1] == 429 # Case 2: Assign slot fails -> 429 - with patch.object(_slice_manager, "is_full", return_value=False), \ - patch.object(_slice_manager, "assign_slot", return_value=None): + with ( + patch.object(_slice_manager, "is_full", return_value=False), + patch.object(_slice_manager, "assign_slot", return_value=None), + ): res_no_slot = restconf_connect({"services": [l3_service]}, "10.0.0.1") assert res_no_slot[1] == 429 # Case 3: FRR command execution raises Exception -> 500 mock_frr = MagicMock() mock_frr.execute_commands.side_effect = Exception("SSH failure") - with patch.object(_slice_manager, "is_full", return_value=False), \ - patch.object(_slice_manager, "assign_slot", return_value=1), \ - patch("src.realizer.restconf.restconf_connect.frr_connector", return_value=mock_frr): + with ( + patch.object(_slice_manager, "is_full", return_value=False), + patch.object(_slice_manager, "assign_slot", return_value=1), + patch("src.realizer.restconf.restconf_connect.frr_connector", return_value=mock_frr), + ): res_500 = restconf_connect({"services": [l3_service]}, "10.0.0.1") assert res_500[1] == 500 # Case 4: FRR success -> proceed to nbi_post and return mock_resp mock_frr_ok = MagicMock() mock_frr_ok.execute_commands.return_value = None - with patch.object(_slice_manager, "is_full", return_value=False), \ - patch.object(_slice_manager, "assign_slot", return_value=1), \ - patch("src.realizer.restconf.restconf_connect.frr_connector", return_value=mock_frr_ok): + with ( + patch.object(_slice_manager, "is_full", return_value=False), + patch.object(_slice_manager, "assign_slot", return_value=1), + patch("src.realizer.restconf.restconf_connect.frr_connector", return_value=mock_frr_ok), + ): res_ok = restconf_connect({"services": [l3_service]}, "10.0.0.1") assert res_ok == mock_resp @@ -597,8 +606,7 @@ class TestRestconfConnect: mock_resp_fail.status_code = 502 mock_resp_fail.text = "Bad Gateway" - with flask_app.app_context(), \ - patch("src.realizer.restconf.restconf_connect.tfs_connector") as mock_conn_cls: + with flask_app.app_context(), patch("src.realizer.restconf.restconf_connect.tfs_connector") as mock_conn_cls: mock_conn = MagicMock() mock_conn.nbi_post.return_value = mock_resp_fail mock_conn_cls.return_value = mock_conn @@ -615,7 +623,9 @@ class TestTfsConnector: conn = tfs_connector() mock_get_resp = MagicMock() - mock_get_resp.iter_lines.return_value = [b''] + mock_get_resp.iter_lines.return_value = [ + b'' + ] mock_post_resp = MagicMock() mock_post_resp.text = "OK" @@ -662,11 +672,7 @@ class TestTfsConnector: conn = tfs_connector() mock_resp = MagicMock() mock_resp.json.return_value = [ - { - "ietf-network:networks": { - "network": [{"network-id": "urn:tfs:network:admin", "nodes": []}] - } - } + {"ietf-network:networks": {"network": [{"network-id": "urn:tfs:network:admin", "nodes": []}]}} ] with patch("requests.get", return_value=mock_resp): @@ -699,14 +705,16 @@ class TestTfsConnector: {"device_id": {"device_uuid": {"uuid": "dev-1"}}}, {"device_id": {"device_uuid": {"uuid": "dev-2"}}}, {"device_id": {"device_uuid": {"uuid": "dev-2"}}}, - {"device_id": {"device_uuid": {"uuid": "dev-3"}}} + {"device_id": {"device_uuid": {"uuid": "dev-3"}}}, ] } ] } - with patch("requests.get", return_value=mock_resp), \ - patch.object(conn, "get_device_name", side_effect=lambda ip, uuid: f"name-{uuid}"): + with ( + patch("requests.get", return_value=mock_resp), + patch.object(conn, "get_device_name", side_effect=lambda ip, uuid: f"name-{uuid}"), + ): path, code = conn.get_service_path("10.0.0.1", "svc-123") assert code == 200 assert path == ["name-dev-1", "name-dev-2", "name-dev-3"] @@ -742,3 +750,466 @@ class TestTfsConnector: # Nested in subscription-result nested = {"ietf-subscribed-notifications:subscription-result": {"stream": {"uri": "http://stream.com/nested"}}} assert conn._extract_stream_uri(nested, "http://base") == "http://stream.com/nested" + + +# ============================================================================= +# 9. Tests for E2E Orchestrator (src/realizer/e2e/) +# ============================================================================= + + +class TestE2ERealizer: + """Unit tests for E2E main, connect, and service types.""" + + def test_e2e_dispatch(self): + from src.realizer.e2e.main import e2e + + with ( + patch("src.realizer.e2e.main.l3ipowdm_slice", return_value=[{"req": "l3owdm"}]), + patch("src.realizer.e2e.main.del_l3ipowdm_slice", return_value={"req": "del"}), + ): + assert e2e({}, way="L3oWDM", rules={"actions": []}) == [{"req": "l3owdm"}] + assert e2e({}, way="DEL_L3oWDM", response=[], rules={"actions": []}) == {"req": "del"} + assert e2e({}, way="UNSUPPORTED") is None + + def test_e2e_connect(self, monkeypatch): + from src.realizer.e2e.e2e_connect import e2e_connect + + mock_conn = MagicMock() + mock_conn.ipowdm_post.return_value = MagicMock(status_code=200) + mock_conn.ipowdm_put.return_value = MagicMock(status_code=200) + monkeypatch.setattr("src.realizer.e2e.e2e_connect.tfs_connector", lambda: mock_conn) + monkeypatch.setenv("E2E_OPTICAL_IP", "10.10.10.10") + + payload_with_uuid = { + "services": [ + { + "service_id": {"service_uuid": {"uuid": "slice-e2e-123"}}, + } + ] + } + res = e2e_connect(payload_with_uuid, "10.0.0.1", is_update=False) + assert res.status_code == 200 + mock_conn.ipowdm_post.assert_called_once_with("10.10.10.10", "slice-e2e-123", payload_with_uuid) + + # Update mode + res_update = e2e_connect(payload_with_uuid, "10.0.0.1", is_update=True, old_service_id="old-123") + assert res_update.status_code == 200 + mock_conn.ipowdm_put.assert_called_once() + + def test_l3ipowdm_slice_builders(self): + from src.realizer.e2e.service_types.l3ipowdm_slice import l3ipowdm_slice + + assert l3ipowdm_slice(None) == [] + assert l3ipowdm_slice({}) == [] + + rules = { + "network-slice-uuid": "slice-uuid-001", + "actions": [ + { + "type": "CREATE_OPTICAL_SLICE", + "content": { + "tenant-uuid": "tenant-001", + "service-interface-point": [{"uuid": "sip-1"}, {"uuid": "sip-2"}], + "node": [ + {"node-id": "node-1", "owned-node-edge-point": [{"media-channel-node-edge-point-spec": {}}]} + ], + "link": [{"link-id": "link-1"}], + }, + }, + { + "type": "PROVISION_MEDIA_CHANNEL_OLS_PATH", + "tenant-uuid": "tenant-001", + "content": { + "src-sip-uuid": "sip-src", + "dest-sip-uuid": "dest-sip", + "direction": "UNIDIRECTIONAL", + "bandwidth-ghz": 50, + "ols-path-uuid": "ols-001", + "layer-protocol-name": "PHOTONIC_MEDIA", + "layer-protocol-qualifier": "TAPI_PHOTONIC_MEDIA", + "lower-frequency-mhz": 193100000, + "upper-frequency-mhz": 193150000, + "link-uuid-path": ["link-1"], + "adjustment-granularity": "G_50GHZ", + "grid-type": "FLEX", + }, + }, + { + "type": "XR_AGENT_ACTIVATE_TRANSCEIVER", + "content": {"components": [{"name": "transceiver-1"}]}, + }, + { + "type": "CONFIG_VPNL3", + "controller_uuid": "ctrl-001", + "content": { + "src-node-uuid": "src-node", + "src-ip-address": "10.0.0.1", + "src-ip-mask": 24, + "src-vlan-id": 100, + "dest1-node-uuid": "dest-node", + "dest1-ip-address": "10.0.0.2", + "dest1-ip-mask": 24, + "dest1-vlan-id": 100, + }, + }, + ], + } + + with patch("src.realizer.e2e.service_types.l3ipowdm_slice.load_template") as mock_tmpl: + mock_tmpl.side_effect = lambda path: { + "tapi-common:context": { + "service-interface-point": [{}, {}], + "tapi-topology:topology-context": {"topology": [{"node": [], "link": []}]}, + "name": [{"value": ""}], + }, + "services": [ + { + "service_id": {"service_uuid": {"uuid": ""}}, + "service_config": {"config_rules": [{"tapi_lsp": {"rule_set": {}}}]}, + } + ], + "endpoint_id": {"device_id": {"device_uuid": {"uuid": ""}}}, + "rule_set": {}, + } + results = l3ipowdm_slice(rules) + assert len(results) >= 3 + + def test_del_l3ipowdm_slice_webui_and_nbi(self, flask_app): + from src.realizer.e2e.service_types.del_l3ipowdm_slice import del_l3ipowdm_slice + + ietf_intent = { + "ietf-network-slice-service:network-slice-services": { + "slice-service": [ + { + "id": "slice-del-123", + "sdps": { + "sdp": [ + { + "attachment-circuits": { + "attachment-circuit": [{"sdp-peering": {"peer-sap-id": "R1"}}] + }, + "service-match-criteria": {"match-criterion": [{"match-type": [{"vlan": [100]}]}]}, + "node-id": "node-1", + "sdp-ip-address": "192.168.1.1", + }, + { + "attachment-circuits": { + "attachment-circuit": [{"sdp-peering": {"peer-sap-id": "R2"}}] + }, + "service-match-criteria": {"match-criterion": [{"match-type": [{"vlan": [100]}]}]}, + "node-id": "node-2", + "sdp-ip-address": "192.168.1.2", + }, + ] + }, + } + ] + } + } + with flask_app.app_context(): + flask_app.config["UPLOAD_TYPE"] = "WEBUI" + with patch("src.realizer.e2e.service_types.del_l3ipowdm_slice.load_template") as mock_tmpl: + mock_tmpl.return_value = { + "services": [ + { + "service_id": {"service_uuid": {"uuid": ""}}, + "service_endpoint_ids": [ + {"device_id": {"device_uuid": {}}, "endpoint_uuid": {}}, + {"device_id": {"device_uuid": {}}, "endpoint_uuid": {}}, + ], + "service_constraints": [], + "service_config": { + "config_rules": [ + {}, + {"custom": {"resource_value": {}, "resource_key": ""}}, + {"custom": {"resource_value": {}, "resource_key": ""}}, + ] + }, + } + ] + } + res_webui = del_l3ipowdm_slice(ietf_intent, [{"id": "slice-del-123", "requirements": ["r1"]}]) + assert res_webui["service_id"]["service_uuid"]["uuid"] == "slice-del-123" + + flask_app.config["UPLOAD_TYPE"] = "NBI" + with patch("src.realizer.e2e.service_types.del_l3ipowdm_slice.load_template") as mock_tmpl: + mock_tmpl.return_value = { + "ietf-l2vpn-svc:vpn-service": [ + { + "vpn-id": "", + "site": [ + {"site-network-access": {"interface": {}}}, + {"site-network-access": {"interface": {}}}, + ], + } + ] + } + res_nbi = del_l3ipowdm_slice(ietf_intent, []) + assert res_nbi["ietf-l2vpn-svc:vpn-service"][0]["vpn-id"] == "slice-del-123" + + +# ============================================================================= +# 10. Tests for IXIA Controller (src/realizer/ixia/) +# ============================================================================= + + +class TestIxiaRealizer: + """Unit tests for IXIA main, connect, and helpers.""" + + def test_ixia_intent_parsing(self): + from src.realizer.ixia.main import ixia + + intent = { + "ietf-network-slice-service:network-slice-services": { + "slo-sle-templates": { + "slo-sle-template": [ + { + "description": "v1-low-latency", + "sle-policy": {"reliability": 99.9}, + "slo-policy": { + "metric-bound": [ + {"metric-type": "one-way-bandwidth", "bound": 1000}, + {"metric-type": "one-way-delay-maximum", "bound": 10.5}, + {"metric-type": "one-way-delay-variation-maximum", "bound": 2.0}, + ] + }, + } + ] + }, + "slice-service": [ + { + "sdps": { + "sdp": [ + { + "attachment-circuits": { + "attachment-circuit": [{"sdp-peering": {"peer-sap-id": "192.168.1.10"}}] + }, + "service-match-criteria": {"match-criterion": [{"match-type": [{"vlan": [200]}]}]}, + }, + { + "attachment-circuits": { + "attachment-circuit": [{"sdp-peering": {"peer-sap-id": "192.168.1.20"}}] + }, + }, + ] + } + } + ], + } + } + res = ixia(intent) + assert res["src_node_ip"] == "192.168.1.10" + assert res["dst_node_ip"] == "192.168.1.20" + assert res["vlan_id"] == 200 + assert res["bandwidth"] == 1000 + assert res["latency"] == 10.5 + assert res["tolerance"] == 2.0 + assert res["latency_version"] == "v1-low-latency" + assert res["reliability"] == 99.9 + + def test_ixia_connect(self): + from src.realizer.ixia.ixia_connect import ixia_connect + + with patch("src.realizer.ixia.ixia_connect.NEII_controller") as mock_ctrl_cls: + mock_inst = MagicMock() + mock_inst.nscNEII.return_value = {"status": "ok"} + mock_ctrl_cls.return_value = mock_inst + + res = ixia_connect({"services": [{"src_node_ip": "1.1.1.1"}]}, "10.0.0.5") + assert res == {"status": "ok"} + mock_inst.nscNEII.assert_called_once() + + def test_neii_controller_helpers(self): + from src.realizer.ixia.helpers.NEII_V4 import NEII_controller + + ctrl = NEII_controller("10.0.0.5") + assert ctrl.ixia_ip == "10.0.0.5" + + with patch("src.realizer.ixia.helpers.NEII_V4.automatizacion") as mock_auto: + mock_auto.obtener_informacion_puerto.return_value = {"profiles": []} + mock_auto.envio_peticion.return_value = "OK" + + json_data = { + "src_node_ip": "192.168.1.1", + "dst_node_ip": "192.168.1.2", + "vlan_id": 100, + "bandwidth": 5000, + "latency": 10, + "tolerance": 2, + "latency_version": "2", + "reliability": 99, + } + res = ctrl.nscNEII(json_data) + assert res is not None + + def test_automatizacion_static_methods(self): + from src.realizer.ixia.helpers.automatizacion_ne2v4 import automatizacion + + delay_none = automatizacion.añadir_configuracion_puerto_delay(10, "1") + assert delay_none["ethernetDelay"]["pdvMode"] == "NONE" + + delay_gauss = automatizacion.añadir_configuracion_puerto_delay(10, "gauss", 3) + assert delay_gauss["ethernetDelay"]["pdvMode"] == "GAUSSIAN" + + delay_internet = automatizacion.añadir_configuracion_puerto_delay(10, "internet", 3) + assert delay_internet["ethernetDelay"]["pdvMode"] == "INTERNET" + + ipv4_cfg = automatizacion.añadir_configuracion_puerto_ipv4("c0a80101", "c0a80102", "4") + assert "profiles" in ipv4_cfg + + vlan_cfg = automatizacion.añadir_configuracion_VLAN(100) + assert "profiles" in vlan_cfg + + drop_cfg = automatizacion.añadir_configuración_packetDrop(5, 100) + assert drop_cfg["packetDrop"]["enabled"] is True + + policer_cfg = automatizacion.añadir_configuracion_policer(1000) + assert policer_cfg["policer"]["excessBitRate"] == 1000 + + shaper_cfg = automatizacion.añadir_configuracion_shaper(2000) + assert shaper_cfg["shaper"]["bitRate"] == 2000 + + +# ============================================================================= +# 11. Tests for TFS Controller and Services (src/realizer/tfs/) +# ============================================================================= + + +class TestTfsRealizer: + """Unit tests for TFS main, connect, and service types.""" + + def test_tfs_dispatch(self): + from src.realizer.tfs.main import tfs + + with ( + patch("src.realizer.tfs.main.tfs_l2vpn", return_value={"type": "l2"}), + patch("src.realizer.tfs.main.tfs_l3vpn", return_value={"type": "l3"}), + ): + assert tfs({}, way="L2") == {"type": "l2"} + assert tfs({}, way="L3") == {"type": "l3"} + assert tfs({}, way="UNKNOWN") == {"type": "l2"} + + def test_tfs_connect(self, flask_app): + from src.realizer.tfs.tfs_connect import tfs_connect + + with flask_app.app_context(): + flask_app.config["UPLOAD_TYPE"] = "WEBUI" + flask_app.config["TFS_L2VPN_SUPPORT"] = False + with patch("src.realizer.tfs.tfs_connect.tfs_connector") as mock_conn_cls: + mock_inst = MagicMock() + mock_inst.webui_post.return_value = MagicMock(status_code=200) + mock_conn_cls.return_value = mock_inst + + res = tfs_connect({"services": []}, "10.0.0.1") + assert res.status_code == 200 + + def test_tfs_l2vpn_and_l3vpn_generation(self, flask_app): + from src.realizer.tfs.service_types.tfs_l2vpn import tfs_l2vpn + from src.realizer.tfs.service_types.tfs_l3vpn import tfs_l3vpn + + intent = { + "ietf-network-slice-service:network-slice-services": { + "slice-service": [ + { + "id": "slice-tfs-100", + "sdps": { + "sdp": [ + { + "attachment-circuits": { + "attachment-circuit": [{"sdp-peering": {"peer-sap-id": "R1"}}] + }, + "service-match-criteria": {"match-criterion": [{"match-type": [{"vlan": [100]}]}]}, + "node-id": "node-1", + }, + { + "attachment-circuits": { + "attachment-circuit": [{"sdp-peering": {"peer-sap-id": "R2"}}] + }, + "service-match-criteria": {"match-criterion": [{"match-type": [{"vlan": [100]}]}]}, + "node-id": "node-2", + }, + ] + }, + } + ] + } + } + with flask_app.app_context(): + flask_app.config["UPLOAD_TYPE"] = "NBI" + with patch("src.realizer.tfs.service_types.tfs_l2vpn.load_template") as mock_l2_tmpl: + mock_l2_tmpl.return_value = { + "ietf-l2vpn-svc:vpn-service": [ + { + "vpn-id": "", + "site": [ + {"site-network-access": {"interface": {}}}, + {"site-network-access": {"interface": {}}}, + ], + } + ] + } + res_l2 = tfs_l2vpn(intent, []) + assert res_l2 is not None + + with patch("src.realizer.tfs.service_types.tfs_l3vpn.load_template") as mock_l3_tmpl: + mock_l3_tmpl.return_value = { + "ietf-l3vpn-svc:l3vpn-svc": { + "vpn-services": {"vpn-service": [{}]}, + "sites": { + "site": [ + { + "locations": {"location": [{}]}, + "devices": {"device": [{}]}, + "site-network-accesses": { + "site-network-access": [ + { + "vpn-attachment": {}, + "service": { + "qos": { + "qos-profile": { + "classes": {"class": [{"latency": {}, "bandwidth": {}}]} + } + } + }, + } + ] + }, + } + ] + }, + } + } + res_l3 = tfs_l3vpn( + intent, + [ + { + "id": "slice-tfs-100", + "requirements": [{"constraint_type": "availability[%]", "constraint_value": 99}], + } + ], + ) + assert res_l3 is not None + + +# ============================================================================= +# 12. Tests for Realizer Reconfiguration Action (src/realizer/main.py) +# ============================================================================= + + +def test_realizer_reconfig_action(flask_app): + """Test realizer RECONFIG action successfully returns path and topology.""" + with flask_app.app_context(): + flask_app.config["RESTCONF_IP"] = "127.0.0.1" + with ( + patch("src.realizer.main.get_data_by_slice_id", return_value=[{"service_id": "svc-reconfig-1"}]), + patch("src.realizer.main.tfs_connector") as mock_conn_cls, + ): + mock_inst = MagicMock() + mock_inst.get_service_path.return_value = (["node-a", "node-b"], 200) + mock_inst.get_network_topology.return_value = ({"network-id": "admin"}, 200) + mock_conn_cls.return_value = mock_inst + + res = realizer({"slice_id": "slice-reconfig-1"}, action="RECONFIG") + assert res["slice_id"] == "slice-reconfig-1" + assert res["service_path"] == ["node-a", "node-b"] + assert res["network_topology"] == {"network-id": "admin"} diff --git a/src/tests/test_utils.py b/src/tests/test_utils.py index 8b112d7..db9514e 100644 --- a/src/tests/test_utils.py +++ b/src/tests/test_utils.py @@ -51,6 +51,7 @@ def test_load_template_invalid(tmp_path): assert result["success"] is False assert "Template loading error" in result["error"] + def test_dump_templates_enabled(monkeypatch, tmp_path): """Should correctly dump multiple JSON files into src/templates when DUMP_TEMPLATES is enabled.""" templates_dir = tmp_path / "src" / "templates" @@ -73,6 +74,7 @@ def test_dump_templates_enabled(monkeypatch, tmp_path): assert file_path.exists() assert json.loads(file_path.read_text()) == data + def test_dump_templates_disabled(monkeypatch, tmp_path): """Should not write anything into src/templates when DUMP_TEMPLATES is disabled.""" templates_dir = tmp_path / "src" / "templates" @@ -89,6 +91,7 @@ def test_dump_templates_disabled(monkeypatch, tmp_path): for name in ["nbi_template.json", "ietf_template.json", "realizer_template.json"]: assert not (templates_dir / name).exists() + def test_send_response_success(): """Should return success=True and code=200 if the result is True.""" resp, code = send_response(True, data={"k": "v"}) @@ -106,6 +109,7 @@ def test_send_response_error(): assert resp["data"] is None assert "failure" in resp["error"] + def ietf_intent(): """Valid simplified IETF intent.""" return { @@ -116,15 +120,11 @@ def ietf_intent(): "id": "qos1", "slo-policy": { "metric-bound": [ - { - "metric-type": "one-way-bandwidth", - "metric-unit": "kbps", - "bound": 1000 - } + {"metric-type": "one-way-bandwidth", "metric-unit": "kbps", "bound": 1000} ], "availability": 99.9, - "mtu": 1500 - } + "mtu": 1500, + }, } ] }, @@ -137,28 +137,14 @@ def ietf_intent(): "id": "CU", "sdp-ip-address": "10.0.0.1", "service-match-criteria": { - "match-criterion": [{ - "match-type": [ - { - "type": "vlan", - "vlan": [100] - } - ] - }] + "match-criterion": [{"match-type": [{"type": "vlan", "vlan": [100]}]}] }, }, { "id": "DU", "sdp-ip-address": "10.0.0.2", "service-match-criteria": { - "match-criterion": [{ - "match-type": [ - { - "type": "vlan", - "vlan": [100] - } - ] - }] + "match-criterion": [{"match-type": [{"type": "vlan", "vlan": [100]}]}] }, }, ] @@ -186,7 +172,9 @@ def test_build_response_ok(): # Validate constraints requirements = slice_data["requirements"] - assert any(r["constraint_type"] == "one-way-bandwidth[kbps]" and r["constraint_value"] == "1000" for r in requirements) + assert any( + r["constraint_type"] == "one-way-bandwidth[kbps]" and r["constraint_value"] == "1000" for r in requirements + ) assert any(r["constraint_type"] == "availability[%]" and r["constraint_value"] == "99.9" for r in requirements) assert any(r["constraint_type"] == "mtu[bytes]" and r["constraint_value"] == "1500" for r in requirements) @@ -194,7 +182,9 @@ def test_build_response_ok(): def test_build_response_empty_policy(): """Should return a list without constraints if slo-policy is empty.""" intent = ietf_intent() - intent["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["slo-policy"] = {} + intent["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0][ + "slo-policy" + ] = {} response = [] result = build_response(intent, response) @@ -260,4 +250,4 @@ class TestSafeGet: assert safe_get(data, ["a", 0, "b", "c"]) == 42 assert safe_get(data, ["a", 1, "b", "c"]) is None assert safe_get(data, ["x", "y"]) is None - assert safe_get(None, ["a"]) is None \ No newline at end of file + assert safe_get(None, ["a"]) is None diff --git a/src/tests/test_webui.py b/src/tests/test_webui.py index 1491805..bfdf4ef 100644 --- a/src/tests/test_webui.py +++ b/src/tests/test_webui.py @@ -16,6 +16,7 @@ def webui_app(): app.config["DUMMY_MODE"] = True from src.webui.gui import gui_bp + app.register_blueprint(gui_bp) return app @@ -30,6 +31,7 @@ class TestWebUIHelpers: def test_safe_int(self): import src.webui.gui as gui_module + __safe_int = getattr(gui_module, "__safe_int") assert __safe_int("10") == 10 @@ -42,6 +44,7 @@ class TestWebUIHelpers: def test_build_request_ietf(self): import src.webui.gui as gui_module + __build_request_ietf = getattr(gui_module, "__build_request_ietf") res = __build_request_ietf( @@ -52,7 +55,7 @@ class TestWebUIHelpers: latency="10", tolerance="2", latency_version="gaussian", - reliability="99" + reliability="99", ) assert isinstance(res, dict) slice_svc = res["ietf-network-slice-service:network-slice-services"]["slice-service"][0] @@ -61,14 +64,11 @@ class TestWebUIHelpers: def test_build_request(self): import src.webui.gui as gui_module + __build_request = getattr(gui_module, "__build_request") res = __build_request( - ip_version="IPv4", - src_node_ip="10.0.0.1", - dst_node_ip="10.0.0.2", - vlan_id="100", - bandwidth="500" + ip_version="IPv4", src_node_ip="10.0.0.1", dst_node_ip="10.0.0.2", vlan_id="100", bandwidth="500" ) assert res["ip_version"] == "IPv4" assert res["src_node_ip"] == "10.0.0.1" @@ -76,6 +76,7 @@ class TestWebUIHelpers: def test_datos_json_file_not_found(self): import src.webui.gui as gui_module + __datos_json = getattr(gui_module, "__datos_json") with patch("builtins.open", side_effect=FileNotFoundError): @@ -101,17 +102,11 @@ class TestWebUIRoutes: resp_get = webui_client.get("/webui/login") assert resp_get.status_code == 200 - resp_post = webui_client.post("/webui/login", data={ - "username": "admin", - "password": "admin" - }) + resp_post = webui_client.post("/webui/login", data={"username": "admin", "password": "admin"}) assert resp_post.status_code in [200, 302] def test_login_post_invalid(self, webui_client): - resp_post = webui_client.post("/webui/login", data={ - "username": "wrong", - "password": "bad" - }) + resp_post = webui_client.post("/webui/login", data={"username": "wrong", "password": "bad"}) assert resp_post.status_code == 200 assert b"Credenciales incorrectas" in resp_post.data @@ -129,24 +124,25 @@ class TestWebUIRoutes: assert resp_get.status_code == 200 # POST form submit in DUMMY_MODE - resp_post = client.post("/webui/dev", data={ - "ip_version": "IPv4", - "src_node_ipv4": "10.0.0.1", - "dst_node_ipv4": "10.0.0.2", - "vlan_id": "100", - "bandwidth_intent": "1000", - "latency_intent": "10" - }) + resp_post = client.post( + "/webui/dev", + data={ + "ip_version": "IPv4", + "src_node_ipv4": "10.0.0.1", + "dst_node_ipv4": "10.0.0.2", + "vlan_id": "100", + "bandwidth_intent": "1000", + "latency_intent": "10", + }, + ) assert resp_post.status_code == 200 # POST form submit with DUMMY_MODE = False webui_app.config["DUMMY_MODE"] = False with patch("src.webui.gui.NEII_controller") as mock_neii: - resp_post_neii = client.post("/webui/dev", data={ - "ip_version": "IPv4", - "src_node_ipv4": "10.0.0.1", - "dst_node_ipv4": "10.0.0.2" - }) + resp_post_neii = client.post( + "/webui/dev", data={"ip_version": "IPv4", "src_node_ipv4": "10.0.0.1", "dst_node_ipv4": "10.0.0.2"} + ) assert resp_post_neii.status_code == 200 assert mock_neii.return_value.nscNEII.called @@ -159,13 +155,16 @@ class TestWebUIRoutes: mock_resp.raise_for_status.return_value = None with patch("requests.post", return_value=mock_resp): - resp_post = webui_client.post("/webui/generate/tfs", data={ - "src_node_ip": "10.0.0.1", - "dst_node_ip": "10.0.0.2", - "vlan_id": "100", - "latency_intent": "10", - "bandwidth_intent": "1000" - }) + resp_post = webui_client.post( + "/webui/generate/tfs", + data={ + "src_node_ip": "10.0.0.1", + "dst_node_ip": "10.0.0.2", + "vlan_id": "100", + "latency_intent": "10", + "bandwidth_intent": "1000", + }, + ) assert resp_post.status_code in [200, 302] def test_generate_ixia_get_and_post(self, webui_client): @@ -177,69 +176,86 @@ class TestWebUIRoutes: mock_resp.raise_for_status.return_value = None with patch("requests.post", return_value=mock_resp): - resp_post = webui_client.post("/webui/generate/ixia", data={ - "src_node_ip": "10.0.0.1", - "dst_node_ip": "10.0.0.2", - "vlan_id": "100", - "latency_intent": "10", - "bandwidth_intent": "1000", - "tolerance_intent": "2", - "reliability": "99" - }) + resp_post = webui_client.post( + "/webui/generate/ixia", + data={ + "src_node_ip": "10.0.0.1", + "dst_node_ip": "10.0.0.2", + "vlan_id": "100", + "latency_intent": "10", + "bandwidth_intent": "1000", + "tolerance_intent": "2", + "reliability": "99", + }, + ) assert resp_post.status_code in [200, 302] def test_generate_ixia_error(self, webui_client): import requests + with patch("requests.post", side_effect=requests.RequestException("Conn error")): - resp_post = webui_client.post("/webui/generate/ixia", data={ - "src_node_ip": "10.0.0.1", - "dst_node_ip": "10.0.0.2", - "vlan_id": "100", - "latency_intent": "10", - "bandwidth_intent": "1000", - "tolerance_intent": "2", - "reliability": "99" - }) + resp_post = webui_client.post( + "/webui/generate/ixia", + data={ + "src_node_ip": "10.0.0.1", + "dst_node_ip": "10.0.0.2", + "vlan_id": "100", + "latency_intent": "10", + "bandwidth_intent": "1000", + "tolerance_intent": "2", + "reliability": "99", + }, + ) assert resp_post.status_code == 200 assert b"Intent Generation Error" in resp_post.data def test_search_get_and_post_filters(self, webui_client): - sample_slice = [{ - "controller": "TFS", - "intent": { - "ietf-network-slice-service:network-slice-services": { - "slice-service": [{ - "sdps": { - "sdp": [ - { - "sdp-ip-address": "10.0.0.1", - "service-match-criteria": { - "match-criterion": [{"match-type": [{"vlan": [100]}]}] - } - }, + sample_slice = [ + { + "controller": "TFS", + "intent": { + "ietf-network-slice-service:network-slice-services": { + "slice-service": [ + { + "sdps": { + "sdp": [ + { + "sdp-ip-address": "10.0.0.1", + "service-match-criteria": { + "match-criterion": [{"match-type": [{"vlan": [100]}]}] + }, + }, + { + "sdp-ip-address": "10.0.0.2", + "service-match-criteria": { + "match-criterion": [{"match-type": [{"vlan": [100]}]}] + }, + }, + ] + } + } + ], + "slo-sle-templates": { + "slo-sle-template": [ { - "sdp-ip-address": "10.0.0.2", - "service-match-criteria": { - "match-criterion": [{"match-type": [{"vlan": [100]}]}] + "slo-policy": { + "metric-bound": [ + {"metric-type": "one-way-bandwidth", "bound": 1000, "metric-unit": "Mbps"}, + {"metric-type": "one-way-delay-maximum", "bound": 10, "metric-unit": "ms"}, + { + "metric-type": "one-way-delay-variation-maximum", + "bound": 2, + "metric-unit": "ms", + }, + ] } } ] - } - }], - "slo-sle-templates": { - "slo-sle-template": [{ - "slo-policy": { - "metric-bound": [ - {"metric-type": "one-way-bandwidth", "bound": 1000, "metric-unit": "Mbps"}, - {"metric-type": "one-way-delay-maximum", "bound": 10, "metric-unit": "ms"}, - {"metric-type": "one-way-delay-variation-maximum", "bound": 2, "metric-unit": "ms"} - ] - } - }] + }, } - } + }, } - }] + ] mock_get_resp = MagicMock() mock_get_resp.json.return_value = sample_slice @@ -250,17 +266,20 @@ class TestWebUIRoutes: assert resp_get.status_code == 200 # Test POST search filters - for opt, val in [("Source IP", "10.0.0.1"), ("Destiny IP", "10.0.0.2"), ("Controller", "TFS"), ("VLAN", 100)]: - resp_post = webui_client.post("/webui/search", data={ - "search_option": opt, - "search_value": str(val) - }) + for opt, val in [ + ("Source IP", "10.0.0.1"), + ("Destiny IP", "10.0.0.2"), + ("Controller", "TFS"), + ("VLAN", 100), + ]: + resp_post = webui_client.post("/webui/search", data={"search_option": opt, "search_value": str(val)}) assert resp_post.status_code == 200 data = json.loads(resp_post.data) assert "result" in data def test_search_request_exception(self, webui_client): import requests + with patch("requests.get", side_effect=requests.RequestException("API error")): resp = webui_client.get("/webui/search") assert resp.status_code == 200 @@ -272,10 +291,6 @@ class TestWebUIRoutes: assert "result" in data def test_update_ips_route(self, webui_client, tmp_path): - with patch("os.path.exists", return_value=False), \ - patch("src.webui.gui.SRC_PATH", str(tmp_path)): - resp = webui_client.post("/webui/update_ips", json={ - "tfs_ip": "10.10.10.10", - "ixia_ip": "10.10.10.20" - }) + with patch("os.path.exists", return_value=False), patch("src.webui.gui.SRC_PATH", str(tmp_path)): + resp = webui_client.post("/webui/update_ips", json={"tfs_ip": "10.10.10.10", "ixia_ip": "10.10.10.20"}) assert resp.status_code == 200 diff --git a/swagger/E2E_namespace.py b/swagger/E2E_namespace.py index b9f91cd..05e46bb 100644 --- a/swagger/E2E_namespace.py +++ b/swagger/E2E_namespace.py @@ -1,11 +1,11 @@ # Copyright 2022-2026 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 - +# +# 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. @@ -14,249 +14,257 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. +"""E2E Orchestrator namespace definitions and resource controllers.""" + +from __future__ import annotations + +from typing import Any + from flask import request from flask_restx import Namespace, Resource, fields, reqparse + +from src.api.main import Api as E2EHandler from src.main import NSController -from src.api.main import Api -import json -from swagger.models.create_models import create_gpp_nrm_28541_model, create_ietf_network_slice_nbi_yang_model +from swagger.helpers import extract_json_payload +from swagger.models.create_models import ( + create_gpp_nrm_28541_model, + create_ietf_network_slice_nbi_yang_model, +) e2e_ns = Namespace( "E2E", - description="Operations related to transport network slices with E2E Orchestrator" + description="Operations related to transport network slices with E2E Orchestrator", ) - # 3GPP NRM TS28.541 Data models gpp_network_slice_request_model = create_gpp_nrm_28541_model(e2e_ns) # IETF draft-ietf-teas-ietf-network-slice-nbi-yang Data models - slice_ddbb_model, slice_response_model = create_ietf_network_slice_nbi_yang_model(e2e_ns) -# Alert Data Model -alarm_info_model = e2e_ns.model("AlarmInfo", { - "is-alarmed": fields.Boolean(description="Is alarmed", required=True), - "perceived-severity": fields.String(description="Perceived severity", required=True), - "probable-cause": fields.String(description="Probable cause", required=True), - "threshold-low-breached": fields.Float(description="Threshold low breached", required=False), - "threshold-high-breached": fields.Float(description="Threshold high breached", required=False) -}) - -additional_info_model = e2e_ns.model("AdditionalInfo", { - "service-id": fields.String(description="Service ID", required=False), - "kpi-name": fields.String(description="KPI name", required=False), - "measured-value": fields.Float(description="Measured value", required=False) -}) - -tapi_notification_model = e2e_ns.model("TapiNotification", { - "uuid": fields.String(description="UUID", required=True), - "notification-type": fields.String(description="Notification type", required=True), - "event-time-stamp": fields.String(description="Event timestamp", required=True), - "target-object-type": fields.String(description="Target object type", required=False), - "layer-protocol-name": fields.String(description="Layer protocol name", required=False), - "alarm-info": fields.Nested(alarm_info_model, required=False), - "additional-info": fields.Nested(additional_info_model, required=False) -}) - -tapi_notification_item_model = e2e_ns.model("TapiNotificationItem", { - "tapi-notification:notification": fields.Nested(tapi_notification_model, required=True) -}) - -alert_model = e2e_ns.model("Alert", { - "tapi-notification:notification-context": fields.List(fields.Nested(tapi_notification_item_model), required=True) -}) +# Alert Data Models +alarm_info_model = e2e_ns.model( + "AlarmInfo", + { + "is-alarmed": fields.Boolean(description="Is alarmed", required=True), + "perceived-severity": fields.String(description="Perceived severity", required=True), + "probable-cause": fields.String(description="Probable cause", required=True), + "threshold-low-breached": fields.Float(description="Threshold low breached", required=False), + "threshold-high-breached": fields.Float(description="Threshold high breached", required=False), + }, +) + +additional_info_model = e2e_ns.model( + "AdditionalInfo", + { + "service-id": fields.String(description="Service ID", required=False), + "kpi-name": fields.String(description="KPI name", required=False), + "measured-value": fields.Float(description="Measured value", required=False), + }, +) + +tapi_notification_model = e2e_ns.model( + "TapiNotification", + { + "uuid": fields.String(description="UUID", required=True), + "notification-type": fields.String(description="Notification type", required=True), + "event-time-stamp": fields.String(description="Event timestamp", required=True), + "target-object-type": fields.String(description="Target object type", required=False), + "layer-protocol-name": fields.String(description="Layer protocol name", required=False), + "alarm-info": fields.Nested(alarm_info_model, required=False), + "additional-info": fields.Nested(additional_info_model, required=False), + }, +) + +tapi_notification_item_model = e2e_ns.model( + "TapiNotificationItem", + { + "tapi-notification:notification": fields.Nested(tapi_notification_model, required=True), + }, +) + +alert_model = e2e_ns.model( + "Alert", + { + "tapi-notification:notification-context": fields.List( + fields.Nested(tapi_notification_item_model), required=True + ), + }, +) upload_parser = reqparse.RequestParser() -upload_parser.add_argument('file', location='files', type='FileStorage', help="File to upload") -upload_parser.add_argument('json_data', location='form', help="JSON Data in string format") +upload_parser.add_argument("file", location="files", type="FileStorage", help="File to upload") +upload_parser.add_argument("json_data", location="form", help="JSON Data in string format") + + +# ============================================================================= +# Namespace Resource Controllers +# ============================================================================= + -# Namespace Controllers @e2e_ns.route("/slice") class E2ESliceList(Resource): - @e2e_ns.doc(summary="Return all transport network slices", description="Returns all transport network slices from the slice controller.") + """Resource for collection-level slice operations on E2E controller.""" + + @e2e_ns.doc( + summary="Return all transport network slices", + description="Returns all transport network slices from the slice controller.", + ) @e2e_ns.response(200, "Slices returned", slice_ddbb_model) @e2e_ns.response(404, "Transport network slices not found") @e2e_ns.response(500, "Internal server error") - def get(self): - """Retrieve all slices""" + def get(self) -> tuple[dict[str, Any] | list[Any], int]: + """Retrieve all slices.""" controller = NSController(controller_type="E2E") - data, code = Api(controller).get_flows() - return data, code - - @e2e_ns.doc(summary="Submit a transport network slice request", description="This endpoint allows clients to submit transport network slice requests using a JSON payload.") - @e2e_ns.response(201,"Slice created successfully", slice_response_model) + return E2EHandler(controller).get_flows() + + @e2e_ns.doc( + summary="Submit a transport network slice request", + description="This endpoint allows clients to submit transport network slice requests using a JSON payload.", + ) + @e2e_ns.response(201, "Slice created successfully", slice_response_model) @e2e_ns.response(200, "No service to process.") @e2e_ns.response(400, "Invalid request format") @e2e_ns.response(500, "Internal server error") @e2e_ns.expect(upload_parser) - def post(self): - """Submit a new slice request with a file""" - - json_data = None - - # Try to get the JSON data from the uploaded file - uploaded_file = request.files.get('file') - if uploaded_file: - if not uploaded_file.filename.endswith('.json'): - return { - "success": False, - "data": None, - "error": "Only JSON files allowed" - }, 400 - - try: - json_data = json.load(uploaded_file) # Convert file to JSON - except json.JSONDecodeError: - return { - "success": False, - "data": None, - "error": "JSON file not valid" - }, 400 - - # If no file was uploaded, try to get the JSON data from the form - if json_data is None: - raw_json = request.form.get('json_data') - if raw_json: - try: - json_data = json.loads(raw_json) # Convert string to JSON - except json.JSONDecodeError: - return { - "success": False, - "data": None, - "error": "JSON file not valid" - }, 400 - - # If no JSON data was found, return an error - if json_data is None: - return { - "success": False, - "data": None, - "error": "No data sent" - }, 400 - - # Process the JSON data with the NSController + def post(self) -> tuple[dict[str, Any], int]: + """Submit a new slice request with a file or form payload.""" + json_data, err_resp = extract_json_payload(request) + if err_resp is not None: + return err_resp + controller = NSController(controller_type="E2E") - data, code = Api(controller).add_flow(json_data) - return data, code - - @e2e_ns.doc(summary="Delete all transport network slices", description="Deletes all transport network slices from the slice controller.") + return E2EHandler(controller).add_flow(json_data) # type: ignore[arg-type] + + @e2e_ns.doc( + summary="Delete all transport network slices", + description="Deletes all transport network slices from the slice controller.", + ) @e2e_ns.response(204, "All transport network slices deleted successfully.") @e2e_ns.response(500, "Internal server error") - def delete(self): - """Delete all slices""" + def delete(self) -> tuple[dict[str, Any], int]: + """Delete all slices.""" controller = NSController(controller_type="E2E") - data, code = Api(controller).delete_flows() - return data, code + return E2EHandler(controller).delete_flows() @e2e_ns.route("/slice/") @e2e_ns.doc(params={"slice_id": "The ID of the slice to retrieve or modify"}) class E2ESlice(Resource): - @e2e_ns.doc(summary="Return a specific transport network slice", description="Returns specific information related to a slice by providing its id") + """Resource for individual slice operations on E2E controller.""" + + @e2e_ns.doc( + summary="Return a specific transport network slice", + description="Returns specific information related to a slice by providing its id", + ) @e2e_ns.response(200, "Slice returned", slice_ddbb_model) @e2e_ns.response(404, "Transport network slice not found.") @e2e_ns.response(500, "Internal server error") - def get(self, slice_id): - """Retrieve a specific slice""" + def get(self, slice_id: str) -> tuple[dict[str, Any] | list[Any], int]: + """Retrieve a specific slice.""" controller = NSController(controller_type="E2E") - data, code = Api(controller).get_flows(slice_id) - return data, code + return E2EHandler(controller).get_flows(slice_id) - @e2e_ns.doc(summary="Delete a specific transport network slice", description="Deletes a specific transport network slice from the slice controller based on the provided `slice_id`.") + @e2e_ns.doc( + summary="Delete a specific transport network slice", + description="Deletes a specific transport network slice from the slice controller based on the provided `slice_id`.", + ) @e2e_ns.response(204, "Transport network slice deleted successfully.") @e2e_ns.response(404, "Transport network slice not found.") @e2e_ns.response(500, "Internal server error") - def delete(self, slice_id): - """Delete a slice""" + def delete(self, slice_id: str) -> tuple[dict[str, Any], int]: + """Delete a slice.""" controller = NSController(controller_type="E2E") - data, code = Api(controller).delete_flows(slice_id) - return data, code + return E2EHandler(controller).delete_flows(slice_id) @e2e_ns.expect(slice_ddbb_model, validate=True) - @e2e_ns.doc(summary="Modify a specific transport network slice", description="Returns a specific slice that has been modified") + @e2e_ns.doc( + summary="Modify a specific transport network slice", + description="Returns a specific slice that has been modified", + ) @e2e_ns.response(200, "Slice modified", slice_response_model) @e2e_ns.response(404, "Transport network slice not found.") @e2e_ns.response(500, "Internal server error") - def put(self, slice_id): - """Modify a slice""" + def put(self, slice_id: str) -> tuple[dict[str, Any], int]: + """Modify a slice.""" json_data = request.get_json() controller = NSController(controller_type="E2E") - data, code = Api(controller).modify_flow(slice_id, json_data) - return data, code + return E2EHandler(controller).modify_flow(slice_id, json_data) @e2e_ns.route("/alert") class E2EAlertList(Resource): + """Resource for alert collection operations on E2E controller.""" + @e2e_ns.doc(summary="Return all alerts", description="Returns all alerts received.") @e2e_ns.response(200, "Alerts returned") @e2e_ns.response(500, "Internal server error") - def get(self): - """Retrieve all alerts""" + def get(self) -> tuple[dict[str, Any] | list[Any], int]: + """Retrieve all alerts.""" controller = NSController(controller_type="E2E") - data, code = Api(controller).get_alerts() - return data, code + return E2EHandler(controller).get_alerts() @e2e_ns.doc(summary="Receive alerts", description="This endpoint allows E2E to receive alerts.") @e2e_ns.response(201, "Alert saved successfully") @e2e_ns.response(400, "Invalid alert format") @e2e_ns.response(500, "Internal server error") @e2e_ns.expect(alert_model, validate=True) - def post(self): - """Receive an alert""" + def post(self) -> tuple[dict[str, Any], int]: + """Receive an alert.""" json_data = request.get_json() if not json_data: - return { - "success": False, - "data": None, - "error": "No alert data sent" - }, 400 + return {"success": False, "data": None, "error": "No alert data sent"}, 400 controller = NSController(controller_type="E2E") - data, code = Api(controller).receive_alert(json_data) - return data, code + return E2EHandler(controller).receive_alert(json_data) @e2e_ns.doc(summary="Delete all alerts", description="Deletes all alerts.") @e2e_ns.response(204, "All alerts deleted successfully.") @e2e_ns.response(500, "Internal server error") - def delete(self): - """Delete all alerts""" + def delete(self) -> tuple[dict[str, Any], int]: + """Delete all alerts.""" controller = NSController(controller_type="E2E") - data, code = Api(controller).delete_alerts() - return data, code + return E2EHandler(controller).delete_alerts() @e2e_ns.route("/alert/") @e2e_ns.doc(params={"alert_id": "The UUID of the alert to retrieve, modify, or delete"}) class E2EAlertDetail(Resource): - @e2e_ns.doc(summary="Return a specific alert", description="Returns specific information related to an alert by providing its UUID") + """Resource for single alert operations on E2E controller.""" + + @e2e_ns.doc( + summary="Return a specific alert", + description="Returns specific information related to an alert by providing its UUID", + ) @e2e_ns.response(200, "Alert returned") @e2e_ns.response(404, "Alert not found.") @e2e_ns.response(500, "Internal server error") - def get(self, alert_id): - """Retrieve a specific alert""" + def get(self, alert_id: str) -> tuple[dict[str, Any] | list[Any], int]: + """Retrieve a specific alert.""" controller = NSController(controller_type="E2E") - data, code = Api(controller).get_alerts(alert_id) - return data, code + return E2EHandler(controller).get_alerts(alert_id) - @e2e_ns.doc(summary="Delete a specific alert", description="Deletes a specific alert based on the provided `alert_id`.") + @e2e_ns.doc( + summary="Delete a specific alert", + description="Deletes a specific alert based on the provided `alert_id`.", + ) @e2e_ns.response(204, "Alert deleted successfully.") @e2e_ns.response(404, "Alert not found.") @e2e_ns.response(500, "Internal server error") - def delete(self, alert_id): - """Delete an alert""" + def delete(self, alert_id: str) -> tuple[dict[str, Any], int]: + """Delete an alert.""" controller = NSController(controller_type="E2E") - data, code = Api(controller).delete_alerts(alert_id) - return data, code + return E2EHandler(controller).delete_alerts(alert_id) @e2e_ns.expect(alert_model, validate=True) - @e2e_ns.doc(summary="Modify a specific alert", description="Modifies a specific alert and returns the updated alert data") + @e2e_ns.doc( + summary="Modify a specific alert", + description="Modifies a specific alert and returns the updated alert data", + ) @e2e_ns.response(200, "Alert modified") @e2e_ns.response(404, "Alert not found.") @e2e_ns.response(500, "Internal server error") - def put(self, alert_id): - """Modify an alert""" + def put(self, alert_id: str) -> tuple[dict[str, Any], int]: + """Modify an alert.""" json_data = request.get_json() controller = NSController(controller_type="E2E") - data, code = Api(controller).modify_alert(alert_id, json_data) - return data, code - + return E2EHandler(controller).modify_alert(alert_id, json_data) diff --git a/swagger/helpers.py b/swagger/helpers.py new file mode 100644 index 0000000..66ea5db --- /dev/null +++ b/swagger/helpers.py @@ -0,0 +1,64 @@ +# Copyright 2022-2026 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. + +"""Helper utilities for Swagger namespaces and request parsing.""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +from flask import Request + +logger = logging.getLogger(__name__) + + +def extract_json_payload(req: Request) -> tuple[dict[str, Any] | None, tuple[dict[str, Any], int] | None]: + """Extract and validate JSON payload from multipart file upload, form data, or JSON body. + + Args: + req: Incoming Flask request. + + Returns: + A tuple of (parsed_json_dict, None) on success, or + (None, (error_response_dict, status_code)) on failure. + """ + uploaded_file = req.files.get("file") + if uploaded_file: + filename = uploaded_file.filename or "" + if not filename.endswith(".json"): + return None, ({"success": False, "data": None, "error": "Only JSON files allowed"}, 400) + + try: + return json.load(uploaded_file), None + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + logger.debug("Failed to decode uploaded JSON file: %s", exc) + return None, ({"success": False, "data": None, "error": "JSON file not valid"}, 400) + + raw_json = req.form.get("json_data") + if raw_json: + try: + return json.loads(raw_json), None + except json.JSONDecodeError as exc: + logger.debug("Failed to decode json_data form field: %s", exc) + return None, ({"success": False, "data": None, "error": "JSON file not valid"}, 400) + + json_body = req.get_json(silent=True) + if json_body is not None: + return json_body, None + + return None, ({"success": False, "data": None, "error": "No data sent"}, 400) diff --git a/swagger/ixia_namespace.py b/swagger/ixia_namespace.py index 12f122b..d30dbbc 100644 --- a/swagger/ixia_namespace.py +++ b/swagger/ixia_namespace.py @@ -14,16 +14,26 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. +"""IXIA NEII namespace definitions and resource controllers.""" + +from __future__ import annotations + +from typing import Any + from flask import request from flask_restx import Namespace, Resource, reqparse + +from src.api.main import Api as IxiaHandler from src.main import NSController -from src.api.main import Api -import json -from swagger.models.create_models import create_gpp_nrm_28541_model, create_ietf_network_slice_nbi_yang_model +from swagger.helpers import extract_json_payload +from swagger.models.create_models import ( + create_gpp_nrm_28541_model, + create_ietf_network_slice_nbi_yang_model, +) ixia_ns = Namespace( "ixia", - description="Operations related to transport network slices with IXIA NEII" + description="Operations related to transport network slices with IXIA NEII", ) # 3GPP NRM TS28.541 Data models @@ -33,98 +43,100 @@ gpp_network_slice_request_model = create_gpp_nrm_28541_model(ixia_ns) slice_ddbb_model, slice_response_model = create_ietf_network_slice_nbi_yang_model(ixia_ns) upload_parser = reqparse.RequestParser() -upload_parser.add_argument('file', location='files', type='FileStorage', help="Archivo a subir") -upload_parser.add_argument('json_data', location='form', help="Datos JSON en formato string") +upload_parser.add_argument("file", location="files", type="FileStorage", help="File to upload") +upload_parser.add_argument("json_data", location="form", help="JSON Data in string format") + + +# ============================================================================= +# Namespace Resource Controllers +# ============================================================================= -# Namespace Controllers @ixia_ns.route("/slice") class IxiaSliceList(Resource): - @ixia_ns.doc(summary="Return all transport network slices", description="Returns all transport network slices from the slice controller.") + """Resource for collection-level slice operations on IXIA controller.""" + + @ixia_ns.doc( + summary="Return all transport network slices", + description="Returns all transport network slices from the slice controller.", + ) @ixia_ns.response(200, "Slices returned", slice_ddbb_model) @ixia_ns.response(404, "Transport network slices not found") @ixia_ns.response(500, "Internal server error") - def get(self): - """Retrieve all slices""" + def get(self) -> tuple[dict[str, Any] | list[Any], int]: + """Retrieve all slices.""" controller = NSController(controller_type="IXIA") - data, code = Api(controller).get_flows() - return data, code - - @ixia_ns.doc(summary="Submit a transport network slice request", description="This endpoint allows clients to submit transport network slice requests using a JSON payload.") + return IxiaHandler(controller).get_flows() + + @ixia_ns.doc( + summary="Submit a transport network slice request", + description="This endpoint allows clients to submit transport network slice requests using a JSON payload.", + ) @ixia_ns.response(201, "Slice created successfully", slice_response_model) @ixia_ns.response(200, "No service to process.") @ixia_ns.response(400, "Invalid request format") @ixia_ns.response(500, "Internal server error") @ixia_ns.expect(upload_parser) - def post(self): - """Submit a new slice request with a file""" - json_data = None - - uploaded_file = request.files.get('file') - if uploaded_file: - if not uploaded_file.filename.endswith('.json'): - return {"success": False, "data": None, "error": "Only JSON files allowed"}, 400 - try: - json_data = json.load(uploaded_file) - except json.JSONDecodeError: - return {"success": False, "data": None, "error": "JSON file not valid"}, 400 - - if json_data is None: - raw_json = request.form.get('json_data') - if raw_json: - try: - json_data = json.loads(raw_json) - except json.JSONDecodeError: - return {"success": False, "data": None, "error": "JSON file not valid"}, 400 - - if json_data is None: - return {"success": False, "data": None, "error": "No data sent"}, 400 + def post(self) -> tuple[dict[str, Any], int]: + """Submit a new slice request with a file or form payload.""" + json_data, err_resp = extract_json_payload(request) + if err_resp is not None: + return err_resp controller = NSController(controller_type="IXIA") - data, code = Api(controller).add_flow(json_data) - return data, code - - @ixia_ns.doc(summary="Delete all transport network slices", description="Deletes all transport network slices from the slice controller.") + return IxiaHandler(controller).add_flow(json_data) # type: ignore[arg-type] + + @ixia_ns.doc( + summary="Delete all transport network slices", + description="Deletes all transport network slices from the slice controller.", + ) @ixia_ns.response(204, "All transport network slices deleted successfully.") @ixia_ns.response(500, "Internal server error") - def delete(self): - """Delete all slices""" + def delete(self) -> tuple[dict[str, Any], int]: + """Delete all slices.""" controller = NSController(controller_type="IXIA") - data, code = Api(controller).delete_flows() - return data, code + return IxiaHandler(controller).delete_flows() @ixia_ns.route("/slice/") @ixia_ns.doc(params={"slice_id": "The ID of the slice to retrieve or modify"}) class IxiaSlice(Resource): - @ixia_ns.doc(summary="Return a specific transport network slice", description="Returns specific information related to a slice by providing its id") + """Resource for individual slice operations on IXIA controller.""" + + @ixia_ns.doc( + summary="Return a specific transport network slice", + description="Returns specific information related to a slice by providing its id", + ) @ixia_ns.response(200, "Slice returned", slice_ddbb_model) @ixia_ns.response(404, "Transport network slice not found.") @ixia_ns.response(500, "Internal server error") - def get(self, slice_id): - """Retrieve a specific slice""" + def get(self, slice_id: str) -> tuple[dict[str, Any] | list[Any], int]: + """Retrieve a specific slice.""" controller = NSController(controller_type="IXIA") - data, code = Api(controller).get_flows(slice_id) - return data, code + return IxiaHandler(controller).get_flows(slice_id) - @ixia_ns.doc(summary="Delete a specific transport network slice", description="Deletes a specific transport network slice from the slice controller based on the provided `slice_id`.") + @ixia_ns.doc( + summary="Delete a specific transport network slice", + description="Deletes a specific transport network slice from the slice controller based on the provided `slice_id`.", + ) @ixia_ns.response(204, "Transport network slice deleted successfully.") @ixia_ns.response(404, "Transport network slice not found.") @ixia_ns.response(500, "Internal server error") - def delete(self, slice_id): - """Delete a slice""" + def delete(self, slice_id: str) -> tuple[dict[str, Any], int]: + """Delete a slice.""" controller = NSController(controller_type="IXIA") - data, code = Api(controller).delete_flows(slice_id) - return data, code + return IxiaHandler(controller).delete_flows(slice_id) @ixia_ns.expect(slice_ddbb_model, validate=True) - @ixia_ns.doc(summary="Modify a specific transport network slice", description="Returns a specific slice that has been modified") + @ixia_ns.doc( + summary="Modify a specific transport network slice", + description="Returns a specific slice that has been modified", + ) @ixia_ns.response(200, "Slice modified", slice_ddbb_model) @ixia_ns.response(404, "Transport network slice not found.") @ixia_ns.response(500, "Internal server error") - def put(self, slice_id): - """Modify a slice""" + def put(self, slice_id: str) -> tuple[dict[str, Any], int]: + """Modify a slice.""" json_data = request.get_json() controller = NSController(controller_type="IXIA") - data, code = Api(controller).modify_flow(slice_id, json_data) - return data, code \ No newline at end of file + return IxiaHandler(controller).modify_flow(slice_id, json_data) diff --git a/swagger/models/create_models.py b/swagger/models/create_models.py index 6c2a3f9..e8ee29f 100644 --- a/swagger/models/create_models.py +++ b/swagger/models/create_models.py @@ -14,34 +14,38 @@ #This file is an original contribution from Telefonica Innovación Digital S.L. -from flask_restx import fields +"""Swagger model builders for 3GPP NRM TS28.541 and IETF Network Slice NBI YANG.""" -def create_gpp_nrm_28541_model(slice_ns): - # 3GPP NRM TS28.541 Data models +from __future__ import annotations + +from flask_restx import Model, Namespace, fields + + +def create_gpp_nrm_28541_model(slice_ns: Namespace) -> Model: + """Create and register 3GPP NRM TS28.541 models within given namespace. + + Args: + slice_ns: Target Flask-RESTX namespace. + + Returns: + The root NetworkSliceRequest model. + """ logical_interface_info_model = slice_ns.model( "LogicalInterfaceInfo", { - "logicalInterfaceType": fields.String( - description="Type of logical interface", example="VLAN" - ), - "logicalInterfaceId": fields.String( - description="Identifier of the logical interface", example="300" - ), + "logicalInterfaceType": fields.String(description="Type of logical interface", example="VLAN"), + "logicalInterfaceId": fields.String(description="Identifier of the logical interface", example="300"), }, ) ep_transport_model = slice_ns.model( "EpTransport", { - "IpAddress": fields.String( - description="IP address of the endpoint", example="100.1.1.1" - ), + "IpAddress": fields.String(description="IP address of the endpoint", example="100.1.1.1"), "logicalInterfaceInfo": fields.Nested( logical_interface_info_model, description="Logical interface details" ), - "NextHopInfo": fields.String( - description="Next hop information", example="100.1.1.254" - ), + "NextHopInfo": fields.String(description="Next hop information", example="100.1.1.254"), "qosProfile": fields.String(description="QoS profile", example="5QI100"), "EpApplicationRef": fields.List( fields.String, @@ -54,12 +58,8 @@ def create_gpp_nrm_28541_model(slice_ns): slice_profile_model = slice_ns.model( "SliceProfile", { - "sliceProfileId": fields.String( - description="ID of the slice profile", example="TopId" - ), - "pLMNInfoList": fields.Raw( - description="PLMN information list (nullable)", example=None - ), + "sliceProfileId": fields.String(description="ID of the slice profile", example="TopId"), + "pLMNInfoList": fields.Raw(description="PLMN information list (nullable)", example=None), "TopSliceSubnetProfile": fields.Nested( slice_ns.model( "TopSliceSubnetProfile", @@ -68,12 +68,8 @@ def create_gpp_nrm_28541_model(slice_ns): slice_ns.model( "DLThpt", { - "GuaThpt": fields.Integer( - description="Guaranteed throughput", example=200 - ), - "MaxThpt": fields.Integer( - description="Maximum throughput", example=400 - ), + "GuaThpt": fields.Integer(description="Guaranteed throughput", example=200), + "MaxThpt": fields.Integer(description="Maximum throughput", example=400), }, ), description="Downlink throughput details", @@ -82,22 +78,14 @@ def create_gpp_nrm_28541_model(slice_ns): slice_ns.model( "ULThpt", { - "GuaThpt": fields.Integer( - description="Guaranteed throughput", example=200 - ), - "MaxThpt": fields.Integer( - description="Maximum throughput", example=400 - ), + "GuaThpt": fields.Integer(description="Guaranteed throughput", example=200), + "MaxThpt": fields.Integer(description="Maximum throughput", example=400), }, ), description="Uplink throughput details", ), - "dLLatency": fields.Integer( - description="Downlink latency", example=20 - ), - "uLLatency": fields.Integer( - description="Uplink latency", example=20 - ), + "dLLatency": fields.Integer(description="Downlink latency", example=20), + "uLLatency": fields.Integer(description="Uplink latency", example=20), }, ), description="Top slice subnet profile details", @@ -108,21 +96,11 @@ def create_gpp_nrm_28541_model(slice_ns): subnet_model = slice_ns.model( "Subnet", { - "operationalState": fields.String( - description="Operational state of the subnet", example="" - ), - "administrativeState": fields.String( - description="Administrative state of the subnet", example="" - ), - "nsInfo": fields.Raw( - description="Network slice information (object)", example={} - ), - "managedFunctionRef": fields.List( - fields.Raw, description="Managed function references", example=[] - ), - "networkSliceSubnetType": fields.String( - description="Type of the subnet", example="TOP_SLICESUBNET" - ), + "operationalState": fields.String(description="Operational state of the subnet", example=""), + "administrativeState": fields.String(description="Administrative state of the subnet", example=""), + "nsInfo": fields.Raw(description="Network slice information (object)", example={}), + "managedFunctionRef": fields.List(fields.Raw, description="Managed function references", example=[]), + "networkSliceSubnetType": fields.String(description="Type of the subnet", example="TOP_SLICESUBNET"), "SliceProfileList": fields.List( fields.Nested(slice_profile_model), description="List of slice profiles for the subnet", @@ -142,21 +120,11 @@ def create_gpp_nrm_28541_model(slice_ns): subnet_model, description="Top-level slice details", ), - "TopSliceSubnet1": fields.Nested( - subnet_model, description="Details of the top slice subnet" - ), - "CNSliceSubnet1": fields.Nested( - subnet_model, description="Details of the CN slice subnet" - ), - "RANSliceSubnet1": fields.Nested( - subnet_model, description="Details of the RAN slice subnet" - ), - "MidhaulSliceSubnet1": fields.Nested( - subnet_model, description="Details of the midhaul slice subnet" - ), - "BackhaulSliceSubnet1": fields.Nested( - subnet_model, description="Details of the backhaul slice subnet" - ), + "TopSliceSubnet1": fields.Nested(subnet_model, description="Details of the top slice subnet"), + "CNSliceSubnet1": fields.Nested(subnet_model, description="Details of the CN slice subnet"), + "RANSliceSubnet1": fields.Nested(subnet_model, description="Details of the RAN slice subnet"), + "MidhaulSliceSubnet1": fields.Nested(subnet_model, description="Details of the midhaul slice subnet"), + "BackhaulSliceSubnet1": fields.Nested(subnet_model, description="Details of the backhaul slice subnet"), "EpTransport CU-UP1": fields.Nested( ep_transport_model, description="Details of the transport endpoint CU-UP1" ), @@ -164,12 +132,8 @@ def create_gpp_nrm_28541_model(slice_ns): slice_ns.model( "EPF1U", { - "localAddress": fields.String( - description="Local address", example="100.1.1.2" - ), - "remoteAddress": fields.String( - description="Remote address", example="1.1.3.2" - ), + "localAddress": fields.String(description="Local address", example="100.1.1.2"), + "remoteAddress": fields.String(description="Remote address", example="1.1.3.2"), "epTransportRef": fields.List( fields.String, description="References to transport endpoints", @@ -183,120 +147,230 @@ def create_gpp_nrm_28541_model(slice_ns): ) return gpp_network_slice_request_model -def create_ietf_network_slice_nbi_yang_model(slice_ns): - # IETF draft-ietf-teas-ietf-network-slice-nbi-yang Data models - slo_policy_model = slice_ns.model('SloPolicy', { - 'metric-bound': fields.List(fields.Nested(slice_ns.model('MetricBound', { - 'metric-type': fields.String(), - 'metric-unit': fields.String(), - 'bound': fields.Integer() - }))) - }) - sle_policy_model = slice_ns.model('SlePolicy', { - 'security': fields.String(), - 'isolation': fields.String(), - 'path-constraints': fields.Nested(slice_ns.model('PathConstraints', { - 'service-functions': fields.String(), - 'diversity': fields.Nested(slice_ns.model('Diversity', { - 'diversity-type': fields.String() - })) - })) - }) +def create_ietf_network_slice_nbi_yang_model(slice_ns: Namespace) -> tuple[Model, Model]: + """Create and register IETF draft-ietf-teas-ietf-network-slice-nbi-yang models. - slo_sle_template_model = slice_ns.model('SloSleTemplate', { - 'id': fields.String(), - 'description': fields.String(), - 'slo-policy': fields.Nested(slo_policy_model), - 'sle-policy': fields.Nested(sle_policy_model) - }) + Args: + slice_ns: Target Flask-RESTX namespace. - service_match_criteria_model = slice_ns.model('ServiceMatchCriteria', { - 'match-criterion': fields.List(fields.Nested(slice_ns.model('MatchCriterion', { - 'index': fields.Integer(), - 'match-type': fields.List(fields.Nested(slice_ns.model('MatchType', { - 'type': fields.String(), - 'VLAN': fields.List(fields.Integer()) - }))), - 'target-connection-group-id': fields.String() - }))) - }) + Returns: + Tuple of (slice_ddbb_model, slice_response_model). + """ + slo_policy_model = slice_ns.model( + "SloPolicy", + { + "metric-bound": fields.List( + fields.Nested( + slice_ns.model( + "MetricBound", + { + "metric-type": fields.String(), + "metric-unit": fields.String(), + "bound": fields.Integer(), + }, + ) + ) + ) + }, + ) - attachment_circuit_model = slice_ns.model('AttachmentCircuit', { - 'id': fields.String(), - 'ac-ipv4-address': fields.String(), - 'ac-ipv4-prefix-length': fields.Integer(), - 'sdp-peering': fields.Nested(slice_ns.model('SdpPeering', { - 'peer-sap-id': fields.String() - })), - 'status': fields.String() - }) + sle_policy_model = slice_ns.model( + "SlePolicy", + { + "security": fields.String(), + "isolation": fields.String(), + "path-constraints": fields.Nested( + slice_ns.model( + "PathConstraints", + { + "service-functions": fields.String(), + "diversity": fields.Nested( + slice_ns.model( + "Diversity", + {"diversity-type": fields.String()}, + ) + ), + }, + ) + ), + }, + ) - sdp_model = slice_ns.model('Sdp', { - 'id': fields.String(), - 'geo-location': fields.String(), - 'node-id': fields.String(), - 'sdp-ip-address': fields.String(), - 'tp-ref': fields.String(), - 'service-match-criteria': fields.Nested(service_match_criteria_model), - 'incoming-qos-policy': fields.String(), - 'outgoing-qos-policy': fields.String(), - 'sdp-peering': fields.Nested(slice_ns.model('SdpPeering', { - 'peer-sap-id': fields.String(), - 'protocols': fields.String() - })), - 'ac-svc-ref': fields.List(fields.String()), - 'attachment-circuits': fields.List(fields.Nested(attachment_circuit_model)), - 'status': fields.String(), - 'sdp-monitoring': fields.String() - }) + slo_sle_template_model = slice_ns.model( + "SloSleTemplate", + { + "id": fields.String(), + "description": fields.String(), + "slo-policy": fields.Nested(slo_policy_model), + "sle-policy": fields.Nested(sle_policy_model), + }, + ) + + service_match_criteria_model = slice_ns.model( + "ServiceMatchCriteria", + { + "match-criterion": fields.List( + fields.Nested( + slice_ns.model( + "MatchCriterion", + { + "index": fields.Integer(), + "match-type": fields.List( + fields.Nested( + slice_ns.model( + "MatchType", + { + "type": fields.String(), + "VLAN": fields.List(fields.Integer()), + }, + ) + ) + ), + "target-connection-group-id": fields.String(), + }, + ) + ) + ) + }, + ) + + attachment_circuit_model = slice_ns.model( + "AttachmentCircuit", + { + "id": fields.String(), + "ac-ipv4-address": fields.String(), + "ac-ipv4-prefix-length": fields.Integer(), + "sdp-peering": fields.Nested( + slice_ns.model( + "SdpPeering", + {"peer-sap-id": fields.String()}, + ) + ), + "status": fields.String(), + }, + ) - connection_group_model = slice_ns.model('ConnectionGroup', { - 'id': fields.String(), - 'connectivity-type': fields.String(), - 'connectivity-construct': fields.List(fields.Nested(slice_ns.model('ConnectivityConstruct', { - 'id': fields.Integer(), - 'a2a-sdp': fields.List(fields.Nested(slice_ns.model('A2ASdp', { - 'sdp-id': fields.String() - }))) - }))), - 'status': fields.String() - }) + sdp_model = slice_ns.model( + "Sdp", + { + "id": fields.String(), + "geo-location": fields.String(), + "node-id": fields.String(), + "sdp-ip-address": fields.String(), + "tp-ref": fields.String(), + "service-match-criteria": fields.Nested(service_match_criteria_model), + "incoming-qos-policy": fields.String(), + "outgoing-qos-policy": fields.String(), + "sdp-peering": fields.Nested( + slice_ns.model( + "SdpPeeringDetails", + { + "peer-sap-id": fields.String(), + "protocols": fields.String(), + }, + ) + ), + "ac-svc-ref": fields.List(fields.String()), + "attachment-circuits": fields.List(fields.Nested(attachment_circuit_model)), + "status": fields.String(), + "sdp-monitoring": fields.String(), + }, + ) - slice_service_model = slice_ns.model('SliceService', { - 'id': fields.String(), - 'description': fields.String(), - 'service-tags': fields.Nested(slice_ns.model('ServiceTags', { - 'tag-type': fields.Nested(slice_ns.model('TagType', { - 'tag-type': fields.String(), - 'value': fields.String() - })) - })), - 'slo-sle-template': fields.String(), - 'status': fields.String(), - 'sdps': fields.Nested(slice_ns.model('Sdps', { - 'sdp': fields.List(fields.Nested(sdp_model)) - })), - 'connection-groups': fields.Nested(slice_ns.model('ConnectionGroups', { - 'connection-group': fields.List(fields.Nested(connection_group_model)) - })) - }) + connection_group_model = slice_ns.model( + "ConnectionGroup", + { + "id": fields.String(), + "connectivity-type": fields.String(), + "connectivity-construct": fields.List( + fields.Nested( + slice_ns.model( + "ConnectivityConstruct", + { + "id": fields.Integer(), + "a2a-sdp": fields.List( + fields.Nested( + slice_ns.model( + "A2ASdp", + {"sdp-id": fields.String()}, + ) + ) + ), + }, + ) + ) + ), + "status": fields.String(), + }, + ) - ietf_network_slice_request_model = slice_ns.model('NetworkSliceService', { - 'ietf-network-slice-service:network-slice-services': fields.Nested(slice_ns.model('NetworkSliceServices', { - 'slo-sle-templates': fields.Nested(slice_ns.model('SloSleTemplates', { - 'slo-sle-template': fields.List(fields.Nested(slo_sle_template_model)) - })), - 'slice-service': fields.List(fields.Nested(slice_service_model)) - })) - }) + slice_service_model = slice_ns.model( + "SliceService", + { + "id": fields.String(), + "description": fields.String(), + "service-tags": fields.Nested( + slice_ns.model( + "ServiceTags", + { + "tag-type": fields.Nested( + slice_ns.model( + "TagType", + { + "tag-type": fields.String(), + "value": fields.String(), + }, + ) + ) + }, + ) + ), + "slo-sle-template": fields.String(), + "status": fields.String(), + "sdps": fields.Nested( + slice_ns.model( + "Sdps", + {"sdp": fields.List(fields.Nested(sdp_model))}, + ) + ), + "connection-groups": fields.Nested( + slice_ns.model( + "ConnectionGroups", + {"connection-group": fields.List(fields.Nested(connection_group_model))}, + ) + ), + }, + ) - slice_ddbb_model = slice_ns.model('ddbb_model', { - 'slice_id': fields.String(), - 'intent': fields.List(fields.Nested(ietf_network_slice_request_model)), - 'controller': fields.String() - }) + ietf_network_slice_request_model = slice_ns.model( + "NetworkSliceService", + { + "ietf-network-slice-service:network-slice-services": fields.Nested( + slice_ns.model( + "NetworkSliceServices", + { + "slo-sle-templates": fields.Nested( + slice_ns.model( + "SloSleTemplates", + {"slo-sle-template": fields.List(fields.Nested(slo_sle_template_model))}, + ) + ), + "slice-service": fields.List(fields.Nested(slice_service_model)), + }, + ) + ) + }, + ) + slice_ddbb_model = slice_ns.model( + "ddbb_model", + { + "slice_id": fields.String(), + "intent": fields.List(fields.Nested(ietf_network_slice_request_model)), + "controller": fields.String(), + }, + ) slice_response_model = slice_ns.model( "SliceResponse", @@ -311,32 +385,42 @@ def create_ietf_network_slice_nbi_yang_model(slice_ns): slice_ns.model( "SliceDetails", { - "id": fields.String(description="Slice ID", example="slice-service-11327140-7361-41b3-aa45-e84a7fb40be9"), + "id": fields.String( + description="Slice ID", + example="slice-service-11327140-7361-41b3-aa45-e84a7fb40be9", + ), "source": fields.String(description="Source IP", example="10.60.11.3"), - "destination": fields.String(description="Destination IP", example="10.60.60.105"), + "destination": fields.String( + description="Destination IP", example="10.60.60.105" + ), "vlan": fields.String(description="VLAN ID", example="100"), "requirements": fields.List( fields.Nested( slice_ns.model( "SliceRequirement", { - "constraint_type": fields.String(description="Type of constraint", example="one-way-bandwidth[kbps]"), - "constraint_value": fields.String(description="Constraint value", example="2000") - } + "constraint_type": fields.String( + description="Type of constraint", + example="one-way-bandwidth[kbps]", + ), + "constraint_value": fields.String( + description="Constraint value", example="2000" + ), + }, ) ), - description="List of requirements for the slice" - ) - } + description="List of requirements for the slice", + ), + }, ) ), - description="List of slices" + description="List of slices", ), "setup_time": fields.Float(description="Slice setup time in milliseconds", example=12.57), - } + }, ) ), - "error": fields.String(description="Error message if request failed", example=None) - } + "error": fields.String(description="Error message if request failed", example=None), + }, ) - return slice_ddbb_model, slice_response_model \ No newline at end of file + return slice_ddbb_model, slice_response_model diff --git a/swagger/models/create_models_restconf.py b/swagger/models/create_models_restconf.py index 0a4c0ae..c983e3c 100644 --- a/swagger/models/create_models_restconf.py +++ b/swagger/models/create_models_restconf.py @@ -14,417 +14,621 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. -from flask_restx import fields - - -def create_ietf_network_slice_nbi_yang_model(slice_ns): - - - # ============================================== - # Modelos base y reutilizables - # ============================================== - - # Status models - admin_status_model = slice_ns.model('AdminStatus', { - 'status': fields.String(description='Administrative service status'), - 'last-change': fields.String(description='Indicates the actual date and time of the service status change') - }) - - oper_status_model = slice_ns.model('OperStatus', { - 'status': fields.String(description='Operational status'), - 'last-change': fields.String(description='Indicates the actual date and time of the service status change') - }) - - status_model = slice_ns.model('Status', { - 'admin-status': fields.Nested(admin_status_model, description='Administrative service status'), - 'oper-status': fields.Nested(oper_status_model, description='Operational service status') - }) - - # SLO Policy models - metric_bound_model = slice_ns.model('MetricBound', { - 'metric-type': fields.String(required=True, description='Identifies SLO metric type of the Slice Service'), - 'metric-unit': fields.String(description='The metric unit of the parameter'), - 'value-description': fields.String(description='The description of the provided value'), - 'percentile-value': fields.Float(description='The percentile value of the metric type'), - 'bound': fields.Integer(description='The bound on the Slice Service connection metric') - }) - - slo_policy_model = slice_ns.model('SLOPolicy', { - 'metric-bound': fields.List(fields.Nested(metric_bound_model), description='List of Slice Service metric bounds'), - 'availability': fields.String(description='Service availability level'), - 'mtu': fields.Integer(description='Maximum length of Layer 2 data packets of the Slice Service') - }) - - # SLE Policy models - diversity_model = slice_ns.model('Diversity', { - 'diversity-type': fields.String(description='The type of disjointness on Slice Service') - }) - - path_constraints_model = slice_ns.model('PathConstraints', { - 'service-functions': fields.Raw(description='Container for the policy of service function'), - 'diversity': fields.Nested(diversity_model, description='Container for the policy of disjointness') - }) - - sle_policy_model = slice_ns.model('SLEPolicy', { - 'security': fields.List(fields.String, description='The security functions'), - 'isolation': fields.List(fields.String, description='The Slice Service isolation requirement'), - 'max-occupancy-level': fields.Integer(description='The maximal occupancy level'), - 'path-constraints': fields.Nested(path_constraints_model, description='Container for the policy of path constraints') - }) - - # Combined SLO/SLE policy - service_slo_sle_policy_model = slice_ns.model('ServiceSLOSLEPolicy', { - 'description': fields.String(description='Describes the SLO and SLE policy'), - 'slo-policy': fields.Nested(slo_policy_model, description='Contains the SLO policy'), - 'sle-policy': fields.Nested(sle_policy_model, description='Contains the SLE policy') - }) - - # ============================================== - # SLO/SLE Template models - # ============================================== - - slo_sle_template_model = slice_ns.model('SLOSLETemplate', { - 'id': fields.String(required=True, description='Identification of the SLO and SLE template'), - 'description': fields.String(description='Describes the SLO and SLE policy template'), - 'template-ref': fields.String(description='The reference to a standard template'), - 'slo-policy': fields.Nested(slo_policy_model, description='Contains the SLO policy'), - 'sle-policy': fields.Nested(sle_policy_model, description='Contains the SLE policy') - }) - - slo_sle_templates_model = slice_ns.model('SLOSLETemplates', { - 'slo-sle-template': fields.List(fields.Nested(slo_sle_template_model), description='List for SLO and SLE template identifiers') - }) - - # ============================================== - # QoS Policy models - # ============================================== - - cos_model = slice_ns.model('CoS', { - 'cos-id': fields.Integer(required=True, description='Identifier of the CoS'), - 'cir': fields.Integer(description='Committed Information Rate'), - 'cbs': fields.Integer(description='Committed Burst Size'), - 'eir': fields.Integer(description='Excess Information Rate'), - 'ebs': fields.Integer(description='Excess Burst Size'), - 'pir': fields.Integer(description='Peak Information Rate'), - 'pbs': fields.Integer(description='Peak Burst Size') - }) - - classes_model = slice_ns.model('Classes', { - 'cos': fields.List(fields.Nested(cos_model), description='List of Class of Services') - }) - - rate_limits_model = slice_ns.model('RateLimits', { - 'cir': fields.Integer(description='Committed Information Rate'), - 'cbs': fields.Integer(description='Committed Burst Size'), - 'eir': fields.Integer(description='Excess Information Rate'), - 'ebs': fields.Integer(description='Excess Burst Size'), - 'pir': fields.Integer(description='Peak Information Rate'), - 'pbs': fields.Integer(description='Peak Burst Size'), - 'classes': fields.Nested(classes_model, description='Container for service class bandwidth control') - }) - - qos_policy_model = slice_ns.model('QoSPolicy', { - 'qos-policy-name': fields.String(description='The name of the QoS policy'), - 'rate-limits': fields.Nested(rate_limits_model, description='Container for the asymmetric traffic control') - }) - - # ============================================== - # SDP Peering models - # ============================================== - - sdp_peering_model = slice_ns.model('SDPPeering', { - 'peer-sap-id': fields.List(fields.String, description='Indicates the reference to the remote endpoints'), - 'protocols': fields.Raw(description='Serves as an augmentation target') - }) - - # ============================================== - # Attachment Circuit models - # ============================================== - - ac_tag_model = slice_ns.model('ACTag', { - 'tag-type': fields.String(required=True, description='The Attachment Circuit tag type'), - 'tag-type-value': fields.List(fields.String, description='The Attachment Circuit tag values') - }) - - ac_tags_model = slice_ns.model('ACTags', { - 'ac-tag': fields.List(fields.Nested(ac_tag_model), description='The Attachment Circuit tag list') - }) - - attachment_circuit_model = slice_ns.model('AttachmentCircuit', { - 'id': fields.String(required=True, description='The identifier of Attachment Circuit'), - 'description': fields.String(description='The Attachment Circuit description'), - 'ac-svc-ref': fields.String(description='A reference to the AC service'), - 'ac-node-id': fields.String(description='The Attachment Circuit node ID'), - 'ac-tp-id': fields.String(description='The termination port ID'), - 'ac-ipv4-address': fields.String(description='The IPv4 address of the AC'), - 'ac-ipv4-prefix-length': fields.Integer(description='The length of the IPv4 subnet prefix'), - 'ac-ipv6-address': fields.String(description='The IPv6 address of the AC'), - 'ac-ipv6-prefix-length': fields.Integer(description='The length of IPv6 subnet prefix'), - 'mtu': fields.Integer(description='Maximum size of the Slice Service Layer 2 data packet'), - 'ac-tags': fields.Nested(ac_tags_model, description='Container for the Attachment Circuit tags'), - 'incoming-qos-policy': fields.Nested(qos_policy_model, description='The QoS policy imposed on ingress direction'), - 'outgoing-qos-policy': fields.Nested(qos_policy_model, description='The QoS policy imposed on egress direction'), - 'sdp-peering': fields.Nested(sdp_peering_model, description='Describes SDP peering attributes'), - 'status': fields.Nested(status_model, description='Service status') - }) - - attachment_circuits_model = slice_ns.model('AttachmentCircuits', { - 'attachment-circuit': fields.List(fields.Nested(attachment_circuit_model), description='List of Attachment Circuits') - }) - - # ============================================== - # Service Match Criteria models - # ============================================== - - match_type_model = slice_ns.model('MatchType', { - 'type': fields.String(required=True, description='Indicates the match type of the entry'), - 'interface-name': fields.List(fields.String, description='Physical interface name for the match criteria'), - 'vlan': fields.List(fields.Integer, description='VLAN ID value for the match criteria'), - 'label': fields.List(fields.String, description='MPLS label value for the match criteria'), - 'ip-prefix': fields.List(fields.String, description='IP prefix value for the match criteria'), - 'dscp': fields.List(fields.Integer, description='DSCP value for the match criteria'), - 'acl-name': fields.List(fields.String, description='ACL name value for the match criteria') - }) - - match_criterion_model = slice_ns.model('MatchCriterion', { - 'index': fields.Integer(required=True, description='The identifier of a match criteria'), - 'match-type': fields.List(fields.Nested(match_type_model), description='List of the Slice Service traffic match types'), - 'target-connection-group-id': fields.String(description='Reference to the Slice Service Connection Group'), - 'connection-group-sdp-role': fields.String(description='Specifies the role of SDP in the Connection Group'), - 'target-connectivity-construct-id': fields.String(description='Reference to a Network Slice Connectivity Construct') - }) - - service_match_criteria_model = slice_ns.model('ServiceMatchCriteria', { - 'match-criterion': fields.List(fields.Nested(match_criterion_model), description='List of the Slice Service traffic match criteria') - }) - - # ============================================== - # SDP Monitoring models - # ============================================== - - sdp_monitoring_model = slice_ns.model('SDPMonitoring', { - 'incoming-bw-value': fields.Integer(description='Absolute value of the incoming bandwidth'), - 'incoming-bw-percent': fields.Integer(description='Percentage of the incoming bandwidth'), - 'outgoing-bw-value': fields.Integer(description='Absolute value of the outgoing bandwidth'), - 'outgoing-bw-percent': fields.Integer(description='Percentage of the outgoing bandwidth') - }) - - # ============================================== - # Geo Location models - # ============================================== - - velocity_model = slice_ns.model('Velocity', { - 'v-north': fields.Float(description='Rate of change towards true north'), - 'v-east': fields.Float(description='Rate of change perpendicular to the right of true north'), - 'v-up': fields.Float(description='Rate of change away from the center of mass') - }) - - geodetic_system_model = slice_ns.model('GeodeticSystem', { - 'geodetic-datum': fields.String(description='A geodetic-datum defining the meaning of latitude, longitude, and height'), - 'coord-accuracy': fields.Float(description='The accuracy of the latitude/longitude pair'), - 'height-accuracy': fields.Float(description='The accuracy of the height value') - }) - - reference_frame_model = slice_ns.model('ReferenceFrame', { - 'alternate-system': fields.String(description='The system in which the astronomical body is defined'), - 'astronomical-body': fields.String(description='An astronomical body as named by the IAU'), - 'geodetic-system': fields.Nested(geodetic_system_model, description='The geodetic system of the location data') - }) - - geo_location_model = slice_ns.model('GeoLocation', { - 'reference-frame': fields.Nested(reference_frame_model, description='The Frame of Reference for the location values'), - 'latitude': fields.Float(description='The latitude value on the astronomical body'), - 'longitude': fields.Float(description='The longitude value on the astronomical body'), - 'height': fields.Float(description='Height from a reference 0 value'), - 'x': fields.Float(description='The X value as defined by the reference-frame'), - 'y': fields.Float(description='The Y value as defined by the reference-frame'), - 'z': fields.Float(description='The Z value as defined by the reference-frame'), - 'velocity': fields.Nested(velocity_model, description='If the object is in motion, the velocity vector'), - 'timestamp': fields.String(description='Reference time when location was recorded'), - 'valid-until': fields.String(description='The timestamp for which this geo-location is valid until') - }) - - # ============================================== - # SDP models - # ============================================== - - sdp_model = slice_ns.model('SDP', { - 'id': fields.String(required=True, description='The unique identifier of the SDP'), - 'description': fields.String(description='Provides a description of the SDP'), - 'geo-location': fields.Nested(geo_location_model, description='A location on an astronomical body'), - 'node-id': fields.String(description='A unique identifier of an edge node of the SDP'), - 'sdp-ip-address': fields.List(fields.String, description='IPv4 or IPv6 address of the SDP'), - 'tp-ref': fields.String(description='A reference to Termination Point in the custom topology'), - 'service-match-criteria': fields.Nested(service_match_criteria_model, description='Describes the Slice Service match criteria'), - 'incoming-qos-policy': fields.Nested(qos_policy_model, description='The QoS policy imposed on ingress direction'), - 'outgoing-qos-policy': fields.Nested(qos_policy_model, description='The QoS policy imposed on egress direction'), - 'sdp-peering': fields.Nested(sdp_peering_model, description='Describes SDP peering attributes'), - 'ac-svc-ref': fields.List(fields.String, description='A reference to the ACs'), - 'ce-mode': fields.Boolean(description='When true, this indicates the SDP is located on the CE'), - 'attachment-circuits': fields.Nested(attachment_circuits_model, description='List of Attachment Circuits'), - 'status': fields.Nested(status_model, description='Service status'), - 'sdp-monitoring': fields.Nested(sdp_monitoring_model, description='Container for SDP monitoring metrics') - }) - - sdps_model = slice_ns.model('SDPs', { - 'sdp': fields.List(fields.Nested(sdp_model), description='List of SDPs in this Slice Service') - }) - - # ============================================== - # Connectivity Construct models - # ============================================== - - connectivity_construct_monitoring_model = slice_ns.model('ConnectivityConstructMonitoring', { - 'one-way-min-delay': fields.Integer(description='One-way minimum delay or latency'), - 'one-way-max-delay': fields.Integer(description='One-way maximum delay or latency'), - 'one-way-delay-variation': fields.Integer(description='One-way delay variation'), - 'one-way-packet-loss': fields.Float(description='The ratio of packets dropped to packets transmitted'), - 'two-way-min-delay': fields.Integer(description='Two-way minimum delay or latency'), - 'two-way-max-delay': fields.Integer(description='Two-way maximum delay or latency'), - 'two-way-delay-variation': fields.Integer(description='Two-way delay variation'), - 'two-way-packet-loss': fields.Float(description='The ratio of packets dropped to packets transmitted') - }) - - a2a_sdp_model = slice_ns.model('A2ASDP', { - 'sdp-id': fields.String(required=True, description='Reference to an SDP'), - 'slo-sle-template': fields.String(description='Standard SLO and SLE template to be used'), - 'service-slo-sle-policy': fields.Nested(service_slo_sle_policy_model, description='Contains the SLO and SLE policy') - }) - - connectivity_construct_model = slice_ns.model('ConnectivityConstruct', { - 'id': fields.String(required=True, description='The Connectivity Construct identifier'), - 'p2p-sender-sdp': fields.String(description='Reference to a sender SDP'), - 'p2p-receiver-sdp': fields.String(description='Reference to a receiver SDP'), - 'p2mp-sender-sdp': fields.String(description='Reference to a sender SDP'), - 'p2mp-receiver-sdp': fields.List(fields.String, description='Reference to a receiver SDP'), - 'a2a-sdp': fields.List(fields.Nested(a2a_sdp_model), description='List of included A2A SDPs'), - 'slo-sle-template': fields.String(description='Standard SLO and SLE template to be used'), - 'service-slo-sle-policy': fields.Nested(service_slo_sle_policy_model, description='Contains the SLO and SLE policy'), - 'service-slo-sle-policy-override': fields.String(description='SLO/SLE policy override option'), - 'status': fields.Nested(status_model, description='Service status'), - 'connectivity-construct-monitoring': fields.Nested(connectivity_construct_monitoring_model, description='SLO status per Connectivity Construct') - }) - - # ============================================== - # Connection Group models - # ============================================== - - connection_group_monitoring_model = slice_ns.model('ConnectionGroupMonitoring', { - 'one-way-min-delay': fields.Integer(description='One-way minimum delay or latency'), - 'one-way-max-delay': fields.Integer(description='One-way maximum delay or latency'), - 'one-way-delay-variation': fields.Integer(description='One-way delay variation'), - 'one-way-packet-loss': fields.Float(description='The ratio of packets dropped to packets transmitted'), - 'two-way-min-delay': fields.Integer(description='Two-way minimum delay or latency'), - 'two-way-max-delay': fields.Integer(description='Two-way maximum delay or latency'), - 'two-way-delay-variation': fields.Integer(description='Two-way delay variation'), - 'two-way-packet-loss': fields.Float(description='The ratio of packets dropped to packets transmitted') - }) - - connection_group_model = slice_ns.model('ConnectionGroup', { - 'id': fields.String(required=True, description='The Connection Group identifier'), - 'connectivity-type': fields.String(description='Connection Group connectivity type'), - 'slo-sle-template': fields.String(description='Standard SLO and SLE template to be used'), - 'service-slo-sle-policy': fields.Nested(service_slo_sle_policy_model, description='Contains the SLO and SLE policy'), - 'service-slo-sle-policy-override': fields.String(description='SLO/SLE policy override option'), - 'connectivity-construct': fields.List(fields.Nested(connectivity_construct_model), description='List of Connectivity Constructs'), - 'connection-group-monitoring': fields.Nested(connection_group_monitoring_model, description='SLO status per Connection Group') - }) - - connection_groups_model = slice_ns.model('ConnectionGroups', { - 'connection-group': fields.List(fields.Nested(connection_group_model), description='List of Connection Groups') - }) - - # ============================================== - # Service Tags models - # ============================================== - - tag_type_model = slice_ns.model('TagType', { - 'tag-type': fields.String(required=True, description='Slice Service tag type'), - 'tag-type-value': fields.List(fields.String, description='The tag values') - }) - - service_tags_model = slice_ns.model('ServiceTags', { - 'tag-type': fields.List(fields.Nested(tag_type_model), description='The service tag list') - }) - - # ============================================== - # Custom Topology models - # ============================================== - - custom_topology_model = slice_ns.model('CustomTopology', { - 'network-ref': fields.String(description='Used to reference a network') - }) - - # ============================================== - # Slice Service models - # ============================================== - - slice_service_model = slice_ns.model('SliceService', { - 'id': fields.String(required=True, description='A unique Slice Service identifier within an NSC'), - 'description': fields.String(description='Textual description of the Slice Service'), - 'service-tags': fields.Nested(service_tags_model, description='Container for a list of service tags'), - 'slo-sle-template': fields.String(description='Standard SLO and SLE template to be used'), - 'service-slo-sle-policy': fields.Nested(service_slo_sle_policy_model, description='Contains the SLO and SLE policy'), - 'test-only': fields.String(description='When present, this is a feasibility check'), - 'status': fields.Nested(status_model, description='Service status'), - 'sdps': fields.Nested(sdps_model, description='Slice Service SDPs'), - 'connection-groups': fields.Nested(connection_groups_model, description='Contains Connection Groups'), - 'custom-topology': fields.Nested(custom_topology_model, description='Container for custom topology') - }) - - # ============================================== - # Root model: Network Slice Services - # ============================================== - - network_slice_services_model = slice_ns.model('NetworkSliceServices', { - 'ietf-network-slice-service:network-slice-services': fields.Nested(slice_ns.model('NetworkSliceServicesContainer', { - 'slo-sle-templates': fields.Nested(slo_sle_templates_model, description='Contains a set of Slice Service templates'), - 'slice-service': fields.List(fields.Nested(slice_service_model), description='A Slice Service is identified by a service id') - }), description='Contains a list of Network Slice Services') - }) - - # ------------------------------------------------------------------ - # API responses - # ------------------------------------------------------------------ +"""Swagger model builders for RESTCONF IETF Network Slice Service YANG model.""" + +from __future__ import annotations + +from flask_restx import Model, Namespace, fields + + +def create_ietf_network_slice_nbi_yang_model( + slice_ns: Namespace, +) -> tuple[Model, Model, Model, Model, Model, Model, Model, Model]: + """Create and register RESTCONF IETF Network Slice NBI YANG models. + + Args: + slice_ns: Target Flask-RESTX namespace. + + Returns: + Tuple of (network_slice_services_model, slo_sle_template_model, + slice_service_model, sdp_model, slice_response_model, + slice_service_response, slo_sle_template_response, sdp_response). + """ + # ========================================================================= + # Status Models + # ========================================================================= + + admin_status_model = slice_ns.model( + "AdminStatus", + { + "status": fields.String(description="Administrative service status"), + "last-change": fields.String(description="Indicates the actual date and time of the service status change"), + }, + ) + + oper_status_model = slice_ns.model( + "OperStatus", + { + "status": fields.String(description="Operational status"), + "last-change": fields.String(description="Indicates the actual date and time of the service status change"), + }, + ) + + status_model = slice_ns.model( + "Status", + { + "admin-status": fields.Nested(admin_status_model, description="Administrative service status"), + "oper-status": fields.Nested(oper_status_model, description="Operational service status"), + }, + ) + + # ========================================================================= + # SLO/SLE Policy Models + # ========================================================================= + + metric_bound_model = slice_ns.model( + "MetricBound", + { + "metric-type": fields.String(required=True, description="Identifies SLO metric type of the Slice Service"), + "metric-unit": fields.String(description="The metric unit of the parameter"), + "value-description": fields.String(description="The description of the provided value"), + "percentile-value": fields.Float(description="The percentile value of the metric type"), + "bound": fields.Integer(description="The bound on the Slice Service connection metric"), + }, + ) + + slo_policy_model = slice_ns.model( + "SLOPolicy", + { + "metric-bound": fields.List( + fields.Nested(metric_bound_model), description="List of Slice Service metric bounds" + ), + "availability": fields.String(description="Service availability level"), + "mtu": fields.Integer(description="Maximum length of Layer 2 data packets of the Slice Service"), + }, + ) + + diversity_model = slice_ns.model( + "Diversity", + { + "diversity-type": fields.String(description="The type of disjointness on Slice Service"), + }, + ) + + path_constraints_model = slice_ns.model( + "PathConstraints", + { + "service-functions": fields.Raw(description="Container for the policy of service function"), + "diversity": fields.Nested(diversity_model, description="Container for the policy of disjointness"), + }, + ) + + sle_policy_model = slice_ns.model( + "SLEPolicy", + { + "security": fields.List(fields.String, description="The security functions"), + "isolation": fields.List(fields.String, description="The Slice Service isolation requirement"), + "max-occupancy-level": fields.Integer(description="The maximal occupancy level"), + "path-constraints": fields.Nested( + path_constraints_model, description="Container for the policy of path constraints" + ), + }, + ) + + service_slo_sle_policy_model = slice_ns.model( + "ServiceSLOSLEPolicy", + { + "description": fields.String(description="Describes the SLO and SLE policy"), + "slo-policy": fields.Nested(slo_policy_model, description="Contains the SLO policy"), + "sle-policy": fields.Nested(sle_policy_model, description="Contains the SLE policy"), + }, + ) + + # ========================================================================= + # SLO/SLE Template Models + # ========================================================================= + + slo_sle_template_model = slice_ns.model( + "SLOSLETemplate", + { + "id": fields.String(required=True, description="Identification of the SLO and SLE template"), + "description": fields.String(description="Describes the SLO and SLE policy template"), + "template-ref": fields.String(description="The reference to a standard template"), + "slo-policy": fields.Nested(slo_policy_model, description="Contains the SLO policy"), + "sle-policy": fields.Nested(sle_policy_model, description="Contains the SLE policy"), + }, + ) + + slo_sle_templates_model = slice_ns.model( + "SLOSLETemplates", + { + "slo-sle-template": fields.List( + fields.Nested(slo_sle_template_model), description="List for SLO and SLE template identifiers" + ), + }, + ) + + # ========================================================================= + # QoS Policy Models + # ========================================================================= + + cos_model = slice_ns.model( + "CoS", + { + "cos-id": fields.Integer(required=True, description="Identifier of the CoS"), + "cir": fields.Integer(description="Committed Information Rate"), + "cbs": fields.Integer(description="Committed Burst Size"), + "eir": fields.Integer(description="Excess Information Rate"), + "ebs": fields.Integer(description="Excess Burst Size"), + "pir": fields.Integer(description="Peak Information Rate"), + "pbs": fields.Integer(description="Peak Burst Size"), + }, + ) + + classes_model = slice_ns.model( + "Classes", + { + "cos": fields.List(fields.Nested(cos_model), description="List of Class of Services"), + }, + ) + + rate_limits_model = slice_ns.model( + "RateLimits", + { + "cir": fields.Integer(description="Committed Information Rate"), + "cbs": fields.Integer(description="Committed Burst Size"), + "eir": fields.Integer(description="Excess Information Rate"), + "ebs": fields.Integer(description="Excess Burst Size"), + "pir": fields.Integer(description="Peak Information Rate"), + "pbs": fields.Integer(description="Peak Burst Size"), + "classes": fields.Nested(classes_model, description="Container for service class bandwidth control"), + }, + ) + + qos_policy_model = slice_ns.model( + "QoSPolicy", + { + "qos-policy-name": fields.String(description="The name of the QoS policy"), + "rate-limits": fields.Nested(rate_limits_model, description="Container for the asymmetric traffic control"), + }, + ) + + # ========================================================================= + # SDP Peering Models + # ========================================================================= + + sdp_peering_model = slice_ns.model( + "SDPPeering", + { + "peer-sap-id": fields.List(fields.String, description="Indicates the reference to the remote endpoints"), + "protocols": fields.Raw(description="Serves as an augmentation target"), + }, + ) + + # ========================================================================= + # Attachment Circuit Models + # ========================================================================= + + ac_tag_model = slice_ns.model( + "ACTag", + { + "tag-type": fields.String(required=True, description="The Attachment Circuit tag type"), + "tag-type-value": fields.List(fields.String, description="The Attachment Circuit tag values"), + }, + ) + + ac_tags_model = slice_ns.model( + "ACTags", + { + "ac-tag": fields.List(fields.Nested(ac_tag_model), description="The Attachment Circuit tag list"), + }, + ) + + attachment_circuit_model = slice_ns.model( + "AttachmentCircuit", + { + "id": fields.String(required=True, description="The identifier of Attachment Circuit"), + "description": fields.String(description="The Attachment Circuit description"), + "ac-svc-ref": fields.String(description="A reference to the AC service"), + "ac-node-id": fields.String(description="The Attachment Circuit node ID"), + "ac-tp-id": fields.String(description="The termination port ID"), + "ac-ipv4-address": fields.String(description="The IPv4 address of the AC"), + "ac-ipv4-prefix-length": fields.Integer(description="The length of the IPv4 subnet prefix"), + "ac-ipv6-address": fields.String(description="The IPv6 address of the AC"), + "ac-ipv6-prefix-length": fields.Integer(description="The length of IPv6 subnet prefix"), + "mtu": fields.Integer(description="Maximum size of the Slice Service Layer 2 data packet"), + "ac-tags": fields.Nested(ac_tags_model, description="Container for the Attachment Circuit tags"), + "incoming-qos-policy": fields.Nested( + qos_policy_model, description="The QoS policy imposed on ingress direction" + ), + "outgoing-qos-policy": fields.Nested( + qos_policy_model, description="The QoS policy imposed on egress direction" + ), + "sdp-peering": fields.Nested(sdp_peering_model, description="Describes SDP peering attributes"), + "status": fields.Nested(status_model, description="Service status"), + }, + ) + + attachment_circuits_model = slice_ns.model( + "AttachmentCircuits", + { + "attachment-circuit": fields.List( + fields.Nested(attachment_circuit_model), description="List of Attachment Circuits" + ), + }, + ) + + # ========================================================================= + # Service Match Criteria Models + # ========================================================================= + + match_type_model = slice_ns.model( + "MatchType", + { + "type": fields.String(required=True, description="Indicates the match type of the entry"), + "interface-name": fields.List(fields.String, description="Physical interface name for the match criteria"), + "vlan": fields.List(fields.Integer, description="VLAN ID value for the match criteria"), + "label": fields.List(fields.String, description="MPLS label value for the match criteria"), + "ip-prefix": fields.List(fields.String, description="IP prefix value for the match criteria"), + "dscp": fields.List(fields.Integer, description="DSCP value for the match criteria"), + "acl-name": fields.List(fields.String, description="ACL name value for the match criteria"), + }, + ) + + match_criterion_model = slice_ns.model( + "MatchCriterion", + { + "index": fields.Integer(required=True, description="The identifier of a match criteria"), + "match-type": fields.List( + fields.Nested(match_type_model), description="List of the Slice Service traffic match types" + ), + "target-connection-group-id": fields.String(description="Reference to the Slice Service Connection Group"), + "connection-group-sdp-role": fields.String(description="Specifies the role of SDP in the Connection Group"), + "target-connectivity-construct-id": fields.String( + description="Reference to a Network Slice Connectivity Construct" + ), + }, + ) + + service_match_criteria_model = slice_ns.model( + "ServiceMatchCriteria", + { + "match-criterion": fields.List( + fields.Nested(match_criterion_model), description="List of the Slice Service traffic match criteria" + ), + }, + ) + + # ========================================================================= + # SDP Monitoring Models + # ========================================================================= + + sdp_monitoring_model = slice_ns.model( + "SDPMonitoring", + { + "incoming-bw-value": fields.Integer(description="Absolute value of the incoming bandwidth"), + "incoming-bw-percent": fields.Integer(description="Percentage of the incoming bandwidth"), + "outgoing-bw-value": fields.Integer(description="Absolute value of the outgoing bandwidth"), + "outgoing-bw-percent": fields.Integer(description="Percentage of the outgoing bandwidth"), + }, + ) + + # ========================================================================= + # Geo Location Models + # ========================================================================= + + velocity_model = slice_ns.model( + "Velocity", + { + "v-north": fields.Float(description="Rate of change towards true north"), + "v-east": fields.Float(description="Rate of change perpendicular to the right of true north"), + "v-up": fields.Float(description="Rate of change away from the center of mass"), + }, + ) + + geodetic_system_model = slice_ns.model( + "GeodeticSystem", + { + "geodetic-datum": fields.String( + description="A geodetic-datum defining the meaning of latitude, longitude, and height" + ), + "coord-accuracy": fields.Float(description="The accuracy of the latitude/longitude pair"), + "height-accuracy": fields.Float(description="The accuracy of the height value"), + }, + ) + + reference_frame_model = slice_ns.model( + "ReferenceFrame", + { + "alternate-system": fields.String(description="The system in which the astronomical body is defined"), + "astronomical-body": fields.String(description="An astronomical body as named by the IAU"), + "geodetic-system": fields.Nested( + geodetic_system_model, description="The geodetic system of the location data" + ), + }, + ) + + geo_location_model = slice_ns.model( + "GeoLocation", + { + "reference-frame": fields.Nested( + reference_frame_model, description="The Frame of Reference for the location values" + ), + "latitude": fields.Float(description="The latitude value on the astronomical body"), + "longitude": fields.Float(description="The longitude value on the astronomical body"), + "height": fields.Float(description="Height from a reference 0 value"), + "x": fields.Float(description="The X value as defined by the reference-frame"), + "y": fields.Float(description="The Y value as defined by the reference-frame"), + "z": fields.Float(description="The Z value as defined by the reference-frame"), + "velocity": fields.Nested(velocity_model, description="If the object is in motion, the velocity vector"), + "timestamp": fields.String(description="Reference time when location was recorded"), + "valid-until": fields.String(description="The timestamp for which this geo-location is valid until"), + }, + ) + + # ========================================================================= + # SDP Models + # ========================================================================= + + sdp_model = slice_ns.model( + "SDP", + { + "id": fields.String(required=True, description="The unique identifier of the SDP"), + "description": fields.String(description="Provides a description of the SDP"), + "geo-location": fields.Nested(geo_location_model, description="A location on an astronomical body"), + "node-id": fields.String(description="A unique identifier of an edge node of the SDP"), + "sdp-ip-address": fields.List(fields.String, description="IPv4 or IPv6 address of the SDP"), + "tp-ref": fields.String(description="A reference to Termination Point in the custom topology"), + "service-match-criteria": fields.Nested( + service_match_criteria_model, description="Describes the Slice Service match criteria" + ), + "incoming-qos-policy": fields.Nested( + qos_policy_model, description="The QoS policy imposed on ingress direction" + ), + "outgoing-qos-policy": fields.Nested( + qos_policy_model, description="The QoS policy imposed on egress direction" + ), + "sdp-peering": fields.Nested(sdp_peering_model, description="Describes SDP peering attributes"), + "ac-svc-ref": fields.List(fields.String, description="A reference to the ACs"), + "ce-mode": fields.Boolean(description="When true, this indicates the SDP is located on the CE"), + "attachment-circuits": fields.Nested(attachment_circuits_model, description="List of Attachment Circuits"), + "status": fields.Nested(status_model, description="Service status"), + "sdp-monitoring": fields.Nested(sdp_monitoring_model, description="Container for SDP monitoring metrics"), + }, + ) + + sdps_model = slice_ns.model( + "SDPs", + { + "sdp": fields.List(fields.Nested(sdp_model), description="List of SDPs in this Slice Service"), + }, + ) + + # ========================================================================= + # Connectivity Construct Models + # ========================================================================= + + connectivity_construct_monitoring_model = slice_ns.model( + "ConnectivityConstructMonitoring", + { + "one-way-min-delay": fields.Integer(description="One-way minimum delay or latency"), + "one-way-max-delay": fields.Integer(description="One-way maximum delay or latency"), + "one-way-delay-variation": fields.Integer(description="One-way delay variation"), + "one-way-packet-loss": fields.Float(description="The ratio of packets dropped to packets transmitted"), + "two-way-min-delay": fields.Integer(description="Two-way minimum delay or latency"), + "two-way-max-delay": fields.Integer(description="Two-way maximum delay or latency"), + "two-way-delay-variation": fields.Integer(description="Two-way delay variation"), + "two-way-packet-loss": fields.Float(description="The ratio of packets dropped to packets transmitted"), + }, + ) + + a2a_sdp_model = slice_ns.model( + "A2ASDP", + { + "sdp-id": fields.String(required=True, description="Reference to an SDP"), + "slo-sle-template": fields.String(description="Standard SLO and SLE template to be used"), + "service-slo-sle-policy": fields.Nested( + service_slo_sle_policy_model, description="Contains the SLO and SLE policy" + ), + }, + ) + + connectivity_construct_model = slice_ns.model( + "ConnectivityConstruct", + { + "id": fields.String(required=True, description="The Connectivity Construct identifier"), + "p2p-sender-sdp": fields.String(description="Reference to a sender SDP"), + "p2p-receiver-sdp": fields.String(description="Reference to a receiver SDP"), + "p2mp-sender-sdp": fields.String(description="Reference to a sender SDP"), + "p2mp-receiver-sdp": fields.List(fields.String, description="Reference to a receiver SDP"), + "a2a-sdp": fields.List(fields.Nested(a2a_sdp_model), description="List of included A2A SDPs"), + "slo-sle-template": fields.String(description="Standard SLO and SLE template to be used"), + "service-slo-sle-policy": fields.Nested( + service_slo_sle_policy_model, description="Contains the SLO and SLE policy" + ), + "service-slo-sle-policy-override": fields.String(description="SLO/SLE policy override option"), + "status": fields.Nested(status_model, description="Service status"), + "connectivity-construct-monitoring": fields.Nested( + connectivity_construct_monitoring_model, description="SLO status per Connectivity Construct" + ), + }, + ) + + # ========================================================================= + # Connection Group Models + # ========================================================================= + + connection_group_monitoring_model = slice_ns.model( + "ConnectionGroupMonitoring", + { + "one-way-min-delay": fields.Integer(description="One-way minimum delay or latency"), + "one-way-max-delay": fields.Integer(description="One-way maximum delay or latency"), + "one-way-delay-variation": fields.Integer(description="One-way delay variation"), + "one-way-packet-loss": fields.Float(description="The ratio of packets dropped to packets transmitted"), + "two-way-min-delay": fields.Integer(description="Two-way minimum delay or latency"), + "two-way-max-delay": fields.Integer(description="Two-way maximum delay or latency"), + "two-way-delay-variation": fields.Integer(description="Two-way delay variation"), + "two-way-packet-loss": fields.Float(description="The ratio of packets dropped to packets transmitted"), + }, + ) + + connection_group_model = slice_ns.model( + "ConnectionGroup", + { + "id": fields.String(required=True, description="The Connection Group identifier"), + "connectivity-type": fields.String(description="Connection Group connectivity type"), + "slo-sle-template": fields.String(description="Standard SLO and SLE template to be used"), + "service-slo-sle-policy": fields.Nested( + service_slo_sle_policy_model, description="Contains the SLO and SLE policy" + ), + "service-slo-sle-policy-override": fields.String(description="SLO/SLE policy override option"), + "connectivity-construct": fields.List( + fields.Nested(connectivity_construct_model), description="List of Connectivity Constructs" + ), + "connection-group-monitoring": fields.Nested( + connection_group_monitoring_model, description="SLO status per Connection Group" + ), + }, + ) + + connection_groups_model = slice_ns.model( + "ConnectionGroups", + { + "connection-group": fields.List( + fields.Nested(connection_group_model), description="List of Connection Groups" + ), + }, + ) + + # ========================================================================= + # Service Tags Models + # ========================================================================= + + tag_type_model = slice_ns.model( + "TagType", + { + "tag-type": fields.String(required=True, description="Slice Service tag type"), + "tag-type-value": fields.List(fields.String, description="The tag values"), + }, + ) + + service_tags_model = slice_ns.model( + "ServiceTags", + { + "tag-type": fields.List(fields.Nested(tag_type_model), description="The service tag list"), + }, + ) + + # ========================================================================= + # Custom Topology Models + # ========================================================================= + + custom_topology_model = slice_ns.model( + "CustomTopology", + { + "network-ref": fields.String(description="Used to reference a network"), + }, + ) + + # ========================================================================= + # Slice Service Models + # ========================================================================= + + slice_service_model = slice_ns.model( + "SliceService", + { + "id": fields.String(required=True, description="A unique Slice Service identifier within an NSC"), + "description": fields.String(description="Textual description of the Slice Service"), + "service-tags": fields.Nested(service_tags_model, description="Container for a list of service tags"), + "slo-sle-template": fields.String(description="Standard SLO and SLE template to be used"), + "service-slo-sle-policy": fields.Nested( + service_slo_sle_policy_model, description="Contains the SLO and SLE policy" + ), + "test-only": fields.String(description="When present, this is a feasibility check"), + "status": fields.Nested(status_model, description="Service status"), + "sdps": fields.Nested(sdps_model, description="Slice Service SDPs"), + "connection-groups": fields.Nested(connection_groups_model, description="Contains Connection Groups"), + "custom-topology": fields.Nested(custom_topology_model, description="Container for custom topology"), + }, + ) + + # ========================================================================= + # Root Model: Network Slice Services + # ========================================================================= + + network_slice_services_model = slice_ns.model( + "NetworkSliceServices", + { + "ietf-network-slice-service:network-slice-services": fields.Nested( + slice_ns.model( + "NetworkSliceServicesContainer", + { + "slo-sle-templates": fields.Nested( + slo_sle_templates_model, description="Contains a set of Slice Service templates" + ), + "slice-service": fields.List( + fields.Nested(slice_service_model), + description="A Slice Service is identified by a service id", + ), + }, + ), + description="Contains a list of Network Slice Services", + ), + }, + ) + + # ========================================================================= + # Response Models + # ========================================================================= slice_service_response = slice_ns.model( - 'SliceServiceResponse', + "SliceServiceResponse", { - 'ietf-network-slice-service:network-slice-services': - fields.Nested(slice_service_model) - } + "ietf-network-slice-service:network-slice-services": fields.Nested(slice_service_model), + }, ) slo_sle_template_response = slice_ns.model( - 'SloSleTemplateResponse', + "SloSleTemplateResponse", { - 'ietf-network-slice-service:network-slice-services': - fields.Nested( - slice_ns.model('NetworkSliceServicesTemplatesOnly', { - 'slo-sle-templates': fields.Nested(slo_sle_template_model) - }) + "ietf-network-slice-service:network-slice-services": fields.Nested( + slice_ns.model( + "NetworkSliceServicesTemplatesOnly", + { + "slo-sle-templates": fields.Nested(slo_sle_template_model), + }, ) - } + ), + }, ) - sdp_response = slice_ns.model('SdpResponse', + sdp_response = slice_ns.model( + "SdpResponse", { - 'ietf-network-slice-service:network-slice-services': - fields.Nested( - slice_ns.model('NetworkSliceServicesSdpsOnly', { - 'slice-service': fields.List(fields.Nested( - slice_ns.model('SliceServiceSdpsOnly', { - 'id': fields.String(), - 'sdps': fields.Nested(sdps_model) - }) - )) - }) + "ietf-network-slice-service:network-slice-services": fields.Nested( + slice_ns.model( + "NetworkSliceServicesSdpsOnly", + { + "slice-service": fields.List( + fields.Nested( + slice_ns.model( + "SliceServiceSdpsOnly", + { + "id": fields.String(), + "sdps": fields.Nested(sdps_model), + }, + ) + ) + ), + }, ) - }) + ), + }, + ) - # ------------------------------------------------------------------ - # Custom slice response (non-IETF) - # ------------------------------------------------------------------ + # ========================================================================= + # Custom Slice Response (non-IETF) + # ========================================================================= slice_response_model = slice_ns.model( "SliceResponse", @@ -449,21 +653,30 @@ def create_ietf_network_slice_nbi_yang_model(slice_ns): "SliceRequirement", { "constraint_type": fields.String(), - "constraint_value": fields.String() - } + "constraint_value": fields.String(), + }, ) ) - ) - } + ), + }, ) ) ), - "setup_time": fields.Float() - } + "setup_time": fields.Float(), + }, ) ), - "error": fields.String() - } + "error": fields.String(), + }, ) - return network_slice_services_model, slo_sle_template_model, slice_service_model, sdp_model, slice_response_model, slice_service_response, slo_sle_template_response, sdp_response \ No newline at end of file + return ( + network_slice_services_model, + slo_sle_template_model, + slice_service_model, + sdp_model, + slice_response_model, + slice_service_response, + slo_sle_template_response, + sdp_response, + ) diff --git a/swagger/restconf_namespace.py b/swagger/restconf_namespace.py index df361e5..b342433 100644 --- a/swagger/restconf_namespace.py +++ b/swagger/restconf_namespace.py @@ -14,406 +14,543 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. +"""RESTCONF namespace definitions and resource controllers for IETF Network Slice Service.""" + +from __future__ import annotations + import logging -from flask import request, Response, request, current_app, stream_with_context +from typing import Any + +from flask import Response, current_app, request, stream_with_context from flask_restx import Namespace, Resource, fields + +from src.api.main import Api as RestconfHandler from src.main import NSController -from src.api.main import Api -from swagger.models.create_models_restconf import create_ietf_network_slice_nbi_yang_model +from swagger.models.create_models_restconf import ( + create_ietf_network_slice_nbi_yang_model, +) + +logger = logging.getLogger(__name__) # Namespace restconf_ns = Namespace( "restconf", - description="RESTCONF operations for IETF Network Slice Service YANG model" + description="RESTCONF operations for IETF Network Slice Service YANG model", ) -network_slice_services_model, slo_sle_template_model, slice_service_model, sdp_model, slice_response_model, slice_service_response, slo_sle_template_response, sdp_response = create_ietf_network_slice_nbi_yang_model(restconf_ns) + +( + network_slice_services_model, + slo_sle_template_model, + slice_service_model, + sdp_model, + slice_response_model, + slice_service_response, + slo_sle_template_response, + sdp_response, +) = create_ietf_network_slice_nbi_yang_model(restconf_ns) + + +# ============================================================================= +# Network Slice Services Root +# ============================================================================= + @restconf_ns.route("/data/ietf-network-slice-service:network-slice-services") class NetworkSliceServices(Resource): + """Resource for root network slice services container.""" @restconf_ns.doc(summary="Contains a list of Network Slice Services") @restconf_ns.response(200, "Network slice services returned", network_slice_services_model) @restconf_ns.response(404, "Nothing found") @restconf_ns.response(500, "Internal server error") - def get(self): + def get(self) -> tuple[dict[str, Any], int]: + """Retrieve all network slice services.""" controller = NSController(controller_type="RESTCONF") - return Api(controller).get_network_slice_services() + return RestconfHandler(controller).get_network_slice_services() @restconf_ns.doc(summary="Create Network Slice Services") @restconf_ns.expect(network_slice_services_model) @restconf_ns.response(201, "Container network-slice-services created", slice_response_model) @restconf_ns.response(500, "Internal server error") - def post(self): + def post(self) -> tuple[dict[str, Any], int]: + """Create network slice services.""" json_data = request.get_json() controller = NSController(controller_type="RESTCONF") - return Api(controller).add_network_slice_service(json_data) + return RestconfHandler(controller).add_network_slice_service(json_data) @restconf_ns.doc(summary="Update Network Slice Services") @restconf_ns.expect(network_slice_services_model) @restconf_ns.response(200, "Container network-slice-services updated", slice_response_model) @restconf_ns.response(404, "Nothing found to update") @restconf_ns.response(500, "Internal server error") - def put(self): + def put(self) -> tuple[dict[str, Any], int]: + """Update network slice services.""" json_data = request.get_json() controller = NSController(controller_type="RESTCONF") - return Api(controller).update_network_slice_service(json_data) + return RestconfHandler(controller).update_network_slice_service(json_data) @restconf_ns.doc(summary="Delete Network Slice Services") @restconf_ns.response(204, "All slices deleted") @restconf_ns.response(500, "Internal server error") - def delete(self): + def delete(self) -> tuple[dict[str, Any], int]: + """Delete all network slice services.""" controller = NSController(controller_type="RESTCONF") - return Api(controller).delete_network_slice_services() + return RestconfHandler(controller).delete_network_slice_services() + + +# ============================================================================= +# Slice Services +# ============================================================================= + @restconf_ns.route("/data/ietf-network-slice-service:network-slice-services/slice-service") class SliceServiceList(Resource): + """Resource for collection-level slice-service operations.""" @restconf_ns.doc(summary="Contains a set of Slice Services") @restconf_ns.response(200, "Slices returned", slice_service_response) @restconf_ns.response(404, "No slices found") - def get(self): + def get(self) -> tuple[dict[str, Any], int]: + """Retrieve all slice services.""" controller = NSController(controller_type="RESTCONF") - return Api(controller).get_slice_services() + return RestconfHandler(controller).get_slice_services() @restconf_ns.doc(summary="Create Slice Services") @restconf_ns.expect(slice_service_model) - #@restconf_ns.expect(slice_service_model) @restconf_ns.response(201, "Slice created", slice_response_model) @restconf_ns.response(409, "Slice already exists") - def post(self): + def post(self) -> tuple[dict[str, Any], int]: + """Create a slice service.""" json_data = request.get_json() controller = NSController(controller_type="RESTCONF") - return Api(controller).add_slice_service(json_data) + return RestconfHandler(controller).add_slice_service(json_data) @restconf_ns.doc(summary="Delete Slice Services") @restconf_ns.response(204, "All slices deleted") - def delete(self): + def delete(self) -> tuple[dict[str, Any], int]: + """Delete all slice services.""" controller = NSController(controller_type="RESTCONF") - return Api(controller).delete_slice_services() + return RestconfHandler(controller).delete_slice_services() + @restconf_ns.route("/data/ietf-network-slice-service:network-slice-services/slice-service=") @restconf_ns.doc(params={"slice_service_id": "Slice identifier"}) class SliceService(Resource): + """Resource for individual slice-service operations.""" @restconf_ns.doc(summary="Get a Slice Service by ID") @restconf_ns.response(200, "Slice returned", slice_service_response) @restconf_ns.response(404, "Slice not found") - def get(self, slice_service_id): + def get(self, slice_service_id: str) -> tuple[dict[str, Any], int]: + """Retrieve a specific slice service.""" controller = NSController(controller_type="RESTCONF") - return Api(controller).get_slice_services(slice_service_id) - + return RestconfHandler(controller).get_slice_services(slice_service_id) + @restconf_ns.doc(summary="Update Slice Service by ID") @restconf_ns.expect(slice_service_model) @restconf_ns.response(200, "Slice updated", slice_response_model) @restconf_ns.response(404, "No slice found to update") - def put(self, slice_service_id): + def put(self, slice_service_id: str) -> tuple[dict[str, Any], int]: + """Update a specific slice service.""" json_data = request.get_json() controller = NSController(controller_type="RESTCONF") - return Api(controller).update_slice_service(slice_service_id, json_data) + return RestconfHandler(controller).update_slice_service(slice_service_id, json_data) @restconf_ns.doc(summary="Delete a Slice Service by ID") @restconf_ns.response(204, "Slice deleted") @restconf_ns.response(404, "Slice not found") - def delete(self, slice_service_id): + def delete(self, slice_service_id: str) -> tuple[dict[str, Any], int]: + """Delete a specific slice service.""" controller = NSController(controller_type="RESTCONF") - return Api(controller).delete_slice_services(slice_service_id) + return RestconfHandler(controller).delete_slice_services(slice_service_id) -@restconf_ns.route("/data/ietf-network:networks/network=") -@restconf_ns.doc(params={"slice_service_id": "Slice identifier"}) -class SliceNetworkTopology(Resource): - @restconf_ns.doc(summary="Get Network Slice Topology by Slice ID (draft-ietf-teas-network-slice-topology-yang-04)") - @restconf_ns.response(200, "Slice topology returned") - @restconf_ns.response(404, "Slice not found") - def get(self, slice_service_id): - controller = NSController(controller_type="RESTCONF") - return Api(controller).get_slice_topology(slice_service_id) +# ============================================================================= +# SLO/SLE Templates +# ============================================================================= + @restconf_ns.route("/data/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template") class SloSleTemplateList(Resource): + """Resource for collection-level SLO/SLE template operations.""" @restconf_ns.doc(summary="Contains a set of SLO/SLE templates") @restconf_ns.response(200, "Templates returned", slo_sle_template_response) - def get(self): + def get(self) -> tuple[dict[str, Any], int]: + """Retrieve all SLO/SLE templates.""" controller = NSController(controller_type="RESTCONF") - return Api(controller).get_slo_sle_templates() + return RestconfHandler(controller).get_slo_sle_templates() @restconf_ns.doc(summary="Create SLO/SLE templates") @restconf_ns.expect(slo_sle_template_model) @restconf_ns.response(201, "Template created", slice_response_model) @restconf_ns.response(409, "Template already exists") - def post(self): + def post(self) -> tuple[dict[str, Any], int]: + """Create an SLO/SLE template.""" json_data = request.get_json() controller = NSController(controller_type="RESTCONF") - return Api(controller).add_slo_sle_template(json_data) + return RestconfHandler(controller).add_slo_sle_template(json_data) @restconf_ns.doc(summary="Delete SLO/SLE templates") @restconf_ns.response(204, "All templates deleted") - def delete(self): + def delete(self) -> tuple[dict[str, Any], int]: + """Delete all SLO/SLE templates.""" controller = NSController(controller_type="RESTCONF") - return Api(controller).delete_slo_sle_templates() + return RestconfHandler(controller).delete_slo_sle_templates() + -@restconf_ns.route("/data/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template=") +@restconf_ns.route( + "/data/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template=" +) class SloSleTemplate(Resource): + """Resource for individual SLO/SLE template operations.""" @restconf_ns.doc(summary="Get SLO/SLE template by ID") @restconf_ns.response(200, "Template returned", slo_sle_template_response) @restconf_ns.response(404, "Template not found") - def get(self, slo_sle_template_id): + def get(self, slo_sle_template_id: str) -> tuple[dict[str, Any], int]: + """Retrieve an SLO/SLE template.""" controller = NSController(controller_type="RESTCONF") - return Api(controller).get_slo_sle_templates(slo_sle_template_id) - + return RestconfHandler(controller).get_slo_sle_templates(slo_sle_template_id) + @restconf_ns.doc(summary="Update SLO/SLE template by ID") @restconf_ns.expect(slo_sle_template_model) @restconf_ns.response(200, "Template updated", slice_response_model) @restconf_ns.response(404, "No template found to update") - def put(self, slo_sle_template_id): + def put(self, slo_sle_template_id: str) -> tuple[dict[str, Any], int]: + """Update an SLO/SLE template.""" json_data = request.get_json() controller = NSController(controller_type="RESTCONF") - return Api(controller).update_slo_sle_template(slo_sle_template_id, json_data) + return RestconfHandler(controller).update_slo_sle_template(slo_sle_template_id, json_data) @restconf_ns.doc(summary="Delete SLO/SLE template by ID") @restconf_ns.response(204, "Template deleted") - def delete(self, slo_sle_template_id): + def delete(self, slo_sle_template_id: str) -> tuple[dict[str, Any], int]: + """Delete an SLO/SLE template.""" controller = NSController(controller_type="RESTCONF") - return Api(controller).delete_slo_sle_templates(slo_sle_template_id) + return RestconfHandler(controller).delete_slo_sle_templates(slo_sle_template_id) + + +# ============================================================================= +# SDPs +# ============================================================================= + @restconf_ns.route("/data/ietf-network-slice-service:network-slice-services/slice-service=/sdps") class SdpList(Resource): + """Resource for collection-level SDP operations on a slice service.""" @restconf_ns.doc(summary="Contains a set of Slice Service SDPs") @restconf_ns.response(200, "SDPs returned", sdp_response) - def get(self, slice_service_id): + def get(self, slice_service_id: str) -> tuple[dict[str, Any], int]: + """Retrieve all SDPs for a slice service.""" controller = NSController(controller_type="RESTCONF") - return Api(controller).get_sdps(slice_service_id) + return RestconfHandler(controller).get_sdps(slice_service_id) @restconf_ns.doc(summary="Create Slice Service SDPs") @restconf_ns.expect(sdp_model) @restconf_ns.response(201, "SDP created", slice_response_model) @restconf_ns.response(409, "SDP already exists") - def post(self, slice_service_id): + def post(self, slice_service_id: str) -> tuple[dict[str, Any], int]: + """Create an SDP on a slice service.""" json_data = request.get_json() controller = NSController(controller_type="RESTCONF") - return Api(controller).add_sdp(slice_service_id, json_data) + return RestconfHandler(controller).add_sdp(slice_service_id, json_data) @restconf_ns.doc(summary="Delete Slice Service SDPs") @restconf_ns.response(204, "All SDPs deleted") - def delete(self, slice_service_id): + def delete(self, slice_service_id: str) -> tuple[dict[str, Any], int]: + """Delete all SDPs on a slice service.""" controller = NSController(controller_type="RESTCONF") - return Api(controller).delete_sdps(slice_service_id) -@restconf_ns.route("/data/ietf-network-slice-service:network-slice-services/slice-service=/sdps/sdp=") + return RestconfHandler(controller).delete_sdps(slice_service_id) + + +@restconf_ns.route( + "/data/ietf-network-slice-service:network-slice-services/slice-service=/sdps/sdp=" +) class Sdp(Resource): + """Resource for individual SDP operations on a slice service.""" @restconf_ns.doc(summary="Get Slice Service SDP by ID") @restconf_ns.response(200, "SDP returned", sdp_response) @restconf_ns.response(404, "SDP not found") - def get(self, slice_service_id, sdp_id): + def get(self, slice_service_id: str, sdp_id: str) -> tuple[dict[str, Any], int]: + """Retrieve an SDP.""" controller = NSController(controller_type="RESTCONF") - return Api(controller).get_sdps(slice_service_id, sdp_id) + return RestconfHandler(controller).get_sdps(slice_service_id, sdp_id) + @restconf_ns.doc(summary="Update Slice Service SDP by ID") @restconf_ns.expect(sdp_model) @restconf_ns.response(200, "SDP updated", slice_response_model) @restconf_ns.response(404, "No SDP found to update") - def put(self, slice_service_id, sdp_id): + def put(self, slice_service_id: str, sdp_id: str) -> tuple[dict[str, Any], int]: + """Update an SDP.""" json_data = request.get_json() controller = NSController(controller_type="RESTCONF") - return Api(controller).update_sdp(slice_service_id, sdp_id, json_data) + return RestconfHandler(controller).update_sdp(slice_service_id, sdp_id, json_data) + @restconf_ns.doc(summary="Delete Slice Service SDP by ID") @restconf_ns.response(204, "SDP deleted") - def delete(self, slice_service_id, sdp_id): + def delete(self, slice_service_id: str, sdp_id: str) -> tuple[dict[str, Any], int]: + """Delete an SDP.""" controller = NSController(controller_type="RESTCONF") - return Api(controller).delete_sdps(slice_service_id, sdp_id) + return RestconfHandler(controller).delete_sdps(slice_service_id, sdp_id) + + +# ============================================================================= +# Topology +# ============================================================================= + + +@restconf_ns.route("/data/ietf-network:networks/network=") +@restconf_ns.doc(params={"slice_service_id": "Slice identifier"}) +class SliceNetworkTopology(Resource): + """Resource for retrieving slice topology.""" + + @restconf_ns.doc( + summary="Get Network Slice Topology by Slice ID (draft-ietf-teas-network-slice-topology-yang-04)", + ) + @restconf_ns.response(200, "Slice topology returned") + @restconf_ns.response(404, "Slice not found") + def get(self, slice_service_id: str) -> tuple[dict[str, Any], int]: + """Retrieve slice topology.""" + controller = NSController(controller_type="RESTCONF") + return RestconfHandler(controller).get_slice_topology(slice_service_id) + + +# ============================================================================= +# Telemetry Operations +# ============================================================================= + -# ================================================================================================================================================ @restconf_ns.route("/operations/telemetry/client") class ClientsList(Resource): + """Resource for telemetry client collection operations.""" + @restconf_ns.doc(description="Gets the list of all registered clients") @restconf_ns.response(200, "Clients retrieved") @restconf_ns.response(404, "No clients found") @restconf_ns.response(500, "Internal server error") - def get(self): - return Api(NSController(controller_type="RESTCONF")).get_clients() - + def get(self) -> tuple[dict[str, Any] | list[dict[str, Any]], int]: + """Retrieve all clients.""" + return RestconfHandler(NSController(controller_type="RESTCONF")).get_clients() + @restconf_ns.doc(description="Deletes all clients") @restconf_ns.response(204, "Clients deleted") @restconf_ns.response(500, "Internal server error") - def delete(self): - return Api(NSController(controller_type="RESTCONF")).delete_clients() - + def delete(self) -> tuple[dict[str, Any], int]: + """Delete all clients.""" + return RestconfHandler(NSController(controller_type="RESTCONF")).delete_clients() + + @restconf_ns.route("/operations/telemetry/client/") class Clients(Resource): + """Resource for individual telemetry client operations.""" + @restconf_ns.response(200, "Client retrieved") @restconf_ns.response(404, "No client found") @restconf_ns.response(500, "Internal server error") - def get(self, client_id): - return Api(NSController(controller_type="RESTCONF")).get_clients(client_id) - + def get(self, client_id: str) -> tuple[dict[str, Any] | list[dict[str, Any]], int]: + """Retrieve a specific client.""" + return RestconfHandler(NSController(controller_type="RESTCONF")).get_clients(client_id) + @restconf_ns.doc(description="Creates a new client") - @restconf_ns.response(201, "Client created", model=restconf_ns.model("ClientModel", { - "client_id": fields.String(required=True, description="Client ID"), - })) + @restconf_ns.response( + 201, + "Client created", + model=restconf_ns.model( + "ClientModel", + { + "client_id": fields.String(required=True, description="Client ID"), + }, + ), + ) @restconf_ns.response(500, "Internal server error") - def post(self, client_id): - return Api(NSController(controller_type="RESTCONF")).add_client(client_id) + def post(self, client_id: str) -> tuple[dict[str, Any], int]: + """Register a new client.""" + return RestconfHandler(NSController(controller_type="RESTCONF")).add_client(client_id) @restconf_ns.doc(description="Deletes a given client") @restconf_ns.response(204, "Client deleted") @restconf_ns.response(404, "No client found") @restconf_ns.response(500, "Internal server error") - def delete(self, client_id): - return Api(NSController(controller_type="RESTCONF")).delete_clients(client_id) + def delete(self, client_id: str) -> tuple[dict[str, Any], int]: + """Delete a client.""" + return RestconfHandler(NSController(controller_type="RESTCONF")).delete_clients(client_id) + @restconf_ns.route("/operations/telemetry/subscription/") class Subscriptions(Resource): + """Resource for client subscription collection operations.""" + @restconf_ns.doc( description="Retrieves all telemetry subscriptions of a given client.", - params={"client_id": "Client ID."} + params={"client_id": "Client ID."}, ) @restconf_ns.response(200, "Subscriptions successfully retrieved") @restconf_ns.response(500, "Internal server error") - def get(self, client_id): - api = Api(NSController(controller_type="RESTCONF")) - return current_app.ensure_sync(api.get_subscriptions)(client_id) - + def get(self, client_id: str) -> Any: + """Retrieve all subscriptions for a client.""" + handler = RestconfHandler(NSController(controller_type="RESTCONF")) + return current_app.ensure_sync(handler.get_subscriptions)(client_id) + @restconf_ns.doc( description="Creates a new telemetry subscription for a given client.", - params={"client_id": "Client ID."} + params={"client_id": "Client ID."}, ) @restconf_ns.response(201, "Subscription successfully created") @restconf_ns.response(400, "Invalid request") @restconf_ns.response(500, "Internal server error") - @restconf_ns.expect(restconf_ns.model("TelemetrySubscriptionModel", { - "slice_id": fields.String(required=True, description="Network Slice ID"), - "frequency": fields.Integer(required=True, description="Telemetry push frequency in seconds") - }), validate=True) - def post(self, client_id): - data = request.json + @restconf_ns.expect( + restconf_ns.model( + "TelemetrySubscriptionModel", + { + "slice_id": fields.String(required=True, description="Network Slice ID"), + "frequency": fields.Integer(required=True, description="Telemetry push frequency in seconds"), + }, + ), + validate=True, + ) + def post(self, client_id: str) -> Any: + """Create a telemetry subscription for a client.""" + data = request.json or {} slice_id = data.get("slice_id") frequency = data.get("frequency") - api = Api(NSController(controller_type="RESTCONF")) - return current_app.ensure_sync(api.add_subscription)( - client_id, - slice_id, - frequency - ) - + handler = RestconfHandler(NSController(controller_type="RESTCONF")) + return current_app.ensure_sync(handler.add_subscription)(client_id, slice_id, frequency) + @restconf_ns.doc( description="Deletes all telemetry subscriptions of a given client.", - params={"client_id": "Client ID."} + params={"client_id": "Client ID."}, ) @restconf_ns.response(204, "Subscriptions successfully deleted") @restconf_ns.response(500, "Internal server error") - def delete(self, client_id): - return Api(NSController(controller_type="RESTCONF")).delete_subscriptions(client_id) + def delete(self, client_id: str) -> tuple[dict[str, Any], int]: + """Delete all subscriptions for a client.""" + return RestconfHandler(NSController(controller_type="RESTCONF")).delete_subscriptions(client_id) @restconf_ns.route("/operations/telemetry/subscription//slice/") class SliceSubscriptions(Resource): + """Resource for specific client-slice subscription operations.""" + @restconf_ns.doc( description="Retrieves a specific telemetry subscription of a given client.", - params={ - "client_id": "Client ID.", - "slice_id": "Network Slice ID." - } + params={"client_id": "Client ID.", "slice_id": "Network Slice ID."}, ) @restconf_ns.response(200, "Subscription successfully retrieved") @restconf_ns.response(500, "Internal server error") - def get(self, client_id, slice_id): - api = Api(NSController(controller_type="RESTCONF")) - return current_app.ensure_sync(api.get_subscriptions)(client_id, slice_id) - + def get(self, client_id: str, slice_id: str) -> Any: + """Retrieve a specific subscription.""" + handler = RestconfHandler(NSController(controller_type="RESTCONF")) + return current_app.ensure_sync(handler.get_subscriptions)(client_id, slice_id) + @restconf_ns.doc( description="Updates a telemetry subscription for a given client.", - params={ - "client_id": "Client ID.", - "slice_id": "Network Slice ID." - } + params={"client_id": "Client ID.", "slice_id": "Network Slice ID."}, ) @restconf_ns.response(201, "Subscription successfully updated") @restconf_ns.response(400, "Invalid request") @restconf_ns.response(500, "Internal server error") - @restconf_ns.expect(restconf_ns.model("TelemetryUpdateSubscriptionModel", { - "frequency": fields.Integer(required=True, description="Telemetry push frequency in seconds") - }), validate=True) - def put(self, client_id, slice_id): - data = request.json + @restconf_ns.expect( + restconf_ns.model( + "TelemetryUpdateSubscriptionModel", + { + "frequency": fields.Integer(required=True, description="Telemetry push frequency in seconds"), + }, + ), + validate=True, + ) + def put(self, client_id: str, slice_id: str) -> Any: + """Update a telemetry subscription.""" + data = request.json or {} frequency = data.get("frequency") - api = Api(NSController(controller_type="RESTCONF")) - return current_app.ensure_sync(api.update_subscription)( - client_id, - slice_id, - frequency - ) + handler = RestconfHandler(NSController(controller_type="RESTCONF")) + return current_app.ensure_sync(handler.update_subscription)(client_id, slice_id, frequency) @restconf_ns.doc( description="Deletes a specific telemetry subscription of a given client.", - params={ - "client_id": "Client ID.", - "slice_id": "Network Slice ID." - } + params={"client_id": "Client ID.", "slice_id": "Network Slice ID."}, ) @restconf_ns.response(204, "Subscription successfully deleted") @restconf_ns.response(500, "Internal server error") - def delete(self, client_id, slice_id): - return Api(NSController(controller_type="RESTCONF")).delete_subscriptions(client_id, slice_id) + def delete(self, client_id: str, slice_id: str) -> tuple[dict[str, Any], int]: + """Delete a specific subscription.""" + return RestconfHandler(NSController(controller_type="RESTCONF")).delete_subscriptions(client_id, slice_id) @restconf_ns.route("/operations/telemetry/subscription//slice//stream") class SliceSubscriptionsStream(Resource): + """Resource for streaming SSE telemetry updates for a subscription.""" + @restconf_ns.doc( description="Opens a telemetry stream of a given subscription of a certain client", - params={ - "client_id": "Client ID.", - "slice_id": "Network Slice ID." - } + params={"client_id": "Client ID.", "slice_id": "Network Slice ID."}, ) @restconf_ns.response(200, "Telemetry stream opened") @restconf_ns.response(500, "Internal server error") - def get(self, client_id, slice_id): - api = Api(NSController(controller_type="RESTCONF")) + def get(self, client_id: str, slice_id: str) -> Response: + """Stream SSE telemetry updates.""" + handler = RestconfHandler(NSController(controller_type="RESTCONF")) return Response( stream_with_context( - api.sync_stream( - api.stream_slice_subscription, + handler.sync_stream( + handler.stream_slice_subscription, client_id, - slice_id + slice_id, ) ), - mimetype="text/event-stream" + mimetype="text/event-stream", ) + @restconf_ns.route("/operations/telemetry/slice") class TelemetryList(Resource): + """Resource for retrieving telemetry metrics across all slices.""" + @restconf_ns.doc(description="Gets latest telemetry of all slices.") @restconf_ns.response(200, "Telemetry retrieved") @restconf_ns.response(500, "Internal server error") - def get(self): + def get(self) -> Any: + """Retrieve latest telemetry for all slices.""" controller = NSController(controller_type="RESTCONF") - logging.info("Retrieving latest telemetry for all slices") - return current_app.ensure_sync(Api(controller).get_telemetry)() + logger.info("Retrieving latest telemetry for all slices") + return current_app.ensure_sync(RestconfHandler(controller).get_telemetry)() + + @restconf_ns.route("/operations/telemetry/slice/") class Telemetry(Resource): + """Resource for retrieving telemetry metrics for a specific slice.""" + @restconf_ns.doc(description="Gets latest telemetry of a certain network slice") @restconf_ns.response(200, "Telemetry retrieved") @restconf_ns.response(404, "Slice not found") @restconf_ns.response(500, "Internal server error") - def get(self, slice_id): + def get(self, slice_id: str) -> Any: + """Retrieve latest telemetry for a specific slice.""" controller = NSController(controller_type="RESTCONF") - logging.info(f"Retrieving latest telemetry for slice '{slice_id}'") - return current_app.ensure_sync(Api(controller).get_telemetry)(slice_id=slice_id) + logger.info("Retrieving latest telemetry for slice '%s'", slice_id) + return current_app.ensure_sync(RestconfHandler(controller).get_telemetry)(slice_id=slice_id) + +# ============================================================================= +# Slice Reconfiguration +# ============================================================================= -# Models for Slice Reconfiguration slice_reconfig_data_model = restconf_ns.model( "RestconfSliceReconfigData", { "slice_id": fields.String(description="ID of the reconfigured network slice", example="slice-1"), - "new_path": fields.List(fields.String, description="New optimal path for traffic after reconfiguration", example=["xrv11", "xrv15", "xrv14"]), + "new_path": fields.List( + fields.String, + description="New optimal path for traffic after reconfiguration", + example=["xrv11", "xrv15", "xrv14"], + ), "request_payload": fields.Raw(description="Topology schedule request payload sent to Change Scheduler"), - "response": fields.Raw(description="Response returned by Change Scheduler service") - } + "response": fields.Raw(description="Response returned by Change Scheduler service"), + }, ) slice_reconfig_response_model = restconf_ns.model( @@ -421,20 +558,26 @@ slice_reconfig_response_model = restconf_ns.model( { "success": fields.Boolean(description="Indicates whether the reconfiguration request succeeded", example=True), "data": fields.Nested(slice_reconfig_data_model, description="Reconfiguration response details"), - "error": fields.String(description="Error message if any", example=None) - } + "error": fields.String(description="Error message if any", example=None), + }, ) -@restconf_ns.route("/operations/ietf-network-slice-service:network-slice-services/slice-service=/reconfigure") +@restconf_ns.route( + "/operations/ietf-network-slice-service:network-slice-services/slice-service=/reconfigure" +) @restconf_ns.doc(params={"slice_service_id": "The ID of the slice to reconfigure"}) class RestconfSliceReconfig(Resource): - @restconf_ns.doc(summary="Reconfigure a specific transport network slice", description="Computes shortest path, compares with current service path, and schedules topology changes with Change Scheduler using RESTCONF controller.") + """Resource for triggering slice reconfiguration.""" + + @restconf_ns.doc( + summary="Reconfigure a specific transport network slice", + description="Computes shortest path, compares with current service path, and schedules topology changes with Change Scheduler using RESTCONF controller.", + ) @restconf_ns.response(200, "Slice reconfigured successfully", slice_reconfig_response_model) @restconf_ns.response(404, "Transport network slice or service not found.") @restconf_ns.response(500, "Internal server error") - def post(self, slice_service_id): - """Reconfigure a slice using Change Scheduler Planner (RESTCONF controller)""" + def post(self, slice_service_id: str) -> tuple[dict[str, Any], int]: + """Reconfigure a slice using Change Scheduler Planner (RESTCONF controller).""" controller = NSController(controller_type="RESTCONF") - data, code = Api(controller).reconfig_slice(slice_service_id) - return data, code + return RestconfHandler(controller).reconfig_slice(slice_service_id) diff --git a/swagger/tfs_namespace.py b/swagger/tfs_namespace.py index 2686b83..6fbe8c8 100644 --- a/swagger/tfs_namespace.py +++ b/swagger/tfs_namespace.py @@ -14,141 +14,129 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. +"""TeraFlowSDN (TFS) namespace definitions and resource controllers.""" + +from __future__ import annotations + +from typing import Any + from flask import request -from flask_restx import Namespace, Resource, fields, reqparse +from flask_restx import Namespace, Resource, reqparse + +from src.api.main import Api as TfsHandler from src.main import NSController -from src.api.main import Api -import json -from swagger.models.create_models import create_gpp_nrm_28541_model, create_ietf_network_slice_nbi_yang_model +from swagger.helpers import extract_json_payload +from swagger.models.create_models import ( + create_gpp_nrm_28541_model, + create_ietf_network_slice_nbi_yang_model, +) tfs_ns = Namespace( "tfs", - description="Operations related to transport network slices with TeraflowSDN (TFS) controller" + description="Operations related to transport network slices with TeraflowSDN (TFS) controller", ) # 3GPP NRM TS28.541 Data models gpp_network_slice_request_model = create_gpp_nrm_28541_model(tfs_ns) # IETF draft-ietf-teas-ietf-network-slice-nbi-yang Data models - slice_ddbb_model, slice_response_model = create_ietf_network_slice_nbi_yang_model(tfs_ns) upload_parser = reqparse.RequestParser() -upload_parser.add_argument('file', location='files', type='FileStorage', help="File to upload") -upload_parser.add_argument('json_data', location='form', help="JSON Data in string format") +upload_parser.add_argument("file", location="files", type="FileStorage", help="File to upload") +upload_parser.add_argument("json_data", location="form", help="JSON Data in string format") + + +# ============================================================================= +# Namespace Resource Controllers +# ============================================================================= + -# Namespace Controllers @tfs_ns.route("/slice") class TfsSliceList(Resource): - @tfs_ns.doc(summary="Return all transport network slices", description="Returns all transport network slices from the slice controller.") + """Resource for collection-level slice operations on TFS controller.""" + + @tfs_ns.doc( + summary="Return all transport network slices", + description="Returns all transport network slices from the slice controller.", + ) @tfs_ns.response(200, "Slices returned", slice_ddbb_model) @tfs_ns.response(404, "Transport network slices not found") @tfs_ns.response(500, "Internal server error") - def get(self): - """Retrieve all slices""" + def get(self) -> tuple[dict[str, Any] | list[Any], int]: + """Retrieve all slices.""" controller = NSController(controller_type="TFS") - data, code = Api(controller).get_flows() - return data, code - - @tfs_ns.doc(summary="Submit a transport network slice request", description="This endpoint allows clients to submit transport network slice requests using a JSON payload.") - @tfs_ns.response(201,"Slice created successfully", slice_response_model) + return TfsHandler(controller).get_flows() + + @tfs_ns.doc( + summary="Submit a transport network slice request", + description="This endpoint allows clients to submit transport network slice requests using a JSON payload.", + ) + @tfs_ns.response(201, "Slice created successfully", slice_response_model) @tfs_ns.response(200, "No service to process.") @tfs_ns.response(400, "Invalid request format") @tfs_ns.response(500, "Internal server error") @tfs_ns.expect(upload_parser) - def post(self): - """Submit a new slice request with a file""" - - json_data = None - - # Try to get the JSON data from the uploaded file - uploaded_file = request.files.get('file') - if uploaded_file: - if not uploaded_file.filename.endswith('.json'): - return { - "success": False, - "data": None, - "error": "Only JSON files allowed" - }, 400 - - try: - json_data = json.load(uploaded_file) # Convert file to JSON - except json.JSONDecodeError: - return { - "success": False, - "data": None, - "error": "JSON file not valid" - }, 400 - - # If no file was uploaded, try to get the JSON data from the form - if json_data is None: - raw_json = request.form.get('json_data') - if raw_json: - try: - json_data = json.loads(raw_json) # Convert string to JSON - except json.JSONDecodeError: - return { - "success": False, - "data": None, - "error": "JSON file not valid" - }, 400 - - # If no JSON data was found, return an error - if json_data is None: - return { - "success": False, - "data": None, - "error": "No data sent" - }, 400 - - # Process the JSON data with the NSController + def post(self) -> tuple[dict[str, Any], int]: + """Submit a new slice request with a file or form payload.""" + json_data, err_resp = extract_json_payload(request) + if err_resp is not None: + return err_resp + controller = NSController(controller_type="TFS") - data, code = Api(controller).add_flow(json_data) - return data, code - - @tfs_ns.doc(summary="Delete all transport network slices", description="Deletes all transport network slices from the slice controller.") + return TfsHandler(controller).add_flow(json_data) # type: ignore[arg-type] + + @tfs_ns.doc( + summary="Delete all transport network slices", + description="Deletes all transport network slices from the slice controller.", + ) @tfs_ns.response(204, "All transport network slices deleted successfully.") @tfs_ns.response(500, "Internal server error") - def delete(self): - """Delete all slices""" + def delete(self) -> tuple[dict[str, Any], int]: + """Delete all slices.""" controller = NSController(controller_type="TFS") - data, code = Api(controller).delete_flows() - return data, code + return TfsHandler(controller).delete_flows() @tfs_ns.route("/slice/") @tfs_ns.doc(params={"slice_id": "The ID of the slice to retrieve or modify"}) class TfsSlice(Resource): - @tfs_ns.doc(summary="Return a specific transport network slice", description="Returns specific information related to a slice by providing its id") + """Resource for individual slice operations on TFS controller.""" + + @tfs_ns.doc( + summary="Return a specific transport network slice", + description="Returns specific information related to a slice by providing its id", + ) @tfs_ns.response(200, "Slice returned", slice_ddbb_model) @tfs_ns.response(404, "Transport network slice not found.") @tfs_ns.response(500, "Internal server error") - def get(self, slice_id): - """Retrieve a specific slice""" + def get(self, slice_id: str) -> tuple[dict[str, Any] | list[Any], int]: + """Retrieve a specific slice.""" controller = NSController(controller_type="TFS") - data, code = Api(controller).get_flows(slice_id) - return data, code + return TfsHandler(controller).get_flows(slice_id) - @tfs_ns.doc(summary="Delete a specific transport network slice", description="Deletes a specific transport network slice from the slice controller based on the provided `slice_id`.") + @tfs_ns.doc( + summary="Delete a specific transport network slice", + description="Deletes a specific transport network slice from the slice controller based on the provided `slice_id`.", + ) @tfs_ns.response(204, "Transport network slice deleted successfully.") @tfs_ns.response(404, "Transport network slice not found.") @tfs_ns.response(500, "Internal server error") - def delete(self, slice_id): - """Delete a slice""" + def delete(self, slice_id: str) -> tuple[dict[str, Any], int]: + """Delete a slice.""" controller = NSController(controller_type="TFS") - data, code = Api(controller).delete_flows(slice_id) - return data, code + return TfsHandler(controller).delete_flows(slice_id) @tfs_ns.expect(slice_ddbb_model, validate=True) - @tfs_ns.doc(summary="Modify a specific transport network slice", description="Returns a specific slice that has been modified") + @tfs_ns.doc( + summary="Modify a specific transport network slice", + description="Returns a specific slice that has been modified", + ) @tfs_ns.response(200, "Slice modified", slice_response_model) @tfs_ns.response(404, "Transport network slice not found.") @tfs_ns.response(500, "Internal server error") - def put(self, slice_id): - """Modify a slice""" + def put(self, slice_id: str) -> tuple[dict[str, Any], int]: + """Modify a slice.""" json_data = request.get_json() controller = NSController(controller_type="TFS") - data, code = Api(controller).modify_flow(slice_id, json_data) - return data, code - - + return TfsHandler(controller).modify_flow(slice_id, json_data) -- GitLab From 455ec60b28305c719558ba44df3156ee5d5656e2 Mon Sep 17 00:00:00 2001 From: velazquez Date: Wed, 19 Aug 2026 15:30:46 +0200 Subject: [PATCH 4/7] Code refactoring 3: improve API design --- src/api/base_handler.py | 13 ++++---- src/api/restconf_handler.py | 23 +++++++------- src/tests/test_api.py | 58 +++++++++++++++++++++++------------ src/tests/test_e2e.py | 40 ++++++++++++++---------- src/tests/test_namespaces.py | 33 ++++++++++++++++++-- src/utils/send_response.py | 8 ++--- swagger/E2E_namespace.py | 7 ++++- swagger/ixia_namespace.py | 5 ++- swagger/restconf_namespace.py | 3 +- swagger/tfs_namespace.py | 5 ++- 10 files changed, 132 insertions(+), 63 deletions(-) diff --git a/src/api/base_handler.py b/src/api/base_handler.py index e0d57c3..d6a8944 100644 --- a/src/api/base_handler.py +++ b/src/api/base_handler.py @@ -103,11 +103,11 @@ class BaseSliceHandler: logger.info("Slice created successfully") return send_response(True, code=201, data=result) except RuntimeError as exc: - return send_response(False, code=200, message=str(exc)) + return send_response(False, code=422, message=str(exc)) except Exception as exc: return send_response(False, code=500, message=str(exc)) - def get_flows(self, slice_id: str | None = None) -> tuple[dict[str, Any] | list[dict[str, Any]], int]: + def get_flows(self, slice_id: str | None = None) -> tuple[dict[str, Any], int]: """Retrieve transport network slice information.""" try: get_all_data_fn = _dep("get_all_data", _db_get_all_data) @@ -115,20 +115,21 @@ class BaseSliceHandler: if slice_id: for slice_item in content: if slice_item.get("slice_id") == slice_id: - return slice_item, 200 - raise ValueError("Transport network slices not found") + return send_response(True, code=200, data=slice_item) + raise ValueError("Transport network slice not found") if not content: - raise ValueError("Transport network slices not found") + return send_response(True, code=200, data=[]) filtered = [s for s in content if s.get("controller") == self.slice_service.controller_type] - return filtered, 200 + return send_response(True, code=200, data=filtered) except ValueError as exc: return send_response(False, code=404, message=str(exc)) except Exception as exc: return send_response(False, code=500, message=str(exc)) + def modify_flow(self, slice_id: str, intent: dict[str, Any]) -> tuple[dict[str, Any], int]: """Modify an existing transport network slice.""" try: diff --git a/src/api/restconf_handler.py b/src/api/restconf_handler.py index 6de678a..1cbb947 100644 --- a/src/api/restconf_handler.py +++ b/src/api/restconf_handler.py @@ -183,7 +183,7 @@ class RestconfHandler: data=result, ) except RuntimeError as exc: - return send_response(False, code=200, message=str(exc)) + return send_response(False, code=422, message=str(exc)) except Exception as exc: return send_response(False, code=500, message=str(exc)) @@ -432,7 +432,7 @@ class RestconfHandler: data=result, ) except RuntimeError as exc: - return send_response(False, code=200, message=str(exc)) + return send_response(False, code=422, message=str(exc)) except Exception as exc: return send_response(False, code=500, message=str(exc)) @@ -471,7 +471,7 @@ class RestconfHandler: except ValueError as exc: return send_response(False, code=404, message=str(exc)) except RuntimeError as exc: - return send_response(False, code=200, message=str(exc)) + return send_response(False, code=422, message=str(exc)) except Exception as exc: return send_response(False, code=500, message=str(exc)) @@ -696,17 +696,18 @@ class RestconfHandler: # RESTCONF Telemetry Operations: Clients # ------------------------------------------------------------------------- - def get_clients(self, client_id: str | None = None) -> tuple[dict[str, Any] | list[dict[str, Any]], int]: + def get_clients(self, client_id: str | None = None) -> tuple[dict[str, Any], int]: """Retrieve one or all registered telemetry clients.""" try: if client_id: get_cli_fn = _dep("get_client", _real_get_client) - return get_cli_fn(client_id), 200 + cli = get_cli_fn(client_id) + if not cli: + raise ValueError(f"Client '{client_id}' not found") + return send_response(True, code=200, data=cli) get_all_cli_fn = _dep("get_all_clients", _real_get_all_clients) clients = get_all_cli_fn() - if not clients: - raise ValueError("No clients found") - return clients, 200 + return send_response(True, code=200, data=clients or []) except ValueError as exc: return send_response(False, code=404, message=str(exc)) except Exception as exc: @@ -808,7 +809,7 @@ class RestconfHandler: True, code=201, message="Subscription successfully created", - data={"sliceId": slice_id, "frequency": frequency}, + data={"slice_id": slice_id, "frequency": frequency}, ) except KeyError as exc: return send_response(False, code=400, message=str(exc)) @@ -836,9 +837,9 @@ class RestconfHandler: logger.info("Subscription for slice '%s' and client '%s' modified successfully", slice_id, client_id) return send_response( True, - code=201, + code=200, message="Subscription successfully modified", - data={"sliceId": slice_id, "frequency": frequency}, + data={"slice_id": slice_id, "frequency": frequency}, ) except KeyError as exc: return send_response(False, code=400, message=str(exc)) diff --git a/src/tests/test_api.py b/src/tests/test_api.py index 790b803..2f25585 100644 --- a/src/tests/test_api.py +++ b/src/tests/test_api.py @@ -170,11 +170,11 @@ class TestBasicApiOperations: """Tests for basic API operations.""" def test_get_flows_empty(self, controller_with_mocked_db): - """Should return an error when there are no slices.""" + """Should return 200 with an empty list when there are no slices.""" result, code = Api(controller_with_mocked_db).get_flows() - assert code == 404 - assert result["success"] is False - assert result["data"] is None + assert code == 200 + assert result["success"] is True + assert result["data"] == [] def test_add_flow_success(self, controller_with_mocked_db, ietf_intent): """Should successfully add a flow.""" @@ -193,7 +193,8 @@ class TestBasicApiOperations: flows, code = Api(controller_with_mocked_db).get_flows() assert code == 200 - assert any(s["slice_id"] == "slice-test-1" for s in flows) + assert flows["success"] is True + assert any(s["slice_id"] == "slice-test-1" for s in flows["data"]) def test_modify_flow_success(self, controller_with_mocked_db, ietf_intent): """Should successfully modify an existing flow.""" @@ -232,7 +233,8 @@ class TestBasicApiOperations: result, code = Api(controller_with_mocked_db).get_flows("slice-test-1") assert code == 200 - assert result["slice_id"] == "slice-test-1" + assert result["success"] is True + assert result["data"]["slice_id"] == "slice-test-1" class TestErrorHandling: @@ -317,7 +319,8 @@ class TestClientAndSubscriptionOperations: # Update subscription res_upd, code_upd = api.update_subscription("client-sub-1", "slice-1", frequency=20) - assert code_upd == 201 + assert code_upd == 200 + assert res_upd["data"]["slice_id"] == "slice-1" # Delete all subscriptions for client res_del_all, code_del_all = api.delete_subscriptions("client-sub-1") @@ -761,7 +764,8 @@ class TestApiFullCoverage: with patch("src.api.main.get_all_data", return_value=[]): res_none, code_none = api.get_flows() - assert code_none == 404 + assert code_none == 200 + assert res_none["data"] == [] with ( flask_app.app_context(), @@ -859,10 +863,10 @@ class TestApiExtendedCoverage: def test_add_network_slice_service_branches(self, controller_with_mocked_db, sample_ietf_intent): api = Api(controller_with_mocked_db) - # RuntimeError -> 200 + # RuntimeError -> 422 with patch.object(api.slice_service, "nsc", side_effect=RuntimeError("No content")): res_rt, code_rt = api.add_network_slice_service(sample_ietf_intent) - assert code_rt == 200 + assert code_rt == 422 # Exception -> 500 with patch.object(api.slice_service, "nsc", side_effect=Exception("Uncaught")): @@ -1021,10 +1025,10 @@ class TestApiRequestedMethodsCoverage: res, code = api.add_network_slice_service(sample_ietf_intent) assert code == 201 - # RuntimeError -> 200 + # RuntimeError -> 422 with patch.object(api.slice_service, "nsc", side_effect=RuntimeError("No content")): res_rt, code_rt = api.add_network_slice_service(sample_ietf_intent) - assert code_rt == 200 + assert code_rt == 422 # Exception -> 500 with patch.object(api.slice_service, "nsc", side_effect=Exception("Uncaught")): @@ -1048,14 +1052,14 @@ class TestApiRequestedMethodsCoverage: res, code = api.add_slice_service(intent_tmpl) assert code == 201 - # RuntimeError -> 200 + # RuntimeError -> 422 with ( patch("src.api.main.get_data_store", side_effect=[None, tmpl_store_data]), patch("src.api.main.normalize_libyang_data", side_effect=lambda x: x), patch.object(api.slice_service, "nsc", side_effect=RuntimeError("No content")), ): res_rt, code_rt = api.add_slice_service(intent_tmpl) - assert code_rt == 200 + assert code_rt == 422 # Exception -> 500 with ( @@ -1094,14 +1098,14 @@ class TestApiRequestedMethodsCoverage: res_500, code_500 = api.update_slice_service("slice-1", intent.copy()) assert code_500 == 500 - # RuntimeError -> 200 + # RuntimeError -> 422 with ( patch("src.api.main.get_data_store", side_effect=[existing_slice, tmpl_data]), patch("src.api.main.normalize_libyang_data", side_effect=lambda x: x), patch.object(api.slice_service, "nsc", side_effect=RuntimeError("No service")), ): res_rt, code_rt = api.update_slice_service("slice-1", intent.copy()) - assert code_rt == 200 + assert code_rt == 422 # ValueError -> 404 with ( @@ -1203,10 +1207,11 @@ class TestApiRequestedMethodsCoverage: res_all, code_all = api.get_clients() assert code_all == 200 - # get_clients all - empty -> 404 + # get_clients all - empty -> 200 with [] with patch("src.api.main.get_all_clients", return_value=[]): - res_404, code_404 = api.get_clients() - assert code_404 == 404 + res_empty, code_empty = api.get_clients() + assert code_empty == 200 + assert res_empty["data"] == [] # get_clients Exception -> 500 with patch("src.api.main.get_all_clients", side_effect=Exception("Client DB Error")): @@ -1364,3 +1369,18 @@ class TestApiRequestedMethodsCoverage: items = list(gen) assert len(items) == 1 assert "event: error" in items[0] + + +class TestSendResponseSecurity: + """Tests to ensure send_response does not leak server internals.""" + + def test_send_response_no_file_path_leak(self): + from src.utils.send_response import send_response + + res, code = send_response(False, message="Invalid payload", code=400) + assert code == 400 + assert res["success"] is False + assert res["error"] == "Invalid payload" + assert "File:" not in res["error"] + assert "Line:" not in res["error"] + diff --git a/src/tests/test_e2e.py b/src/tests/test_e2e.py index 3535c9e..8bd7e3f 100644 --- a/src/tests/test_e2e.py +++ b/src/tests/test_e2e.py @@ -255,25 +255,18 @@ def test_add_and_delete_flow(app, rel_path, json_data, namespace, flags, set_fla else: pytest.fail(f"Unsupported namespace: {namespace}") - if namespace in ["tfs", "ixia", "e2e"]: - assert code in [200, 201], ( - f"Creation failed for request '{rel_path}' in namespace '{namespace}' with flags: {flags}. Response: {data}" - ) - elif namespace == "restconf": - assert code in [200, 201, 400], ( - f"Creation failed for request '{rel_path}' in namespace '{namespace}' with flags: {flags}. Response: {data}" - ) - - # Delete flow if created + # Strict contract validation: if code in [200, 201]: + # Success path: must have success=True and proper payload structure + assert isinstance(data, dict) and data.get("success") is True, ( + f"Successful creation with code {code} must have success=True: {data}" + ) slice_id = None - if isinstance(data, dict): - # Check data payload for slice_id - payload_data = data.get("data") - if isinstance(payload_data, dict): - slices = payload_data.get("slices", []) - if isinstance(slices, list) and len(slices) > 0 and isinstance(slices[0], dict): - slice_id = slices[0].get("id") + payload_data = data.get("data") + if isinstance(payload_data, dict): + slices = payload_data.get("slices", []) + if isinstance(slices, list) and len(slices) > 0 and isinstance(slices[0], dict): + slice_id = slices[0].get("id") if namespace in ["tfs", "ixia", "e2e"]: if slice_id: @@ -284,3 +277,16 @@ def test_add_and_delete_flow(app, rel_path, json_data, namespace, flags, set_fla _, delete_code = api.delete_slice_services() assert delete_code in [200, 204, 404], f"Deletion failed for slice '{slice_id}' in namespace '{namespace}'" + + elif code in [400, 422]: + # Controlled business rejection path: must have success=False and explicit error reason + assert isinstance(data, dict) and data.get("success") is False, ( + f"Controlled rejection with code {code} must have success=False: {data}" + ) + assert data.get("error"), f"Rejection with code {code} must return an error reason: {data}" + + else: + pytest.fail( + f"Unexpected status code {code} for request '{rel_path}' in namespace '{namespace}' " + f"with flags: {flags}. Response: {data}" + ) diff --git a/src/tests/test_namespaces.py b/src/tests/test_namespaces.py index 014cdf2..5bd323b 100644 --- a/src/tests/test_namespaces.py +++ b/src/tests/test_namespaces.py @@ -169,7 +169,7 @@ def test_restconf_services_crud(client, auth_headers, sample_ietf_intent, temp_s headers=auth_headers, data=json.dumps(sample_ietf_intent), ) - assert post_resp.status_code in [200, 201, 500] + assert post_resp.status_code in [200, 201, 422, 500] # PUT (update) put_resp = client.put( @@ -177,7 +177,36 @@ def test_restconf_services_crud(client, auth_headers, sample_ietf_intent, temp_s headers=auth_headers, data=json.dumps(sample_ietf_intent), ) - assert put_resp.status_code in [200, 404, 500] + assert put_resp.status_code in [200, 404, 422, 500] + + +def test_plural_and_singular_routes(client, auth_headers, temp_sqlite_db): + """Test that plural and singular routes work consistently across namespaces.""" + # TFS + resp_slices = client.get("/tfs/slices", headers=auth_headers) + resp_slice = client.get("/tfs/slice", headers=auth_headers) + assert resp_slices.status_code == 200 + assert resp_slice.status_code == 200 + assert resp_slices.get_json()["data"] == resp_slice.get_json()["data"] + + # IXIA + resp_ixia_slices = client.get("/ixia/slices", headers=auth_headers) + resp_ixia_slice = client.get("/ixia/slice", headers=auth_headers) + assert resp_ixia_slices.status_code == 200 + assert resp_ixia_slice.status_code == 200 + + # E2E + resp_e2e_slices = client.get("/e2e/slices", headers=auth_headers) + resp_e2e_slice = client.get("/e2e/slice", headers=auth_headers) + assert resp_e2e_slices.status_code == 200 + assert resp_e2e_slice.status_code == 200 + + # E2E alerts + resp_alerts = client.get("/e2e/alerts", headers=auth_headers) + resp_alert = client.get("/e2e/alert", headers=auth_headers) + assert resp_alerts.status_code in [200, 404] + assert resp_alert.status_code in [200, 404] + def test_delete_slice_endpoints(client, auth_headers): diff --git a/src/utils/send_response.py b/src/utils/send_response.py index 6b8d86d..827aa8b 100644 --- a/src/utils/send_response.py +++ b/src/utils/send_response.py @@ -55,12 +55,12 @@ def send_response( status_code = code or 400 error_message = message or "An error occurred while processing the request." - error_info = f"{error_message} (File: {filename}, Line: {lineno})" - logger.warning("Request failed. Reason: %s", message) + logger.warning("Request failed. Reason: %s (File: %s, Line: %s)", error_message, filename, lineno) response = { "success": False, "data": None, - "error": error_info, + "error": error_message, } - return response, status_code \ No newline at end of file + return response, status_code + \ No newline at end of file diff --git a/swagger/E2E_namespace.py b/swagger/E2E_namespace.py index 05e46bb..8c57470 100644 --- a/swagger/E2E_namespace.py +++ b/swagger/E2E_namespace.py @@ -102,6 +102,7 @@ upload_parser.add_argument("json_data", location="form", help="JSON Data in stri # ============================================================================= +@e2e_ns.route("/slices") @e2e_ns.route("/slice") class E2ESliceList(Resource): """Resource for collection-level slice operations on E2E controller.""" @@ -123,7 +124,7 @@ class E2ESliceList(Resource): description="This endpoint allows clients to submit transport network slice requests using a JSON payload.", ) @e2e_ns.response(201, "Slice created successfully", slice_response_model) - @e2e_ns.response(200, "No service to process.") + @e2e_ns.response(422, "Unprocessable entity") @e2e_ns.response(400, "Invalid request format") @e2e_ns.response(500, "Internal server error") @e2e_ns.expect(upload_parser) @@ -148,6 +149,7 @@ class E2ESliceList(Resource): return E2EHandler(controller).delete_flows() +@e2e_ns.route("/slices/") @e2e_ns.route("/slice/") @e2e_ns.doc(params={"slice_id": "The ID of the slice to retrieve or modify"}) class E2ESlice(Resource): @@ -192,6 +194,7 @@ class E2ESlice(Resource): return E2EHandler(controller).modify_flow(slice_id, json_data) +@e2e_ns.route("/alerts") @e2e_ns.route("/alert") class E2EAlertList(Resource): """Resource for alert collection operations on E2E controller.""" @@ -226,9 +229,11 @@ class E2EAlertList(Resource): return E2EHandler(controller).delete_alerts() +@e2e_ns.route("/alerts/") @e2e_ns.route("/alert/") @e2e_ns.doc(params={"alert_id": "The UUID of the alert to retrieve, modify, or delete"}) class E2EAlertDetail(Resource): + """Resource for single alert operations on E2E controller.""" @e2e_ns.doc( diff --git a/swagger/ixia_namespace.py b/swagger/ixia_namespace.py index d30dbbc..31b1d64 100644 --- a/swagger/ixia_namespace.py +++ b/swagger/ixia_namespace.py @@ -52,6 +52,7 @@ upload_parser.add_argument("json_data", location="form", help="JSON Data in stri # ============================================================================= +@ixia_ns.route("/slices") @ixia_ns.route("/slice") class IxiaSliceList(Resource): """Resource for collection-level slice operations on IXIA controller.""" @@ -73,7 +74,7 @@ class IxiaSliceList(Resource): description="This endpoint allows clients to submit transport network slice requests using a JSON payload.", ) @ixia_ns.response(201, "Slice created successfully", slice_response_model) - @ixia_ns.response(200, "No service to process.") + @ixia_ns.response(422, "Unprocessable entity") @ixia_ns.response(400, "Invalid request format") @ixia_ns.response(500, "Internal server error") @ixia_ns.expect(upload_parser) @@ -98,9 +99,11 @@ class IxiaSliceList(Resource): return IxiaHandler(controller).delete_flows() +@ixia_ns.route("/slices/") @ixia_ns.route("/slice/") @ixia_ns.doc(params={"slice_id": "The ID of the slice to retrieve or modify"}) class IxiaSlice(Resource): + """Resource for individual slice operations on IXIA controller.""" @ixia_ns.doc( diff --git a/swagger/restconf_namespace.py b/swagger/restconf_namespace.py index b342433..463bc6b 100644 --- a/swagger/restconf_namespace.py +++ b/swagger/restconf_namespace.py @@ -450,8 +450,9 @@ class SliceSubscriptions(Resource): description="Updates a telemetry subscription for a given client.", params={"client_id": "Client ID.", "slice_id": "Network Slice ID."}, ) - @restconf_ns.response(201, "Subscription successfully updated") + @restconf_ns.response(200, "Subscription successfully updated") @restconf_ns.response(400, "Invalid request") + @restconf_ns.response(500, "Internal server error") @restconf_ns.expect( restconf_ns.model( diff --git a/swagger/tfs_namespace.py b/swagger/tfs_namespace.py index 6fbe8c8..4e00e6a 100644 --- a/swagger/tfs_namespace.py +++ b/swagger/tfs_namespace.py @@ -52,6 +52,7 @@ upload_parser.add_argument("json_data", location="form", help="JSON Data in stri # ============================================================================= +@tfs_ns.route("/slices") @tfs_ns.route("/slice") class TfsSliceList(Resource): """Resource for collection-level slice operations on TFS controller.""" @@ -73,7 +74,7 @@ class TfsSliceList(Resource): description="This endpoint allows clients to submit transport network slice requests using a JSON payload.", ) @tfs_ns.response(201, "Slice created successfully", slice_response_model) - @tfs_ns.response(200, "No service to process.") + @tfs_ns.response(422, "Unprocessable entity") @tfs_ns.response(400, "Invalid request format") @tfs_ns.response(500, "Internal server error") @tfs_ns.expect(upload_parser) @@ -98,9 +99,11 @@ class TfsSliceList(Resource): return TfsHandler(controller).delete_flows() +@tfs_ns.route("/slices/") @tfs_ns.route("/slice/") @tfs_ns.doc(params={"slice_id": "The ID of the slice to retrieve or modify"}) class TfsSlice(Resource): + """Resource for individual slice operations on TFS controller.""" @tfs_ns.doc( -- GitLab From b0938d2cc18ae8ed93408ad44494fd53d08c36ae Mon Sep 17 00:00:00 2001 From: velazquez Date: Thu, 20 Aug 2026 13:00:00 +0200 Subject: [PATCH 5/7] Code refactoring 4 --- .gitignore | 2 ++ pyproject.toml | 4 +++ src/api/__init__.py | 10 +++---- src/api/handlers/__init__.py | 33 ++++++++++++++++++++++ src/api/{ => handlers}/base_handler.py | 0 src/api/{ => handlers}/e2e_handler.py | 2 +- src/api/{ => handlers}/ixia_handler.py | 2 +- src/api/{ => handlers}/restconf_handler.py | 0 src/api/{ => handlers}/tfs_handler.py | 2 +- src/api/main.py | 10 +++---- 10 files changed, 52 insertions(+), 13 deletions(-) create mode 100644 src/api/handlers/__init__.py rename src/api/{ => handlers}/base_handler.py (100%) rename src/api/{ => handlers}/e2e_handler.py (99%) rename src/api/{ => handlers}/ixia_handler.py (94%) rename src/api/{ => handlers}/restconf_handler.py (100%) rename src/api/{ => handlers}/tfs_handler.py (94%) diff --git a/.gitignore b/.gitignore index 4e1845e..dc5f1b8 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,5 @@ alert.db .python-version .agents/ .coverage +.ruff_cache/ +.pytest_cache/ diff --git a/pyproject.toml b/pyproject.toml index aefb93c..a046f0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,3 +17,7 @@ ignore = [ testpaths = ["src/tests"] pythonpath = ["."] addopts = "-v --tb=short" + +[tool.pyright] +extraPaths = ["."] + diff --git a/src/api/__init__.py b/src/api/__init__.py index cbc8f2e..f6a28b4 100644 --- a/src/api/__init__.py +++ b/src/api/__init__.py @@ -18,12 +18,12 @@ from __future__ import annotations -from src.api.base_handler import BaseSliceHandler -from src.api.e2e_handler import E2EHandler -from src.api.ixia_handler import IxiaHandler +from src.api.handlers.base_handler import BaseSliceHandler +from src.api.handlers.e2e_handler import E2EHandler +from src.api.handlers.ixia_handler import IxiaHandler +from src.api.handlers.restconf_handler import RestconfHandler +from src.api.handlers.tfs_handler import TfsHandler from src.api.main import Api -from src.api.restconf_handler import RestconfHandler -from src.api.tfs_handler import TfsHandler __all__ = [ "Api", diff --git a/src/api/handlers/__init__.py b/src/api/handlers/__init__.py new file mode 100644 index 0000000..9a556bd --- /dev/null +++ b/src/api/handlers/__init__.py @@ -0,0 +1,33 @@ +# Copyright 2022-2026 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. + +"""Handlers subpackage initialization.""" + +from __future__ import annotations + +from src.api.handlers.base_handler import BaseSliceHandler +from src.api.handlers.e2e_handler import E2EHandler +from src.api.handlers.ixia_handler import IxiaHandler +from src.api.handlers.restconf_handler import RestconfHandler +from src.api.handlers.tfs_handler import TfsHandler + +__all__ = [ + "BaseSliceHandler", + "E2EHandler", + "IxiaHandler", + "RestconfHandler", + "TfsHandler", +] diff --git a/src/api/base_handler.py b/src/api/handlers/base_handler.py similarity index 100% rename from src/api/base_handler.py rename to src/api/handlers/base_handler.py diff --git a/src/api/e2e_handler.py b/src/api/handlers/e2e_handler.py similarity index 99% rename from src/api/e2e_handler.py rename to src/api/handlers/e2e_handler.py index e6be038..421e5ff 100644 --- a/src/api/e2e_handler.py +++ b/src/api/handlers/e2e_handler.py @@ -24,7 +24,7 @@ import sys from pathlib import Path from typing import Any -from src.api.base_handler import BaseSliceHandler +from src.api.handlers.base_handler import BaseSliceHandler from src.database import alert_db as _real_alert_db from src.database.db import ( get_all_data as _db_get_all_data, diff --git a/src/api/ixia_handler.py b/src/api/handlers/ixia_handler.py similarity index 94% rename from src/api/ixia_handler.py rename to src/api/handlers/ixia_handler.py index e875b1d..7d25372 100644 --- a/src/api/ixia_handler.py +++ b/src/api/handlers/ixia_handler.py @@ -20,7 +20,7 @@ from __future__ import annotations import logging -from src.api.base_handler import BaseSliceHandler +from src.api.handlers.base_handler import BaseSliceHandler logger = logging.getLogger(__name__) diff --git a/src/api/restconf_handler.py b/src/api/handlers/restconf_handler.py similarity index 100% rename from src/api/restconf_handler.py rename to src/api/handlers/restconf_handler.py diff --git a/src/api/tfs_handler.py b/src/api/handlers/tfs_handler.py similarity index 94% rename from src/api/tfs_handler.py rename to src/api/handlers/tfs_handler.py index 13b2866..520a206 100644 --- a/src/api/tfs_handler.py +++ b/src/api/handlers/tfs_handler.py @@ -20,7 +20,7 @@ from __future__ import annotations import logging -from src.api.base_handler import BaseSliceHandler +from src.api.handlers.base_handler import BaseSliceHandler logger = logging.getLogger(__name__) diff --git a/src/api/main.py b/src/api/main.py index 6fafcc0..caf1128 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -20,12 +20,12 @@ from __future__ import annotations from typing import Any -from src.api.base_handler import ( +from src.api.handlers.base_handler import ( BaseSliceHandler, _delete_slice_from_tfs, _extract_slice_type, ) -from src.api.e2e_handler import ( +from src.api.handlers.e2e_handler import ( E2EHandler, _find_slice_for_alert, _load_fallback_intent, @@ -34,12 +34,12 @@ from src.api.e2e_handler import ( _resolve_slice_id, _swap_p2mp_endpoints, ) -from src.api.ixia_handler import IxiaHandler -from src.api.restconf_handler import ( +from src.api.handlers.ixia_handler import IxiaHandler +from src.api.handlers.restconf_handler import ( RestconfHandler, _build_full_slice_intent, ) -from src.api.tfs_handler import TfsHandler +from src.api.handlers.tfs_handler import TfsHandler from src.database import alert_db from src.database.db import ( delete_all_data, -- GitLab From 781118a9e9e5d360e510807e03cd442b639582e5 Mon Sep 17 00:00:00 2001 From: velazquez Date: Mon, 24 Aug 2026 11:14:08 +0200 Subject: [PATCH 6/7] - Update slice reconfig endpoint - Update tests - Fix update of services in service database - Fix update of network slices in api - Fix bugs --- src/api/handlers/base_handler.py | 11 +- src/api/handlers/restconf_handler.py | 92 +++- src/database/store_data.py | 96 ++-- src/main.py | 124 +++--- src/mapper/main.py | 415 +++++++++--------- .../change_scheduler.py | 46 +- src/planner/planner.py | 25 +- src/realizer/main.py | 14 +- src/realizer/restconf/restconf_connect.py | 17 +- src/tests/test_initialization.py | 36 +- src/tests/test_mapper.py | 2 +- src/tests/test_planner.py | 116 ++--- swagger/restconf_namespace.py | 47 -- 13 files changed, 508 insertions(+), 533 deletions(-) diff --git a/src/api/handlers/base_handler.py b/src/api/handlers/base_handler.py index d6a8944..2038756 100644 --- a/src/api/handlers/base_handler.py +++ b/src/api/handlers/base_handler.py @@ -45,8 +45,17 @@ logger = logging.getLogger(__name__) def _dep(name: str, fallback: Any) -> Any: - """Resolve dependency from src.api.main if patched, otherwise fallback.""" + """Resolve dependency from src.api.main or src.database.db if patched, otherwise fallback.""" main_mod = sys.modules.get("src.api.main") + if main_mod is not None and hasattr(main_mod, name): + val = getattr(main_mod, name) + if hasattr(val, "return_value") or hasattr(val, "_mock_return_value") or hasattr(val, "assert_called"): + return val + db_mod = sys.modules.get("src.database.db") + if db_mod is not None and hasattr(db_mod, name): + val = getattr(db_mod, name) + if hasattr(val, "return_value") or hasattr(val, "_mock_return_value") or hasattr(val, "assert_called"): + return val if main_mod is not None and hasattr(main_mod, name): return getattr(main_mod, name) return fallback diff --git a/src/api/handlers/restconf_handler.py b/src/api/handlers/restconf_handler.py index 1cbb947..290629a 100644 --- a/src/api/handlers/restconf_handler.py +++ b/src/api/handlers/restconf_handler.py @@ -196,9 +196,77 @@ class RestconfHandler: if not existing_data: return send_response(False, code=404, message="Network slice services not found") - result = self.slice_service.nsc(intent) - if not result: - return send_response(False, code=500, message="Failed to process slice in TFS") + # 1. Normalize existing data and extract existing slice IDs + normalize_fn = _dep("normalize_libyang_data", _real_normalize_libyang_data) + normalized_existing = normalize_fn(existing_data) + existing_slices_list = safe_get( + normalized_existing, + ["network-slice-services", "slice-service"] + ) or [] + existing_slice_ids = { + item["id"] for item in existing_slices_list if isinstance(item, dict) and "id" in item + } + + # 2. Normalize incoming intent and extract new slice IDs + normalized_incoming = normalize_fn(intent) + incoming_slices_list = safe_get( + normalized_incoming, + ["ietf-network-slice-service:network-slice-services", "slice-service"] + ) or [] + incoming_slices_map = { + item["id"]: item for item in incoming_slices_list if isinstance(item, dict) and "id" in item + } + new_slice_ids = set(incoming_slices_map.keys()) + + # 3. Calculate diffs: to_delete, to_update, to_create + to_delete = existing_slice_ids - new_slice_ids + to_update = existing_slice_ids & new_slice_ids + to_create = new_slice_ids - existing_slice_ids + + logger.info("Slice Update diff - Delete: %s, Update: %s, Create: %s", to_delete, to_update, to_create) + + # A) Delete slices no longer present + for old_id in to_delete: + logger.info("Deleting slice '%s' during container update...", old_id) + self._delete_single_slice_service(old_id) + + results = [] + # B) Update existing slices + for slice_id in to_update: + logger.info("Updating slice '%s' during container update...", slice_id) + slice_item = incoming_slices_map[slice_id] + full_intent = { + "ietf-network-slice-service:network-slice-services": { + "slo-sle-templates": safe_get(normalized_incoming, ["ietf-network-slice-service:network-slice-services", "slo-sle-templates"]) or {}, + "slice-service": [slice_item], + } + } + res = self.slice_service.nsc(full_intent, slice_id=slice_id) + if not res: + return send_response(False, code=500, message=f"Failed to process slice '{slice_id}' in TFS") + results.append(res) + + # C) Create new slices + for slice_id in to_create: + logger.info("Creating new slice '%s' during container update...", slice_id) + slice_item = incoming_slices_map[slice_id] + full_intent = { + "ietf-network-slice-service:network-slice-services": { + "slo-sle-templates": safe_get(normalized_incoming, ["ietf-network-slice-service:network-slice-services", "slo-sle-templates"]) or {}, + "slice-service": [slice_item], + } + } + res = self.slice_service.nsc(full_intent, slice_id=None) + if not res: + return send_response(False, code=500, message=f"Failed to process new slice '{slice_id}' in TFS") + results.append(res) + + if len(to_update) == 0 and len(to_create) == 0: + slice_id = safe_get(intent, ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "id"]) + res = self.slice_service.nsc(intent, slice_id=slice_id) + if not res: + return send_response(False, code=500, message="Failed to process slice in TFS") + results.append(res) update_ds_fn = _dep("update_data_store", _real_update_data_store) update_ds_fn(intent) @@ -207,10 +275,12 @@ class RestconfHandler: True, code=200, message="Network slice services updated successfully", - data=result, + data=results if len(results) > 1 else (results[0] if results else {}), ) except ValueError as exc: return send_response(False, code=404, message=str(exc)) + except RuntimeError as exc: + return send_response(False, code=422, message=str(exc)) except Exception as exc: return send_response(False, code=500, message=str(exc)) @@ -452,7 +522,7 @@ class RestconfHandler: if err_resp is not None: return err_resp - result = self.slice_service.nsc(full_intent) + result = self.slice_service.nsc(full_intent, slice_id) if not result: return send_response(False, code=500, message="Slice not updated") @@ -985,16 +1055,4 @@ class RestconfHandler: yield f"event: error\ndata: {json.dumps({'error': str(exc)})}\n\n" break - # ------------------------------------------------------------------------- - # RESTCONF Reconfiguration Operations - # ------------------------------------------------------------------------- - def reconfig_slice(self, slice_id: str) -> tuple[dict[str, Any], int]: - """Trigger slice reconfiguration via Change Scheduler Planner.""" - try: - result = self.slice_service.reconfig_slice(slice_id) - return send_response(True, code=200, data=result) - except ValueError as exc: - return send_response(False, code=404, message=str(exc)) - except Exception as exc: - return send_response(False, code=500, message=str(exc)) diff --git a/src/database/store_data.py b/src/database/store_data.py index dd8fc93..a959e6a 100644 --- a/src/database/store_data.py +++ b/src/database/store_data.py @@ -1,45 +1,51 @@ -# Copyright 2022-2026 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. - -from typing import Any - -from src.database.db import save_data, update_data - - -def store_data( - intent: dict[str, Any], - slice_id: str | None = None, - controller_type: str | None = None, -) -> None: - """ - Store network slice intent information in the database. - - Args: - intent (dict[str, Any]): Network slice intent to be stored. - slice_id (str, optional): Existing slice ID to update. Defaults to None. - controller_type (str, optional): Controller type. Defaults to None. - """ - if controller_type == "RESTCONF": - return - - effective_controller = controller_type or "TFS" - - if slice_id: - update_data(slice_id, intent, effective_controller) - return - - resolved_slice_id = intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"] - save_data(resolved_slice_id, intent, effective_controller) \ No newline at end of file +# Copyright 2022-2026 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 typing import Any + +from src.database import db +from src.database.sysrepo_store import update_data_store + + +def store_data( + intent: dict[str, Any], + slice_id: str | None = None, + controller_type: str | None = None, +) -> None: + """ + Store network slice intent information in the database. + + Args: + intent (dict[str, Any]): Network slice intent to be stored. + slice_id (str, optional): Existing slice ID to update. Defaults to None. + controller_type (str, optional): Controller type. Defaults to None. + """ + if controller_type == "RESTCONF": + try: + update_data_store(intent) + return + except Exception as e: + logging.warning(f"Could not update sysrepo datastore: {e}") + + effective_controller = controller_type or "TFS" + + if slice_id: + db.update_data(slice_id, intent, effective_controller) + return + + resolved_slice_id = intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"] + db.save_data(resolved_slice_id, intent, effective_controller) diff --git a/src/main.py b/src/main.py index 6419dc4..20da254 100644 --- a/src/main.py +++ b/src/main.py @@ -91,20 +91,7 @@ def _subscribe_to_alerts(ietf_intents: list[dict[str, Any]], slice_id: str | Non logging.error(f"Failed to subscribe to notification service: {e}") -def _is_change_scheduler_viable(cs_result: Any) -> bool: - """Evaluate whether Change Scheduler response confirms viability.""" - if not isinstance(cs_result, dict): - return False - if cs_result.get("success", True) or cs_result.get("status_code") in (200, 201, 202): - return True - - cs_response = cs_result.get("response", {}) - if isinstance(cs_response, dict): - if cs_response.get("status") in ("VIABLE", "SCHEDULED") or cs_response.get("viable") is True: - return True - - return False class NSController: @@ -133,7 +120,7 @@ class NSController: self, intent_json: dict[str, Any], slice_id: str | None = None, - old_service_id: str | None = None, + old_service_id: str | None = None ) -> dict[str, Any]: """ Main Network Slice Controller workflow to process and realize network slice intents. @@ -144,7 +131,7 @@ class NSController: old_service_id (str, optional): Old service identifier to delete when modifying. Defaults to None. Returns: - dict[str, Any]: Contains slice creation responses and setup time in milliseconds. + dict[str, Any]: Contains slice creation/update responses and setup time in milliseconds. """ self.start_time = time.perf_counter() requests: dict[str, list[Any]] = {"services": []} @@ -152,17 +139,24 @@ class NSController: ietf_intents = nbi_processor(intent_json) is_update = bool(slice_id) + cs_result: dict[str, Any] | None = None for intent in ietf_intents: logging.debug(intent) payload = { "intent": intent, "is_update": is_update, + "slice_id": slice_id, } + logging.debug(f"Is Update: {is_update}") + logging.debug(f"Slice ID: {slice_id}") services, rules = mapper(payload, controller_type=self.controller_type) logging.debug(f"Services: {services}") - + if is_update and current_app.config.get("PLANNER_TYPE") == "CHANGE_SCHEDULER" and isinstance(rules, dict): + cs_result = rules + self.response = build_response(intent, self.response, controller_type=self.controller_type) + logging.warning(self.response) for service in services: request = realizer( @@ -203,11 +197,17 @@ class NSController: self.end_time = time.perf_counter() setup_time = (self.end_time - self.start_time) * 1000 - return { + modification_summary = { "slices": self.response, "setup_time": setup_time, } + if isinstance(cs_result, dict): + cs_result["slice_modification"] = modification_summary + return cs_result + + return modification_summary + def monitoring(self, slice_id: str, slo_sle_template: dict[str, Any]) -> dict[str, Any]: """ Monitor status and SLO compliance of a specific network slice. @@ -219,6 +219,9 @@ class NSController: Returns: dict[str, Any]: Aggregated monitoring metrics and compliance status. """ + if current_app.config.get("DUMMY_MODE"): + raise Exception("Dummy mode is enabled. Cannot monitor slice with dummy mode.") + payload = { "slice_id": slice_id, "slo_sle_template": slo_sle_template, @@ -232,61 +235,48 @@ class NSController: if not is_compliant: logging.warning( - f"SLO/SLE compliance violation (is_compliant=False) detected for slice '{slice_id}'. Triggering automatic reconfig_slice..." + f"SLO/SLE compliance violation (is_compliant=False) detected for slice '{slice_id}'." ) - try: - reconfig_res = self.reconfig_slice(slice_id) - if isinstance(metrics, dict): - metrics["reconfig_result"] = reconfig_res - except Exception as e: - logging.error(f"Automatic reconfig_slice failed for slice '{slice_id}': {e}") - if isinstance(metrics, dict): - metrics["reconfig_error"] = str(e) + planner_type = current_app.config.get("PLANNER_TYPE") + if planner_type == "CHANGE_SCHEDULER": + logging.info(f"Triggering automatic reconfig via self.nsc with planner_type='CHANGE_SCHEDULER' for slice '{slice_id}'...") + try: + intent = None + try: + xpath = "/ietf-network-slice-service:network-slice-services" + intent = get_data_store(xpath) + except Exception as e: + logging.warning(f"Could not retrieve slice '{slice_id}' from sysrepo: {e}") + + if not intent: + try: + from src.database.db import get_data + slice_db_entry = get_data(slice_id) + intent = slice_db_entry.get("intent") if isinstance(slice_db_entry, dict) else None + except Exception as e: + logging.warning(f"Could not retrieve slice '{slice_id}' from DB: {e}") + + if not intent: + intent = { + "ietf-network-slice-service:network-slice-services": { + "slice-service": [{"id": slice_id}] + } + } + + if isinstance(intent, dict) and "network-slice-services" in intent: + intent["ietf-network-slice-service:network-slice-services"] = intent.pop("network-slice-services") + + reconfig_res = self.nsc(intent, slice_id=slice_id, planner_type="CHANGE_SCHEDULER") + if isinstance(metrics, dict): + metrics["reconfig_result"] = reconfig_res + except Exception as e: + logging.error(f"Automatic reconfig failed for slice '{slice_id}': {e}") + if isinstance(metrics, dict): + metrics["reconfig_error"] = str(e) return metrics - def reconfig_slice(self, slice_id: str) -> dict[str, Any]: - """ - Reconfigure a network slice by computing optimal path and coordinating with Change Scheduler. - - Args: - slice_id (str): Network slice identifier. - - Returns: - dict[str, Any]: Result from Planner and slice modification. - """ - reconfig_data = realizer({"slice_id": slice_id}, action="RECONFIG", controller_type=self.controller_type) - planner = Planner() - cs_result = planner.planner(reconfig_data, type="CHANGE_SCHEDULER") - - if _is_change_scheduler_viable(cs_result): - logging.info( - f"Change Scheduler confirmed viability for slice '{slice_id}'. Performing slice PUT modification..." - ) - try: - xpath = "/ietf-network-slice-service:network-slice-services" - intent = get_data_store(xpath) - if not intent: - raise ValueError("Network slice services not found") - - if isinstance(intent, dict) and "network-slice-services" in intent: - intent["ietf-network-slice-service:network-slice-services"] = intent.pop("network-slice-services") - - logging.debug(f"Intent found for slice '{slice_id}': {intent}") - mod_result = self.nsc(intent, slice_id=slice_id) - - if isinstance(cs_result, dict): - cs_result["slice_modification"] = mod_result - logging.info( - f"Slice '{slice_id}' successfully modified via PUT following Change Scheduler viability confirmation." - ) - update_data_store(intent) - except Exception as e: - logging.error(f"Error during slice '{slice_id}' PUT modification: {e}") - if isinstance(cs_result, dict): - cs_result["slice_modification_error"] = str(e) - return cs_result diff --git a/src/mapper/main.py b/src/mapper/main.py index 80af575..a6b8b76 100644 --- a/src/mapper/main.py +++ b/src/mapper/main.py @@ -1,200 +1,215 @@ -# Copyright 2022-2026 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 typing import Any - -from flask import current_app - -from src.database.service_db import save_data -from src.database.sysrepo_store import get_data_store, normalize_libyang_data -from src.planner.planner import Planner -from src.realizer.main import realizer -from src.utils.safe_get import safe_get - -from .aggregate_monitoring import aggregate_monitoring -from .get_service_template import get_service_template -from .process_connnectivity import normalize_connectivity_type, process_connectivity -from .slo_viability import slo_viability - - -def _handle_nrp_mapping(ietf_intent: dict[str, Any]) -> bool: - """Evaluate and assign NRP for slice intent if NRP is enabled.""" - nrp_view = realizer(None, True, "READ") - slos = safe_get( - ietf_intent, - [ - "ietf-network-slice-service:network-slice-services", - "slo-sle-templates", - "slo-sle-template", - 0, - "slo-policy", - "metric-bound", - ], - ) - if not slos: - return True - - candidates = [ - (nrp, slo_viability(slos, nrp)[1]) - for nrp in nrp_view - if slo_viability(slos, nrp)[0] and nrp.get("available") - ] - logging.debug(f"Candidates: {candidates}") - - best_nrp = max(candidates, key=lambda x: x[1])[0] if candidates else None - logging.debug(f"Best NRP: {best_nrp}") - - if best_nrp: - slice_id = safe_get( - ietf_intent, - ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "id"], - ) - if slice_id: - best_nrp.setdefault("slices", []).append(slice_id) - realizer(ietf_intent, True, "UPDATE") - return True - - answer = realizer(ietf_intent, True, "CREATE", best_nrp) - if not answer: - logging.error("Slice rejected due to lack of NRPs") - return False - return True - - - -def _collect_available_templates(ietf_intent: dict[str, Any]) -> list[dict[str, Any]]: - """Retrieve and combine sysrepo datastore and intent SLO/SLE templates.""" - raw_templates = get_data_store( - "/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template" - ) - normalized = normalize_libyang_data(raw_templates) - available_templates: list[dict[str, Any]] = safe_get( - normalized, ["slo-sle-templates", "slo-sle-template"] - ) or [] - - intent_templates = safe_get( - ietf_intent, - ["ietf-network-slice-service:network-slice-services", "slo-sle-templates", "slo-sle-template"], - ) or [] - available_templates.extend(intent_templates) - return available_templates - - -def _map_restconf_services(ietf_intent: dict[str, Any]) -> list[dict[str, Any]]: - """Transform IETF intent into discrete RESTCONF service constructs.""" - available_templates = _collect_available_templates(ietf_intent) - services: list[dict[str, Any]] = [] - - slice_services = safe_get( - ietf_intent, ["ietf-network-slice-service:network-slice-services", "slice-service"] - ) or [] - - for slice_service in slice_services: - service_id = safe_get(slice_service, ["id"]) - way = safe_get(slice_service, ["service-tags", "tag-type", 0, "tag-type-value", 0]) - service_template = get_service_template(slice_service, available_templates) - - connection_groups = safe_get(slice_service, ["connection-groups", "connection-group"]) or [] - for connection_group in connection_groups: - cg_id = safe_get(connection_group, ["id"]) - group_id = f"{service_id}-{cg_id}" - - group_template = get_service_template(connection_group, available_templates) or service_template - raw_conn_type = safe_get(connection_group, ["connectivity-type"]) - connectivity_type = normalize_connectivity_type(raw_conn_type) - - constructs = safe_get(connection_group, ["connectivity-construct"]) or [] - for construct in constructs: - construct_id_raw = safe_get(construct, ["id"]) - full_construct_id = f"{group_id}-{construct_id_raw}" - - final_template = get_service_template(construct, available_templates) or group_template - - sdps = process_connectivity( - cg_id, - connectivity_type, - construct, - construct_id_raw, - slice_service, - ) - if not sdps: - continue - - service = { - "id": full_construct_id, - "template": final_template, - "connectivity_type": connectivity_type, - "sdps": sdps, - "way": way, - } - services.append(service) - - if not current_app.config.get("DUMMY_MODE", False): - save_data(service_id=service["id"], slice_id=service_id) - - if connectivity_type == "point-to-point": - break - - return services - - -def mapper( - payload: dict[str, Any], - controller_type: str = "TFS", - action: str = "CREATE", -) -> tuple[Any, Any] | dict[str, Any] | None: - """ - Map an IETF network slice intent to suitable Network Resource Partitions or controllers. - - Args: - payload (dict[str, Any]): Mapping request payload. - controller_type (str, optional): SDN controller type. Defaults to "TFS". - action (str, optional): Requested action ('CREATE', 'MONITOR'). Defaults to "CREATE". - - Returns: - tuple[Any, Any] | dict[str, Any] | None: Mapped services/optimal path or telemetry dictionary. - """ - match action: - case "CREATE": - ietf_intent = payload.get("intent") - services: Any = [ietf_intent] - optimal_path = None - - if current_app.config.get("NRP_ENABLED", False): - if not _handle_nrp_mapping(ietf_intent): - return None - - if current_app.config.get("PLANNER_ENABLED", False): - is_update = payload.get("is_update", False) - planner_type = current_app.config.get("PLANNER_TYPE", "SHORTEST_PATH") - optimal_path = Planner().planner(ietf_intent, planner_type, is_update=is_update) - logging.debug(f"Optimal path: {optimal_path}") - - if controller_type == "RESTCONF": - services = _map_restconf_services(ietf_intent) - - return services, optimal_path - - case "MONITOR": - logging.debug("Mapper action: MONITOR") - slice_id = payload.get("slice_id") - slo_sle_template = payload.get("slo_sle_template") - return aggregate_monitoring(slice_id, slo_sle_template) - - case _: - return None, None - \ No newline at end of file +# Copyright 2022-2026 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 +import uuid +from typing import Any + +from flask import current_app + +from src.database.service_db import save_data, delete_by_slice_id +from src.database.sysrepo_store import get_data_store, normalize_libyang_data +from src.planner.planner import Planner +from src.realizer.main import realizer +from src.utils.safe_get import safe_get + +from .aggregate_monitoring import aggregate_monitoring +from .get_service_template import get_service_template +from .process_connnectivity import normalize_connectivity_type, process_connectivity +from .slo_viability import slo_viability + + +def _handle_nrp_mapping(ietf_intent: dict[str, Any]) -> bool: + """Evaluate and assign NRP for slice intent if NRP is enabled.""" + nrp_view = realizer(None, True, "READ") + slos = safe_get( + ietf_intent, + [ + "ietf-network-slice-service:network-slice-services", + "slo-sle-templates", + "slo-sle-template", + 0, + "slo-policy", + "metric-bound", + ], + ) + if not slos: + return True + + candidates = [ + (nrp, slo_viability(slos, nrp)[1]) + for nrp in nrp_view + if slo_viability(slos, nrp)[0] and nrp.get("available") + ] + logging.debug(f"Candidates: {candidates}") + + best_nrp = max(candidates, key=lambda x: x[1])[0] if candidates else None + logging.debug(f"Best NRP: {best_nrp}") + + if best_nrp: + slice_id = safe_get( + ietf_intent, + ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "id"], + ) + if slice_id: + best_nrp.setdefault("slices", []).append(slice_id) + realizer(ietf_intent, True, "UPDATE") + return True + + answer = realizer(ietf_intent, True, "CREATE", best_nrp) + if not answer: + logging.error("Slice rejected due to lack of NRPs") + return False + return True + + + +def _collect_available_templates(ietf_intent: dict[str, Any]) -> list[dict[str, Any]]: + """Retrieve and combine sysrepo datastore and intent SLO/SLE templates.""" + raw_templates = get_data_store( + "/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template" + ) + normalized = normalize_libyang_data(raw_templates) + available_templates: list[dict[str, Any]] = safe_get( + normalized, ["slo-sle-templates", "slo-sle-template"] + ) or [] + + intent_templates = safe_get( + ietf_intent, + ["ietf-network-slice-service:network-slice-services", "slo-sle-templates", "slo-sle-template"], + ) or [] + available_templates.extend(intent_templates) + return available_templates + + +def _map_restconf_services(ietf_intent: dict[str, Any], is_update: bool) -> list[dict[str, Any]]: + """Map IETF intent into services.""" + available_templates = _collect_available_templates(ietf_intent) + services: list[dict[str, Any]] = [] + + slice_services = safe_get( + ietf_intent, ["ietf-network-slice-service:network-slice-services", "slice-service"] + ) or [] + + for slice_service in slice_services: + service_id = safe_get(slice_service, ["id"]) + way = safe_get(slice_service, ["service-tags", "tag-type", 0, "tag-type-value", 0]) + service_template = get_service_template(slice_service, available_templates) + logging.debug(f"Service template: {service_template}") + + connection_groups = safe_get(slice_service, ["connection-groups", "connection-group"]) or [] + for connection_group in connection_groups: + cg_id = safe_get(connection_group, ["id"]) + group_id = f"{service_id}-{cg_id}" + + group_template = get_service_template(connection_group, available_templates) or service_template + logging.debug(f"Group template: {group_template}") + raw_conn_type = safe_get(connection_group, ["connectivity-type"]) + connectivity_type = normalize_connectivity_type(raw_conn_type) + + constructs = safe_get(connection_group, ["connectivity-construct"]) or [] + for construct in constructs: + construct_id_raw = safe_get(construct, ["id"]) + full_construct_id = f"{group_id}-{construct_id_raw}-{uuid.uuid4()}" + + final_template = get_service_template(construct, available_templates) or group_template + logging.debug(f"Final template: {final_template}") + + sdps = process_connectivity( + cg_id, + connectivity_type, + construct, + construct_id_raw, + slice_service, + ) + if not sdps: + continue + + service = { + "id": full_construct_id, + "template": final_template, + "connectivity_type": connectivity_type, + "sdps": sdps, + "way": way, + } + services.append(service) + + if connectivity_type == "point-to-point": + break + + if not current_app.config.get("DUMMY_MODE", False): + if is_update: + delete_by_slice_id(service_id) + for service in services: + save_data(service_id=service["id"], slice_id=service_id) + + return services + + +def mapper( + payload: dict[str, Any], + controller_type: str = "TFS", + action: str = "CREATE", +) -> tuple[Any, Any] | dict[str, Any] | None: + """ + Map an IETF network slice intent to suitable Network Resource Partitions or controllers. + + Args: + payload (dict[str, Any]): Mapping request payload. + controller_type (str, optional): SDN controller type. Defaults to "TFS". + action (str, optional): Requested action ('CREATE', 'MONITOR'). Defaults to "CREATE". + + Returns: + tuple[Any, Any] | dict[str, Any] | None: Mapped services/optimal path or telemetry dictionary. + """ + match action: + case "CREATE": + ietf_intent = payload.get("intent") + services: Any = [ietf_intent] + optimal_path = None + slice_id = payload.get("slice_id", None) + is_update = payload.get("is_update", False) + + if current_app.config.get("NRP_ENABLED", False): + if not _handle_nrp_mapping(ietf_intent): + return None + + planner_type = current_app.config.get("PLANNER_TYPE", None) + if current_app.config.get("PLANNER_ENABLED", False): + if not current_app.config.get("DUMMY_MODE", False) or planner_type == "ENERGY": + if is_update and planner_type == "CHANGE_SCHEDULER": + reconfig_data = realizer({"slice_id": slice_id}, action="RECONFIG", controller_type=controller_type) + optimal_path = Planner().planner(reconfig_data, planner_type, is_update=is_update) + else: + optimal_path = Planner().planner(ietf_intent, planner_type, is_update=is_update) + + logging.debug(f"Optimal path: {optimal_path}") + + if controller_type == "RESTCONF": + logging.debug(f"SLICE ID is: {slice_id}") + services = _map_restconf_services(ietf_intent, is_update) + + return services, optimal_path + + case "MONITOR": + logging.debug("Mapper action: MONITOR") + slice_id = payload.get("slice_id") + slo_sle_template = payload.get("slo_sle_template") + return aggregate_monitoring(slice_id, slo_sle_template) + + case _: + return None, None + diff --git a/src/planner/change_scheduler_planner/change_scheduler.py b/src/planner/change_scheduler_planner/change_scheduler.py index d0295e6..47ae221 100644 --- a/src/planner/change_scheduler_planner/change_scheduler.py +++ b/src/planner/change_scheduler_planner/change_scheduler.py @@ -20,9 +20,7 @@ from datetime import datetime, timezone import requests from flask import current_app -from src.database.service_db import get_data_by_slice_id from src.planner.shortest_path import get_shortest_path, normalize_node_id -from src.realizer.restconf.connectors.tfs_connector import tfs_connector from src.utils.safe_get import safe_get @@ -85,38 +83,6 @@ def change_scheduler_planner( cs_port = cs_port or 8090 change_scheduler_url = f"http://{cs_ip}:{cs_port}/change-scheduler/request" - # Fetch service path and topology if not provided directly by realizer - if not current_path or not network: - tfs_ip = "127.0.0.1" - try: - tfs_ip = current_app.config.get("RESTCONF_IP", "127.0.0.1") - except RuntimeError: - pass - - services = get_data_by_slice_id(slice_id) - if not services: - raise ValueError(f"No services found for slice '{slice_id}'.") - - service_id = services[0].get("service_id") - if not service_id: - raise ValueError(f"No valid service_id found for slice '{slice_id}'.") - - conn = tfs_connector() - - if not current_path: - path, path_code = conn.get_service_path(tfs_ip, service_id) - if path_code != 200 or not path or len(path) < 2: - logging.error(f"Failed to retrieve valid current service path for service '{service_id}'") - raise Exception(f"Could not retrieve service path for service '{service_id}'.") - current_path = path - - if not network: - net, topo_code = conn.get_network_topology(tfs_ip, slice_id) - if topo_code != 200 or not net: - logging.error(f"Failed to retrieve network topology for slice '{slice_id}'") - raise Exception(f"Could not retrieve network topology for slice '{slice_id}'.") - network = net - if not current_path or len(current_path) < 2: raise Exception(f"Invalid current service path for slice '{slice_id}'.") @@ -129,9 +95,7 @@ def change_scheduler_planner( logging.error(f"Failed to calculate shortest path between '{src_node}' and '{dst_node}'") raise Exception(f"Could not compute shortest path from '{src_node}' to '{dst_node}'.") - - - # 4. Compare current_path vs optimal_path + # Compare current_path vs optimal_path current_nodes = set(normalize_node_id(n) for n in current_path) optimal_nodes = set(normalize_node_id(n) for n in optimal_path) @@ -294,6 +258,14 @@ def change_scheduler_planner( } # 6. Send request to Change Scheduler + if current_app.config.get("DUMMY_MODE", False): + return { + "slice_id": slice_id, + "new_path": optimal_path, + "request_payload": payload, + "response": {"status": "feasible", "setup_time": 0.0}, + } + logging.info(f"Sending Change Scheduler request to '{change_scheduler_url}': {payload}") headers = {"Content-Type": "application/json"} try: diff --git a/src/planner/planner.py b/src/planner/planner.py index 941bcdc..f75d0eb 100644 --- a/src/planner/planner.py +++ b/src/planner/planner.py @@ -58,6 +58,22 @@ class Planner: network=network, **kwargs, ) + + + def _is_change_scheduler_viable(self, cs_result: Any) -> bool: + """Evaluate whether Change Scheduler response confirms viability.""" + if not isinstance(cs_result, dict): + return False + + if cs_result.get("success", True) or cs_result.get("status_code") in (200, 201, 202): + return True + + cs_response = cs_result.get("response", {}) + if isinstance(cs_response, dict): + if cs_response.get("status") in ("VIABLE", "SCHEDULED") or cs_response.get("viable") is True: + return True + + return False def planner( self, @@ -93,7 +109,14 @@ class Planner: intent, current_app.config["E2E_OPTICAL_IP"], action=action ) case "CHANGE_SCHEDULER": - return self._handle_change_scheduler(intent) + if not is_update: + logging.info("Change Scheduler planner only supports update requests.") + return None + response = self._handle_change_scheduler(intent) + if not self._is_change_scheduler_viable(response): + logging.warning(f"Change Scheduler declared operation not viable for slice.") + return None + return response case _: return None diff --git a/src/realizer/main.py b/src/realizer/main.py index f65098d..13bf666 100644 --- a/src/realizer/main.py +++ b/src/realizer/main.py @@ -101,19 +101,7 @@ def _realize_create( else: way = service.get("way") if isinstance(service, dict) else None if not way and isinstance(service, dict): - way = safe_get( - service, - [ - "ietf-network-slice-service:network-slice-services", - "slice-service", - 0, - "service-tags", - "tag-type", - 0, - "tag-type-value", - 0, - ], - ) + way = safe_get(service, ["ietf-network-slice-service:network-slice-services","slice-service",0,"service-tags","tag-type",0,"tag-type-value",0,]) logger.info("Selected way: %s", way) return select_way(controller=controller_type, way=way, ietf_intent=service, response=response, rules=rules) diff --git a/src/realizer/restconf/restconf_connect.py b/src/realizer/restconf/restconf_connect.py index e882c7c..e1b13b4 100644 --- a/src/realizer/restconf/restconf_connect.py +++ b/src/realizer/restconf/restconf_connect.py @@ -103,22 +103,7 @@ def restconf_connect(requests: dict[str, Any], restconf_ip: str) -> Any: 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", - ], - ) + 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.get("DATAPLANE_SUPPORT") == "FRR": success, error_response = _handle_frr_dataplane(dscp) if not success: diff --git a/src/tests/test_initialization.py b/src/tests/test_initialization.py index d32783b..b445847 100644 --- a/src/tests/test_initialization.py +++ b/src/tests/test_initialization.py @@ -82,21 +82,23 @@ def test_create_app_initialization(temp_sqlite_db): assert "API_USERNAME" in app.config -def test_controller_monitoring(): +def test_controller_monitoring(flask_app): """Test NSController.monitoring dispatches to realizer and mapper with MONITOR action.""" - controller = NSController(controller_type="RESTCONF") - slice_id = "slice-mon-1" - slo_sle_template = {"slo-policy": {"metric-bound": []}} - - with patch("src.main.realizer") as mock_realizer, patch("src.main.mapper") as mock_mapper: - mock_mapper.return_value = {"slice_id": slice_id, "is_compliant": True} - - res = controller.monitoring(slice_id, slo_sle_template) - assert res == {"slice_id": slice_id, "is_compliant": True} - - mock_realizer.assert_called_once_with( - {"slice_id": slice_id, "slo_sle_template": slo_sle_template}, action="MONITOR", controller_type="RESTCONF" - ) - mock_mapper.assert_called_once_with( - {"slice_id": slice_id, "slo_sle_template": slo_sle_template}, action="MONITOR" - ) + with flask_app.app_context(): + flask_app.config["DUMMY_MODE"] = False + controller = NSController(controller_type="RESTCONF") + slice_id = "slice-mon-1" + slo_sle_template = {"slo-policy": {"metric-bound": []}} + + with patch("src.main.realizer") as mock_realizer, patch("src.main.mapper") as mock_mapper: + mock_mapper.return_value = {"slice_id": slice_id, "is_compliant": True} + + res = controller.monitoring(slice_id, slo_sle_template) + assert res == {"slice_id": slice_id, "is_compliant": True} + + mock_realizer.assert_called_once_with( + {"slice_id": slice_id, "slo_sle_template": slo_sle_template}, action="MONITOR", controller_type="RESTCONF" + ) + mock_mapper.assert_called_once_with( + {"slice_id": slice_id, "slo_sle_template": slo_sle_template}, action="MONITOR" + ) diff --git a/src/tests/test_mapper.py b/src/tests/test_mapper.py index 4f7e28a..8b4f184 100644 --- a/src/tests/test_mapper.py +++ b/src/tests/test_mapper.py @@ -752,7 +752,7 @@ class TestMapperMonitoring: ): services, rules = mapper(payload, controller_type="RESTCONF") assert len(services) == 1 - assert services[0]["id"] == "slice-full-1-cg-1-cc-1" + assert services[0]["id"].startswith("slice-full-1-cg-1-cc-1") assert services[0]["template"] == {"id": "construct-tmpl", "bound": 30} assert services[0]["connectivity_type"] == "point-to-point" mock_save_data.assert_called_once() diff --git a/src/tests/test_planner.py b/src/tests/test_planner.py index d362d70..0d2ee91 100644 --- a/src/tests/test_planner.py +++ b/src/tests/test_planner.py @@ -352,28 +352,26 @@ def test_find_link_info(change_scheduler_sample_network): def test_change_scheduler_planner_success(flask_app, change_scheduler_sample_network): - with ( - patch("src.planner.change_scheduler_planner.change_scheduler.get_data_by_slice_id") as mock_db, - patch("src.planner.change_scheduler_planner.change_scheduler.tfs_connector") as mock_conn_cls, - patch("src.planner.change_scheduler_planner.change_scheduler.get_shortest_path") as mock_sp, - patch("requests.post") as mock_post, - ): - mock_db.return_value = [{"service_id": "svc-100", "slice_id": "slice-1"}] - - mock_conn = MagicMock() - mock_conn.get_service_path.return_value = (["xrv11", "xrv12", "xrv13", "xrv14"], 200) - mock_conn.get_network_topology.return_value = (change_scheduler_sample_network, 200) - mock_conn_cls.return_value = mock_conn - - mock_sp.return_value = (["xrv11", "xrv15", "xrv14"], 200) - - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.headers = {"content-type": "application/json"} - mock_response.json.return_value = {"status": "SCHEDULED"} - mock_post.return_value = mock_response - - res = change_scheduler_planner("slice-1", change_scheduler_url="http://127.0.0.1:8090/change-scheduler/request") + with flask_app.app_context(): + flask_app.config["DUMMY_MODE"] = False + with ( + patch("src.planner.change_scheduler_planner.change_scheduler.get_shortest_path") as mock_sp, + patch("requests.post") as mock_post, + ): + mock_sp.return_value = (["xrv11", "xrv15", "xrv14"], 200) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.headers = {"content-type": "application/json"} + mock_response.json.return_value = {"status": "SCHEDULED"} + mock_post.return_value = mock_response + + res = change_scheduler_planner( + "slice-1", + current_path=["xrv11", "xrv12", "xrv13", "xrv14"], + network=change_scheduler_sample_network, + change_scheduler_url="http://127.0.0.1:8090/change-scheduler/request", + ) assert res["slice_id"] == "slice-1" assert res["new_path"] == ["xrv11", "xrv15", "xrv14"] @@ -402,47 +400,35 @@ def test_change_scheduler_planner_success(flask_app, change_scheduler_sample_net def test_change_scheduler_planner_no_services(): - with patch("src.planner.change_scheduler_planner.change_scheduler.get_data_by_slice_id") as mock_db: - mock_db.side_effect = ValueError("No services found") - - with pytest.raises(ValueError) as exc: - change_scheduler_planner("nonexistent-slice") - assert "No services found" in str(exc.value) + with pytest.raises(Exception) as exc: + change_scheduler_planner("nonexistent-slice", current_path=None) + assert "Invalid current service path" in str(exc.value) def test_change_scheduler_planner_service_path_failure(flask_app): - with ( - patch("src.planner.change_scheduler_planner.change_scheduler.get_data_by_slice_id") as mock_db, - patch("src.planner.change_scheduler_planner.change_scheduler.tfs_connector") as mock_conn_cls, - ): - mock_db.return_value = [{"service_id": "svc-100", "slice_id": "slice-1"}] - mock_conn = MagicMock() - mock_conn.get_service_path.return_value = ([], 404) - mock_conn_cls.return_value = mock_conn - - with pytest.raises(Exception) as exc: - change_scheduler_planner("slice-1") - assert "Could not retrieve service path" in str(exc.value) - - -def test_reconfig_slice_main(flask_app): - with ( - patch("src.main.realizer") as mock_realizer, - patch("src.planner.planner.change_scheduler_planner") as mock_planner, - ): - mock_realizer.return_value = { - "slice_id": "slice-123", - "service_path": ["xrv11", "xrv12"], - "network_topology": {}, - } - mock_planner.return_value = {"slice_id": "slice-123", "new_path": ["xrv11"]} + with pytest.raises(Exception) as exc: + change_scheduler_planner("slice-1", current_path=[]) + assert "Invalid current service path" in str(exc.value) - nsc = NSController(controller_type="TFS") - res = nsc.reconfig_slice("slice-123") - assert res["slice_id"] == "slice-123" - mock_realizer.assert_called_once_with({"slice_id": "slice-123"}, action="RECONFIG", controller_type="TFS") - mock_planner.assert_called_once() +def test_monitoring_automatic_reconfig_change_scheduler(flask_app): + with flask_app.app_context(): + flask_app.config["DUMMY_MODE"] = False + flask_app.config["PLANNER_TYPE"] = "CHANGE_SCHEDULER" + with ( + patch("src.main.realizer") as mock_realizer, + patch("src.main.mapper") as mock_mapper, + patch("src.main.NSController.nsc") as mock_nsc, + ): + mock_realizer.return_value = None + mock_mapper.return_value = {"slo_sle_compliance": {"is_compliant": False}} + mock_nsc.return_value = {"slice_id": "slice-auto", "status": "VIABLE"} + + nsc = NSController(controller_type="TFS") + res = nsc.monitoring("slice-auto", {"slo": "test"}) + + assert res.get("reconfig_result") == {"slice_id": "slice-auto", "status": "VIABLE"} + mock_nsc.assert_called_once() def test_planner_class_change_scheduler(): @@ -450,20 +436,8 @@ def test_planner_class_change_scheduler(): mock_planner.return_value = {"slice_id": "slice-999", "new_path": ["A", "B"]} p = Planner() - res = p.planner("slice-999", type="CHANGE_SCHEDULER") + res = p.planner("slice-999", type="CHANGE_SCHEDULER", is_update=True) assert res == {"slice_id": "slice-999", "new_path": ["A", "B"]} mock_planner.assert_called_once_with("slice-999", current_path=None, network=None) - -def test_api_reconfig_slice(flask_app): - with patch("src.main.NSController.reconfig_slice") as mock_nsc: - mock_nsc.return_value = {"slice_id": "slice-1", "new_path": ["xrv11", "xrv15"]} - - nsc = NSController(controller_type="TFS") - api = Api(nsc) - res, code = api.reconfig_slice("slice-1") - - assert code == 200 - assert res["success"] is True - assert res["data"] == {"slice_id": "slice-1", "new_path": ["xrv11", "xrv15"]} diff --git a/swagger/restconf_namespace.py b/swagger/restconf_namespace.py index 463bc6b..67be036 100644 --- a/swagger/restconf_namespace.py +++ b/swagger/restconf_namespace.py @@ -535,50 +535,3 @@ class Telemetry(Resource): logger.info("Retrieving latest telemetry for slice '%s'", slice_id) return current_app.ensure_sync(RestconfHandler(controller).get_telemetry)(slice_id=slice_id) - -# ============================================================================= -# Slice Reconfiguration -# ============================================================================= - -slice_reconfig_data_model = restconf_ns.model( - "RestconfSliceReconfigData", - { - "slice_id": fields.String(description="ID of the reconfigured network slice", example="slice-1"), - "new_path": fields.List( - fields.String, - description="New optimal path for traffic after reconfiguration", - example=["xrv11", "xrv15", "xrv14"], - ), - "request_payload": fields.Raw(description="Topology schedule request payload sent to Change Scheduler"), - "response": fields.Raw(description="Response returned by Change Scheduler service"), - }, -) - -slice_reconfig_response_model = restconf_ns.model( - "RestconfSliceReconfigResponse", - { - "success": fields.Boolean(description="Indicates whether the reconfiguration request succeeded", example=True), - "data": fields.Nested(slice_reconfig_data_model, description="Reconfiguration response details"), - "error": fields.String(description="Error message if any", example=None), - }, -) - - -@restconf_ns.route( - "/operations/ietf-network-slice-service:network-slice-services/slice-service=/reconfigure" -) -@restconf_ns.doc(params={"slice_service_id": "The ID of the slice to reconfigure"}) -class RestconfSliceReconfig(Resource): - """Resource for triggering slice reconfiguration.""" - - @restconf_ns.doc( - summary="Reconfigure a specific transport network slice", - description="Computes shortest path, compares with current service path, and schedules topology changes with Change Scheduler using RESTCONF controller.", - ) - @restconf_ns.response(200, "Slice reconfigured successfully", slice_reconfig_response_model) - @restconf_ns.response(404, "Transport network slice or service not found.") - @restconf_ns.response(500, "Internal server error") - def post(self, slice_service_id: str) -> tuple[dict[str, Any], int]: - """Reconfigure a slice using Change Scheduler Planner (RESTCONF controller).""" - controller = NSController(controller_type="RESTCONF") - return RestconfHandler(controller).reconfig_slice(slice_service_id) -- GitLab From c9f6989d6d03173699940e6e9d38403b2e973646 Mon Sep 17 00:00:00 2001 From: velazquez Date: Mon, 24 Aug 2026 11:51:54 +0200 Subject: [PATCH 7/7] Increase pipeline timeout --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index f8dcd65..3f646a5 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -37,7 +37,7 @@ build nsc: # Apply unit test to the component unit_test nsc: - timeout: 15m + timeout: 30m variables: IMAGE_NAME: 'nsc' # name of the microservice IMAGE_TAG: 'test' # tag of the container image (production, development, etc) -- GitLab