From f2c378cf6816803f5e18b84fae121f8fc8063c0e Mon Sep 17 00:00:00 2001 From: velazquez Date: Fri, 19 Sep 2025 12:04:10 +0200 Subject: [PATCH 01/26] API improved to be consistent --- src/network_slice_controller.py | 277 ++++++++++++++++++-------------- swagger/ixia_namespace.py | 48 +++--- swagger/models/create_models.py | 55 ++++--- swagger/tfs_namespace.py | 54 +++++-- 4 files changed, 251 insertions(+), 183 deletions(-) diff --git a/src/network_slice_controller.py b/src/network_slice_controller.py index 6ac7088..33d6a9a 100644 --- a/src/network_slice_controller.py +++ b/src/network_slice_controller.py @@ -42,15 +42,13 @@ class NSController: - Slice Realization: Convert intents to specific network configurations (L2VPN, L3VPN) """ - def __init__(self, controller_type = "TFS", tfs_ip=TFS_IP, ixia_ip =IXIA_IP, need_l2vpn_support=TFS_L2VPN_SUPPORT): + def __init__(self, controller_type = "TFS"): """ Initialize the Network Slice Controller. Args: controller_type (str): Flag to determine if configurations should be uploaded to Teraflow or IXIA system. - need_l2vpn_support (bool, optional): Flag to determine if additional - L2VPN configuration support is required. Defaults to False. Attributes: controller_type (str): Flag for Teraflow or Ixia upload @@ -60,20 +58,18 @@ class NSController: need_l2vpn_support (bool): Flag for additional L2VPN configuration support """ self.controller_type = controller_type - self.tfs_ip = tfs_ip + self.path = "" self.answer = {} self.cool_answer = {} self.start_time = 0 self.end_time = 0 self.setup_time = 0 - self.need_l2vpn_support = need_l2vpn_support - # Internal templates and views self.__gpp_template = "" self.__ietf_template = "" self.__teraflow_template = "" self.__nrp_view = "" - self.subnet="" + self.subnet = "" # API Methods def add_flow(self, intent): @@ -93,7 +89,19 @@ class NSController: ValueError: If no transport network slices are found Exception: For unexpected errors during slice creation process """ - return self.nsc(intent) + try: + result = self.nsc(intent) + if not result: + return self.__send_response(False, code=404, message="No intents found") + + return self.__send_response( + True, + code=201, + data=result + ) + except Exception as e: + # Handle unexpected errors + return self.__send_response(False, code=500, message=str(e)) def get_flows(self,slice_id=None): """ @@ -128,13 +136,14 @@ class NSController: if slice_id: for slice in content: if slice["slice_id"] == slice_id: - return slice + 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.controller_type] + return [slice for slice in content if slice.get("controller") == self.controller_type], 200 except ValueError as e: # Handle case where no slices are found @@ -157,7 +166,20 @@ class NSController: API Endpoint: PUT /slice/{id} """ - return self.nsc(intent, slice_id) + try: + result = self.nsc(intent, slice_id) + if not result: + return self.__send_response(False, code=404, message="Slice not found") + + return self.__send_response( + True, + code=200, + message="Slice modified successfully", + data=result + ) + except Exception as e: + # Handle unexpected errors + return self.__send_response(False, code=500, message=str(e)) def delete_flows(self, slice_id=None): """ @@ -206,14 +228,14 @@ class NSController: with open(os.path.join(SRC_PATH, "slice_ddbb.json"), 'w') as file: json.dump(content, file, indent=4) logging.info(f"Slice {slice_id} removed successfully") - return self.__send_response(False, code=200, status="success", message=f"Transpor network slice {slice_id} deleted successfully") + return {}, 204 # Delete all slices else: # Optional: Delete in Teraflow if configured if self.controller_type == "TFS": # TODO: should send a delete request to Teraflow - if self.need_l2vpn_support: + if TFS_L2VPN_SUPPORT: self.__tfs_l2vpn_delete() data_removed = [slice for slice in content if slice.get("controller") == self.controller_type] @@ -228,7 +250,7 @@ class NSController: json.dump(filtered_data, file, indent=4) logging.info("All slices removed successfully") - return self.__send_response(False, code=200, status="success", message="All transport network slices deleted successfully.") + return {}, 204 except ValueError as e: return self.__send_response(False, code=404, message=str(e)) @@ -257,78 +279,53 @@ class NSController: tuple: Response status and HTTP status code """ - try: - # Start performance tracking - self.start_time = time.perf_counter() + # Start performance tracking + self.start_time = time.perf_counter() - # Reset requests and load IETF template - self.__load_template(1, os.path.join(TEMPLATES_PATH, "ietf_template_empty.json")) - requests = {"services":[]} + # Reset requests and load IETF template + self.__load_template(1, os.path.join(TEMPLATES_PATH, "ietf_template_empty.json")) + requests = {"services":[]} - # Store the received template for debugging - if DUMP_TEMPLATES: - with open(os.path.join(TEMPLATES_PATH, "nbi_template.json"), "w") as file: - file.write(json.dumps(intent_json,indent=2)) - - # Process intent (translate if 3GPP) - ietf_intents = self.__nbi_processor(intent_json) - - # Store the generated template for debugging - if DUMP_TEMPLATES: - with open(os.path.join(TEMPLATES_PATH, "ietf_template.json"), "w") as file: - file.write(json.dumps(ietf_intents,indent=2)) - - if ietf_intents: - for intent in ietf_intents: - # Extract and store slice request details - self.__extract_data(intent) - self.__store_data(intent, slice_id) - # Mapper - self.__mapper(intent) - # Realizer - tfs_request = self.__realizer(intent) - requests["services"].append(tfs_request) - else: - return self.__send_response(False, code=404, message="No intents found") - - # Store the generated template for debugging - if DUMP_TEMPLATES: - with open(os.path.join(TEMPLATES_PATH, "realizer_template.json"), "w") as archivo: - archivo.write(json.dumps(requests,indent=2)) - - # Optional: Upload template to Teraflow - if not DUMMY_MODE: - if self.controller_type == "TFS": - if UPLOAD_TYPE == "WEBUI": - response = tfs_connector().webui_post(self.tfs_ip, requests) - elif UPLOAD_TYPE == "NBI": - for intent in requests["services"]: - # Send each separate NBI request - response = tfs_connector().nbi_post(self.tfs_ip, intent, self.path) - - if not response.ok: - return self.__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 self.need_l2vpn_support: - self.__tfs_l2vpn_support(requests["services"]) - - logging.info("Request sent to Teraflow") - elif self.controller_type == "IXIA": - neii_controller = NEII_controller() - for intent in requests["services"]: - # Send each separate IXIA request - neii_controller.nscNEII(intent) - logging.info("Requests sent to Ixia") + # Store the received template for debugging + self.__dump_templates("nbi_template", intent_json) + + # Process intent (translate if 3GPP) + ietf_intents = self.__nbi_processor(intent_json) + if not ietf_intents: + return None # Nothing to process + + # Store the generated template for debugging + self.__dump_templates("ietf_template", ietf_intents) + + for intent in ietf_intents: + # Extract and store slice request details + self.__extract_data(intent) + self.__store_data(intent, slice_id) + # Mapper + self.__mapper(intent) + # Realizer + tfs_request = self.__realizer(intent) + requests["services"].append(tfs_request) + + # Store the generated template for debugging + self.__dump_templates("realizer_template", requests) + + # Optional: Upload template to Teraflow + response = self.__send_controller(self.controller_type, requests) - # End performance tracking - self.end_time = time.perf_counter() - return self.__send_response(True, code=200) + if not response: + raise Exception("Controller upload failed") + + # End performance tracking + self.end_time = time.perf_counter() + setup_time = (self.end_time - self.start_time) * 1000 - except ValueError as e: - return self.__send_response(False, code=400, message=str(e)) - except Exception as e: - return self.__send_response(False, code=500, message=str(e)) + slices = self.__build_response(self.answer) + + return { + "slices": slices, + "setup_time": setup_time + } def __nbi_processor(self, intent_json): """ @@ -472,52 +469,60 @@ class NSController: logging.error(f"Template loading error: {e}") return self.__send_response(False, code=500, message=f"Template loading error: {e}") - def __send_response(self, result, status="error", message=None, code=None): + def __dump_templates(self, name, file): + if DUMP_TEMPLATES: + with open(os.path.join(TEMPLATES_PATH, f"{name}.json"), "w") as archivo: + archivo.write(json.dumps(file,indent=2)) + + def __build_response(self, answer): + slices = [] + if hasattr(self, "answer") and isinstance(self.answer, dict): + for subnet, data in self.answer.items(): + slices.append({ + "id": subnet, + "source": data.get("Source"), + "destination": data.get("Destination"), + "vlan": data.get("VLAN"), + "requirements": data.get("QoS Requirements"), + }) + return slices + + def __send_response(self, result, message=None, code=None, data=None): """ - Generate and send a response to the 3GPP client about the slice request. + Generate and send a standardized API response for the 3GPP client. Args: - result (bool): Indicates whether the slice request was successful - status (str, optional): Response status. Defaults to "error" - message (str, optional): Additional error message. Defaults to None - code (str, optional): Response code. Defaults to None + result (bool): Indicates whether the slice request was successful. + message (str, optional): Additional message (success or error). + code (int, optional): HTTP response code. If not provided, defaults + to 200 for success and 400 for error. Returns: - tuple: A tuple containing the response dictionary and status code - """ + tuple: (response_dict, http_status_code) + """ if result: - # Successful slice creation - logging.info("Your slice request was fulfilled sucessfully") - self.setup_time = (self.end_time - self.start_time)*1000 - logging.info(f"Setup time: {self.setup_time:.2f}") - - # Construct detailed successful response - answer = { - "status": "success", - "code": code, - "slices": [], - "setup_time": self.setup_time + # Ensure code is 200 if not provided + code = code or 200 + + response = { + "success": True, + "data": data or {}, + "error": None, } - # Add slice details to the response - for subnet in self.answer: - slice_info = { - "id": subnet, - "source": self.answer[subnet]["Source"], - "destination": self.answer[subnet]["Destination"], - "vlan": self.answer[subnet]["VLAN"], - "requirements": self.answer[subnet]["QoS Requirements"], - } - answer["slices"].append(slice_info) - self.cool_answer = answer + else: - # Failed slice creation - logging.info("Your request cannot be fulfilled. Reason: "+message) - self.cool_answer = { - "status" :status, - "code": code, - "message": message + # Ensure code is 400 if not provided + code = code or 400 + + logging.warning(f"Request failed. Reason: {message}") + + response = { + "success": False, + "data": None, + "error": message or "An error occurred while processing the request." } - return self.cool_answer, code + + return response, code def __extract_data(self, intent_json): """ @@ -582,6 +587,32 @@ class NSController: with open(file_path, 'w') as file: json.dump(content, file, indent=4) + def __send_controller(self, controller_type, requests): + if not DUMMY_MODE: + if controller_type == "TFS": + if UPLOAD_TYPE == "WEBUI": + response = tfs_connector().webui_post(TFS_IP, requests) + elif UPLOAD_TYPE == "NBI": + for intent in requests["services"]: + # Send each separate NBI request + response = tfs_connector().nbi_post(TFS_IP, intent, self.path) + + if not response.ok: + return self.__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 TFS_L2VPN_SUPPORT: + self.__tfs_l2vpn_support(requests["services"]) + + logging.info("Request sent to Teraflow") + elif controller_type == "IXIA": + neii_controller = NEII_controller() + for intent in requests["services"]: + # Send each separate IXIA request + response = neii_controller.nscNEII(intent) + logging.info("Requests sent to Ixia") + return response + else: return True ### NBI processor functionalities def __detect_format(self,json_data): """ @@ -726,13 +757,13 @@ class NSController: if slo["metric-type"] == nrp_slo["metric-type"]: # Handle maximum type SLOs if slo["metric-type"] in slo_type["max"]: - flexibility = (nrp_slo["bound"] - slo["bound"]) / slo["bound"] - if slo["bound"] > nrp_slo["bound"]: + 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"]: - flexibility = (slo["bound"] - nrp_slo["bound"]) / slo["bound"] - if slo["bound"] < nrp_slo["bound"]: + 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 diff --git a/swagger/ixia_namespace.py b/swagger/ixia_namespace.py index 6a14ffe..905863a 100644 --- a/swagger/ixia_namespace.py +++ b/swagger/ixia_namespace.py @@ -1,5 +1,5 @@ from flask import request -from flask_restx import Namespace, Resource, fields, reqparse +from flask_restx import Namespace, Resource, reqparse from src.network_slice_controller import NSController import json from swagger.models.create_models import create_gpp_nrm_28541_model, create_ietf_network_slice_nbi_yang_model @@ -13,13 +13,13 @@ ixia_ns = Namespace( gpp_network_slice_request_model = create_gpp_nrm_28541_model(ixia_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(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") + # Namespace Controllers @ixia_ns.route("/slice") class IxiaSliceList(Resource): @@ -30,53 +30,50 @@ class IxiaSliceList(Resource): def get(self): """Retrieve all slices""" controller = NSController(controller_type="IXIA") - return controller.get_flows() + data, code = 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.") - @ixia_ns.response(200, "Slice request successfully processed", slice_response_model) + @ixia_ns.response(201, "Slice created successfully", slice_response_model) @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 - # 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 {"error": "Only JSON files allowed"}, 400 - + return {"success": False, "data": None, "error": "Only JSON files allowed"}, 400 try: - json_data = json.load(uploaded_file) # Convert file to JSON + json_data = json.load(uploaded_file) except json.JSONDecodeError: - return {"error": "JSON file not valid"}, 400 + 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 + json_data = json.loads(raw_json) except json.JSONDecodeError: - return {"error": "JSON file not valid"}, 400 - - # If no JSON data was found, return an error + return {"success": False, "data": None, "error": "JSON file not valid"}, 400 + if json_data is None: - return {"error": "No data sent"}, 400 + return {"success": False, "data": None, "error": "No data sent"}, 400 - # Process the JSON data with the NSController controller = NSController(controller_type="IXIA") - return controller.add_flow(json_data) + data, code = 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.") - @ixia_ns.response(200, "All transport network slices deleted successfully.") + @ixia_ns.response(204, "All transport network slices deleted successfully.") @ixia_ns.response(500, "Internal server error") def delete(self): """Delete all slices""" controller = NSController(controller_type="IXIA") - return controller.delete_flows() + data, code = controller.delete_flows() + return data, code @ixia_ns.route("/slice/") @@ -89,16 +86,18 @@ class IxiaSlice(Resource): def get(self, slice_id): """Retrieve a specific slice""" controller = NSController(controller_type="IXIA") - return controller.get_flows(slice_id) + data, code = controller.get_flows(slice_id) + return data, code @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(200, "Transport network slice deleted successfully.") + @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""" controller = NSController(controller_type="IXIA") - return controller.delete_flows(slice_id) + data, code = controller.delete_flows(slice_id) + return data, code @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") @@ -109,4 +108,5 @@ class IxiaSlice(Resource): """Modify a slice""" json_data = request.get_json() controller = NSController(controller_type="IXIA") - return controller.modify_flow(slice_id, json_data) \ No newline at end of file + data, code = controller.modify_flow(slice_id, json_data) + return data, code \ No newline at end of file diff --git a/swagger/models/create_models.py b/swagger/models/create_models.py index 94ca83b..9e965bf 100644 --- a/swagger/models/create_models.py +++ b/swagger/models/create_models.py @@ -300,27 +300,42 @@ def create_ietf_network_slice_nbi_yang_model(slice_ns): slice_response_model = slice_ns.model( "SliceResponse", { - "status": fields.String(description="Status of the request", example="success"), - "slices": fields.List( - fields.Nested( - slice_ns.model( - "SliceDetails", - { - "id": fields.String(description="Slice ID", example="CU-UP1_DU1"), - "source": fields.String(description="Source IP", example="100.2.1.2"), - "destination": fields.String(description="Destination IP", example="100.1.1.2"), - "vlan": fields.String(description="VLAN ID", example="100"), - "bandwidth(Mbps)": fields.Integer( - description="Bandwidth in Mbps", example=120 - ), - "latency(ms)": fields.Integer( - description="Latency in milliseconds", example=4 + "success": fields.Boolean(description="Indicates if the request was successful", example=True), + "data": fields.Nested( + slice_ns.model( + "SliceData", + { + "slices": fields.List( + fields.Nested( + slice_ns.model( + "SliceDetails", + { + "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"), + "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") + } + ) + ), + 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) + } ) return slice_ddbb_model, slice_response_model \ No newline at end of file diff --git a/swagger/tfs_namespace.py b/swagger/tfs_namespace.py index c9c3e07..1cf5119 100644 --- a/swagger/tfs_namespace.py +++ b/swagger/tfs_namespace.py @@ -33,8 +33,8 @@ gpp_network_slice_request_model = create_gpp_nrm_28541_model(tfs_ns) 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="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 Controllers @tfs_ns.route("/slice") @@ -46,10 +46,11 @@ class TfsSliceList(Resource): def get(self): """Retrieve all slices""" controller = NSController(controller_type="TFS") - return controller.get_flows() + data, code = 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(200, "Slice request successfully processed", slice_response_model) + @tfs_ns.response(201,"Slice created successfully", slice_response_model) @tfs_ns.response(400, "Invalid request format") @tfs_ns.response(500, "Internal server error") @tfs_ns.expect(upload_parser) @@ -62,12 +63,20 @@ class TfsSliceList(Resource): uploaded_file = request.files.get('file') if uploaded_file: if not uploaded_file.filename.endswith('.json'): - return {"error": "Only JSON files allowed"}, 400 + 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 {"error": "JSON file not valid"}, 400 + 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: @@ -76,23 +85,33 @@ class TfsSliceList(Resource): try: json_data = json.loads(raw_json) # Convert string to JSON except json.JSONDecodeError: - return {"error": "JSON file not valid"}, 400 + 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 {"error": "No data sent"}, 400 + return { + "success": False, + "data": None, + "error": "No data sent" + }, 400 # Process the JSON data with the NSController controller = NSController(controller_type="TFS") - return controller.add_flow(json_data) + data, code = 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.") - @tfs_ns.response(200, "All transport network slices deleted successfully.") + @tfs_ns.response(204, "All transport network slices deleted successfully.") @tfs_ns.response(500, "Internal server error") def delete(self): """Delete all slices""" controller = NSController(controller_type="TFS") - return controller.delete_flows() + data, code = controller.delete_flows() + return data, code @tfs_ns.route("/slice/") @@ -105,26 +124,29 @@ class TfsSlice(Resource): def get(self, slice_id): """Retrieve a specific slice""" controller = NSController(controller_type="TFS") - return controller.get_flows(slice_id) + data, code = controller.get_flows(slice_id) + return data, code @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(200, "Transport network slice deleted successfully.") + @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""" controller = NSController(controller_type="TFS") - return controller.delete_flows(slice_id) + data, code = controller.delete_flows(slice_id) + return data, code @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.response(200, "Slice modified", slice_ddbb_model) + @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""" json_data = request.get_json() controller = NSController(controller_type="TFS") - return controller.modify_flow(slice_id, json_data) + data, code = controller.modify_flow(slice_id, json_data) + return data, code -- GitLab From e47fd7444143de8d9fc7a861b9bb402bc4fd76f9 Mon Sep 17 00:00:00 2001 From: velazquez Date: Thu, 25 Sep 2025 15:53:50 +0200 Subject: [PATCH 02/26] Reestructure code in folders to be more readable --- app.py | 68 +- src/Constants.py | 68 - src/api/main.py | 193 +++ src/config/.env.example | 39 + src/{ => config}/IPs.json | 0 src/config/config.py | 45 + src/config/constants.py | 31 + src/{ => database}/nrp_ddbb.json | 8 +- src/{ => database}/slice_ddbb.json | 0 src/database/store_data.py | 44 + src/helpers.py | 142 -- src/main.py | 121 ++ src/mapper/main.py | 58 + src/mapper/slo_viability.py | 47 + src/nbi_processor/detect_format.py | 24 + src/nbi_processor/main.py | 41 + src/nbi_processor/translator.py | 91 ++ src/network_slice_controller.py | 1290 ----------------- src/planner/planner.py | 18 +- .../ixia => realizer/ixia/helpers}/NEII_V4.py | 5 +- .../ixia/helpers}/automatizacion_ne2v4.py | 0 src/realizer/ixia/ixia_connect.py | 9 + src/realizer/ixia/main.py | 77 + src/realizer/main.py | 27 + src/realizer/nrp_handler.py | 56 + src/realizer/select_way.py | 33 + src/realizer/send_controller.py | 15 + src/realizer/tfs/helpers/cisco_connector.py | 80 + src/realizer/tfs/helpers/tfs_connector.py | 38 + src/realizer/tfs/main.py | 13 + src/realizer/tfs/service_types/tfs_l2vpn.py | 163 +++ src/realizer/tfs/service_types/tfs_l3vpn.py | 118 ++ src/realizer/tfs/tfs_connect.py | 22 + src/utils/build_response.py | 45 + src/utils/dump_templates.py | 21 + src/utils/load_template.py | 26 + src/utils/send_response.py | 38 + src/webui/gui.py | 20 +- swagger/ixia_namespace.py | 15 +- swagger/tfs_namespace.py | 15 +- 40 files changed, 1588 insertions(+), 1576 deletions(-) delete mode 100644 src/Constants.py create mode 100644 src/api/main.py create mode 100644 src/config/.env.example rename src/{ => config}/IPs.json (100%) create mode 100644 src/config/config.py create mode 100644 src/config/constants.py rename src/{ => database}/nrp_ddbb.json (91%) rename src/{ => database}/slice_ddbb.json (100%) create mode 100644 src/database/store_data.py delete mode 100644 src/helpers.py create mode 100644 src/main.py create mode 100644 src/mapper/main.py create mode 100644 src/mapper/slo_viability.py create mode 100644 src/nbi_processor/detect_format.py create mode 100644 src/nbi_processor/main.py create mode 100644 src/nbi_processor/translator.py delete mode 100644 src/network_slice_controller.py rename src/{realizers/ixia => realizer/ixia/helpers}/NEII_V4.py (99%) rename src/{realizers/ixia => realizer/ixia/helpers}/automatizacion_ne2v4.py (100%) create mode 100644 src/realizer/ixia/ixia_connect.py create mode 100644 src/realizer/ixia/main.py create mode 100644 src/realizer/main.py create mode 100644 src/realizer/nrp_handler.py create mode 100644 src/realizer/select_way.py create mode 100644 src/realizer/send_controller.py create mode 100644 src/realizer/tfs/helpers/cisco_connector.py create mode 100644 src/realizer/tfs/helpers/tfs_connector.py create mode 100644 src/realizer/tfs/main.py create mode 100644 src/realizer/tfs/service_types/tfs_l2vpn.py create mode 100644 src/realizer/tfs/service_types/tfs_l3vpn.py create mode 100644 src/realizer/tfs/tfs_connect.py create mode 100644 src/utils/build_response.py create mode 100644 src/utils/dump_templates.py create mode 100644 src/utils/load_template.py create mode 100644 src/utils/send_response.py diff --git a/app.py b/app.py index 61503b3..d2ed061 100644 --- a/app.py +++ b/app.py @@ -1,49 +1,47 @@ -# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) - -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at - -# http://www.apache.org/licenses/LICENSE-2.0 - -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# This file is an original contribution from Telefonica Innovación Digital S.L. - -import os +import logging from flask import Flask from flask_restx import Api from flask_cors import CORS from swagger.tfs_namespace import tfs_ns from swagger.ixia_namespace import ixia_ns -from src.Constants import NSC_PORT, WEBUI_DEPLOY +from src.config.constants import NSC_PORT from src.webui.gui import gui_bp +from src.config.config import create_config + + +def create_app(): + """Factory para crear la app Flask con la configuración cargada""" + app = Flask(__name__) + app = create_config(app) + CORS(app) + + # Configure logging to provide clear and informative log messages + logging.basicConfig( + level=app.config["LOGGING_LEVEL"], + format="%(levelname)s - %(message)s" + ) -app = Flask(__name__) -CORS(app) + # Create API instance + api = Api( + app, + version="1.0", + title="Network Slice Controller (NSC) API", + description="API for orchestrating and realizing transport network slice requests", + doc="/nsc" # Swagger UI URL + ) -# Create API instance -api = Api( - app, - version="1.0", - title="Network Slice Controller (NSC) API", - description="API for orchestrating and realizing transport network slice requests", - doc="/nsc" # Swagger UI URL -) + # Register namespaces + api.add_namespace(tfs_ns, path="/tfs") + api.add_namespace(ixia_ns, path="/ixia") -# Register namespaces -api.add_namespace(tfs_ns, path="/tfs") -api.add_namespace(ixia_ns, path="/ixia") -#gui_bp = Blueprint('gui', __name__, template_folder='templates') + if app.config["WEBUI_DEPLOY"]: + app.secret_key = "clave-secreta-dev" + app.register_blueprint(gui_bp) -if WEBUI_DEPLOY: - app.secret_key = 'clave-secreta-dev' - app.register_blueprint(gui_bp) + return app +# Solo arrancamos el servidor si ejecutamos el script directamente if __name__ == "__main__": + app = create_app() app.run(host="0.0.0.0", port=NSC_PORT, debug=True) diff --git a/src/Constants.py b/src/Constants.py deleted file mode 100644 index 3b02ffd..0000000 --- a/src/Constants.py +++ /dev/null @@ -1,68 +0,0 @@ -# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) - -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at - -# http://www.apache.org/licenses/LICENSE-2.0 - -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# This file includes original contributions from Telefonica Innovación Digital S.L. - -import logging, os, json - -# Default logging level -DEFAULT_LOGGING_LEVEL = logging.INFO - -# Default port for NSC deployment -NSC_PORT = 8081 - -# Paths -# Obtain the absolute path of the current file -SRC_PATH = os.path.dirname(os.path.abspath(__file__)) -with open(os.path.join(SRC_PATH, 'IPs.json')) as f: - ips = json.load(f) - -# Create the path to the desired file relative to the current file -TEMPLATES_PATH = os.path.join(SRC_PATH, "templates") - -# Dump templates -DUMP_TEMPLATES = False - -# Mapper - -# Flag to determine if the NSC performs NRPs -NRP_ENABLED = False -# Planner Flags -PLANNER_ENABLED = True -# Flag to determine if external PCE is used -PCE_EXTERNAL = False - -# Realizer - -# Controller Flags -# If True, config is not sent to controllers -DUMMY_MODE = False - -#####TERAFLOW##### -# Teraflow IP -TFS_IP = ips.get('TFS_IP') -UPLOAD_TYPE = "WEBUI" # "WEBUI" or "NBI" -NBI_L2_PATH = "restconf/data/ietf-l2vpn-svc:l2vpn-svc/vpn-services" -NBI_L3_PATH = "restconf/data/ietf-l3vpn-svc:l3vpn-svc/vpn-services" -# Flag to determine if additional L2VPN configuration support is required for deploying L2VPNs with path selection -TFS_L2VPN_SUPPORT = False - -#####IXIA##### -# IXIA NEII IP -IXIA_IP = ips.get('IXIA_IP') - -# WebUI - -# Flag to deploy the WebUI -WEBUI_DEPLOY = True \ No newline at end of file diff --git a/src/api/main.py b/src/api/main.py new file mode 100644 index 0000000..56c922b --- /dev/null +++ b/src/api/main.py @@ -0,0 +1,193 @@ +from src.config.constants import DATABASE_PATH +from src.utils.send_response import send_response +import os, json, logging +from flask import current_app + +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: + ValueError: If no transport network slices are found + Exception: For unexpected errors during slice creation process + """ + try: + result = self.slice_service.nsc(intent) + if not result: + return send_response(False, code=404, message="No intents found") + + return send_response( + True, + code=201, + data=result + ) + except Exception as e: + # Handle unexpected errors + 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 during file processing + """ + try: + # Read slice database from JSON file + with open(os.path.join(DATABASE_PATH, "slice_ddbb.json"), 'r') as file: + content = json.load(file) + # 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} + """ + try: + result = self.slice_service.nsc(intent, slice_id) + if not result: + return send_response(False, code=404, message="Slice not found") + + return send_response( + True, + code=200, + message="Slice modified successfully", + data=result + ) + 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: Response indicating successful deletion or error details + + API Endpoint: + DELETE /slice/{id} + + Raises: + ValueError: If no slices are found to delete + Exception: For unexpected errors during deletion process + + Notes: + - If controller_type is TFS, attempts to delete from Teraflow + - If need_l2vpn_support is True, performs additional L2VPN cleanup + """ + try: + # Read current slice database + with open(os.path.join(DATABASE_PATH, "slice_ddbb.json"), 'r') as file: + content = json.load(file) + id = None + + # Delete specific slice if slice_id is provided + if slice_id: + for i, slice in enumerate(content): + if slice["slice_id"] == slice_id and slice.get("controller") == self.slice_service.controller_type: + del content[i] + id = i + break + # Raise error if slice not found + if id is None: + raise ValueError("Transport network slice not found") + # Update slice database + with open(os.path.join(DATABASE_PATH, "slice_ddbb.json"), 'w') as file: + json.dump(content, file, indent=4) + logging.info(f"Slice {slice_id} removed successfully") + return {}, 204 + + # Delete all slices + else: + # Optional: Delete in Teraflow if configured + if self.slice_service.controller_type == "TFS": + # TODO: should send a delete request to Teraflow + if current_app.config["TFS_L2VPN_SUPPORT"]: + self.slice_service.tfs_l2vpn_delete() + + data_removed = [slice for slice in content if slice.get("controller") == self.slice_service.controller_type] + + # Verify slices exist before deletion + if len(data_removed) == 0: + raise ValueError("Transport network slices not found") + + filtered_data = [slice for slice in content if slice.get("controller") != self.slice_service.controller_type] + # Clear slice database + with open(os.path.join(DATABASE_PATH, "slice_ddbb.json"), 'w') as file: + json.dump(filtered_data, file, indent=4) + + 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)) \ No newline at end of file diff --git a/src/config/.env.example b/src/config/.env.example new file mode 100644 index 0000000..e525ebd --- /dev/null +++ b/src/config/.env.example @@ -0,0 +1,39 @@ +# ------------------------- +# General +# ------------------------- +LOGGING_LEVEL=INFO # Options: CRITICAL, ERROR, WARNING, INFO, DEBUG, NOTSET +DUMP_TEMPLATES=false + +# ------------------------- +# Mapper +# ------------------------- +# Flag to determine if the NSC performs NRPs +NRP_ENABLED=false +# Planner Flags +PLANNER_ENABLED=true +# Flag to determine if external PCE is used +PCE_EXTERNAL=false + +# ------------------------- +# Realizer +# ------------------------- +# If true, no config sent to controllers +DUMMY_MODE=true + +# ------------------------- +# Teraflow +# ------------------------- +TFS_IP=127.0.0.1 +UPLOAD_TYPE=WEBUI # Options: WEBUI o NBI +# Flag to determine if additional L2VPN configuration support is required for deploying L2VPNs with path selection +TFS_L2VPN_SUPPORT=false + +# ------------------------- +# IXIA +# ------------------------- +IXIA_IP=127.0.0.1 + +# ------------------------- +# WebUI +# ------------------------- +WEBUI_DEPLOY=true diff --git a/src/IPs.json b/src/config/IPs.json similarity index 100% rename from src/IPs.json rename to src/config/IPs.json diff --git a/src/config/config.py b/src/config/config.py new file mode 100644 index 0000000..32a1307 --- /dev/null +++ b/src/config/config.py @@ -0,0 +1,45 @@ +import os +from dotenv import load_dotenv +from flask import Flask +import logging + +# Load .env file if present +load_dotenv() + +LOG_LEVELS = { + "CRITICAL": logging.CRITICAL, + "ERROR": logging.ERROR, + "WARNING": logging.WARNING, + "INFO": logging.INFO, + "DEBUG": logging.DEBUG, + "NOTSET": logging.NOTSET, +} + +def create_config(app: Flask): + """Load flags into Flask app.config""" + # Default logging level + app.config["LOGGING_LEVEL"] = LOG_LEVELS.get(os.getenv("LOGGING_LEVEL", "INFO").upper(),logging.INFO) + + # Dump templates + app.config["DUMP_TEMPLATES"] = os.getenv("DUMP_TEMPLATES", "false").lower() == "true" + + # 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["PCE_EXTERNAL"] = os.getenv("PCE_EXTERNAL", "false").lower() == "true" + + # Realizer + app.config["DUMMY_MODE"] = os.getenv("DUMMY_MODE", "true").lower() == "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" + + # IXIA + app.config["IXIA_IP"] = os.getenv("IXIA_IP", "127.0.0.1") + + # WebUI + app.config["WEBUI_DEPLOY"] = os.getenv("WEBUI_DEPLOY", "false").lower() == "true" + + return app diff --git a/src/config/constants.py b/src/config/constants.py new file mode 100644 index 0000000..cb04fb4 --- /dev/null +++ b/src/config/constants.py @@ -0,0 +1,31 @@ +# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This file includes original contributions from Telefonica Innovación Digital S.L. +from pathlib import Path + +# Default port for NSC deployment +NSC_PORT = 8081 + +# 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" + + + diff --git a/src/nrp_ddbb.json b/src/database/nrp_ddbb.json similarity index 91% rename from src/nrp_ddbb.json rename to src/database/nrp_ddbb.json index 948967e..1616438 100644 --- a/src/nrp_ddbb.json +++ b/src/database/nrp_ddbb.json @@ -6,12 +6,12 @@ { "metric-type": "one-way-bandwidth", "metric-unit": "kbps", - "bound": 1 + "bound": 100000000000 }, { "metric-type": "one-way-delay-maximum", "metric-unit": "milliseconds", - "bound": 800 + "bound": 1 } ], "slices": ["slice-service-02873501-bf0a-4b02-8540-2f9d970ea20f", "slice-service-e3b22fa8-f3da-4da8-881b-c66e5161b4a5"], @@ -24,12 +24,12 @@ { "metric-type": "one-way-bandwidth", "metric-unit": "kbps", - "bound": 1 + "bound": 10000000000000 }, { "metric-type": "one-way-delay-maximum", "metric-unit": "milliseconds", - "bound": 800 + "bound": 2 } ], "slices": ["slice-service-02873501-bf0a-4b02-8540-2f9d970ea20f", "slice-service-e3b22fa8-f3da-4da8-881b-c66e5161b4a5"], diff --git a/src/slice_ddbb.json b/src/database/slice_ddbb.json similarity index 100% rename from src/slice_ddbb.json rename to src/database/slice_ddbb.json diff --git a/src/database/store_data.py b/src/database/store_data.py new file mode 100644 index 0000000..5040314 --- /dev/null +++ b/src/database/store_data.py @@ -0,0 +1,44 @@ +import json, os +from src.config.constants import DATABASE_PATH + +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. + """ + file_path = os.path.join(DATABASE_PATH, "slice_ddbb.json") + # Create initial JSON file if it doesn't exist + if not os.path.exists(file_path): + with open(file_path, 'w') as file: + json.dump([], file, indent=4) + + # Read existing content + with open(file_path, 'r') as file: + content = json.load(file) + + # Update or add new slice intent + if slice_id: + # Update existing slice intent + for slice in content: + if slice["slice_id"] == slice_id: + slice["intent"] = intent + else: + # Add new slice intent + content.append( + { + "slice_id": intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"], + "intent": intent, + "controller": controller_type, + }) + + # # Write updated content back to file + with open(file_path, 'w') as file: + json.dump(content, file, indent=4) \ No newline at end of file diff --git a/src/helpers.py b/src/helpers.py deleted file mode 100644 index 0e15079..0000000 --- a/src/helpers.py +++ /dev/null @@ -1,142 +0,0 @@ -# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) - -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at - -# http://www.apache.org/licenses/LICENSE-2.0 - -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# This file includes original contributions from Telefonica Innovación Digital S.L. - -import logging, requests, json -from netmiko import ConnectHandler -from src.Constants import DEFAULT_LOGGING_LEVEL - -# Configure logging to provide clear and informative log messages -logging.basicConfig( - level=DEFAULT_LOGGING_LEVEL, - format='%(levelname)s - %(message)s') - -#Teraflow -class tfs_connector(): - - def webui_post(self, tfs_ip, service): - user="admin" - password="admin" - token="" - session = requests.Session() - session.auth = (user, password) - url=f'http://{tfs_ip}/webui' - response=session.get(url=url) - for item in response.iter_lines(): - if("csrf_token" in str(item)): - string=str(item).split(' 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 # Si pasó todas las verificaciones, la NRP es viable \ No newline at end of file diff --git a/src/nbi_processor/detect_format.py b/src/nbi_processor/detect_format.py new file mode 100644 index 0000000..290e197 --- /dev/null +++ b/src/nbi_processor/detect_format.py @@ -0,0 +1,24 @@ +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: + 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 diff --git a/src/nbi_processor/main.py b/src/nbi_processor/main.py new file mode 100644 index 0000000..ca1acd3 --- /dev/null +++ b/src/nbi_processor/main.py @@ -0,0 +1,41 @@ +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. + Supports multiple slice subnets in 3GPP 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 diff --git a/src/nbi_processor/translator.py b/src/nbi_processor/translator.py new file mode 100644 index 0000000..4f1953a --- /dev/null +++ b/src/nbi_processor/translator.py @@ -0,0 +1,91 @@ +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. + + 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 + + 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 + """ + # 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-policy"]["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"] = gpp_intent[ep_transport_objects[0]]["logicalInterfaceInfo"]["logicalInterfaceType"] + ietf_i["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][0]["service-match-criteria"]["match-criterion"][0]["value"] = 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"] = gpp_intent[ep_transport_objects[1]]["logicalInterfaceInfo"]["logicalInterfaceType"] + ietf_i["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][1]["service-match-criteria"]["match-criterion"][0]["value"] = 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 diff --git a/src/network_slice_controller.py b/src/network_slice_controller.py deleted file mode 100644 index 33d6a9a..0000000 --- a/src/network_slice_controller.py +++ /dev/null @@ -1,1290 +0,0 @@ -# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) - -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at - -# http://www.apache.org/licenses/LICENSE-2.0 - -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# This file includes original contributions from Telefonica Innovación Digital S.L. - -import json, time, os, logging, uuid, traceback, sys -from datetime import datetime -from src.helpers import tfs_connector, cisco_connector -from src.Constants import DEFAULT_LOGGING_LEVEL, TFS_IP, TFS_L2VPN_SUPPORT, IXIA_IP, SRC_PATH, TEMPLATES_PATH, DUMMY_MODE, DUMP_TEMPLATES, PLANNER_ENABLED, NRP_ENABLED, UPLOAD_TYPE, NBI_L2_PATH, NBI_L3_PATH -from src.realizers.ixia.NEII_V4 import NEII_controller -from src.planner.planner import Planner - -# Configure logging to provide clear and informative log messages -logging.basicConfig( - level=DEFAULT_LOGGING_LEVEL, - format='%(levelname)s - %(message)s') - -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) - """ - - def __init__(self, controller_type = "TFS"): - """ - 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 - answer (dict): Stores slice creation responses - start_time (float): Tracks slice setup start time - end_time (float): Tracks slice setup end time - need_l2vpn_support (bool): Flag for additional L2VPN configuration support - """ - self.controller_type = controller_type - - self.path = "" - self.answer = {} - self.cool_answer = {} - self.start_time = 0 - self.end_time = 0 - self.setup_time = 0 - self.__gpp_template = "" - self.__ietf_template = "" - self.__teraflow_template = "" - self.__nrp_view = "" - self.subnet = "" - - # API Methods - 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: - ValueError: If no transport network slices are found - Exception: For unexpected errors during slice creation process - """ - try: - result = self.nsc(intent) - if not result: - return self.__send_response(False, code=404, message="No intents found") - - return self.__send_response( - True, - code=201, - data=result - ) - except Exception as e: - # Handle unexpected errors - return self.__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 during file processing - """ - try: - # Read slice database from JSON file - with open(os.path.join(SRC_PATH, "slice_ddbb.json"), 'r') as file: - content = json.load(file) - # 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.controller_type], 200 - - except ValueError as e: - # Handle case where no slices are found - return self.__send_response(False, code=404, message=str(e)) - except Exception as e: - # Handle unexpected errors - return self.__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} - """ - try: - result = self.nsc(intent, slice_id) - if not result: - return self.__send_response(False, code=404, message="Slice not found") - - return self.__send_response( - True, - code=200, - message="Slice modified successfully", - data=result - ) - except Exception as e: - # Handle unexpected errors - return self.__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: Response indicating successful deletion or error details - - API Endpoint: - DELETE /slice/{id} - - Raises: - ValueError: If no slices are found to delete - Exception: For unexpected errors during deletion process - - Notes: - - If controller_type is TFS, attempts to delete from Teraflow - - If need_l2vpn_support is True, performs additional L2VPN cleanup - """ - try: - # Read current slice database - with open(os.path.join(SRC_PATH, "slice_ddbb.json"), 'r') as file: - content = json.load(file) - id = None - - # Delete specific slice if slice_id is provided - if slice_id: - for i, slice in enumerate(content): - if slice["slice_id"] == slice_id and slice.get("controller") == self.controller_type: - del content[i] - id = i - break - # Raise error if slice not found - if id is None: - raise ValueError("Transport network slice not found") - # Update slice database - with open(os.path.join(SRC_PATH, "slice_ddbb.json"), 'w') as file: - json.dump(content, file, indent=4) - logging.info(f"Slice {slice_id} removed successfully") - return {}, 204 - - # Delete all slices - else: - # Optional: Delete in Teraflow if configured - if self.controller_type == "TFS": - # TODO: should send a delete request to Teraflow - if TFS_L2VPN_SUPPORT: - self.__tfs_l2vpn_delete() - - data_removed = [slice for slice in content if slice.get("controller") == self.controller_type] - - # Verify slices exist before deletion - if len(data_removed) == 0: - raise ValueError("Transport network slices not found") - - filtered_data = [slice for slice in content if slice.get("controller") != self.controller_type] - # Clear slice database - with open(os.path.join(SRC_PATH, "slice_ddbb.json"), 'w') as file: - json.dump(filtered_data, file, indent=4) - - logging.info("All slices removed successfully") - return {}, 204 - - except ValueError as e: - return self.__send_response(False, code=404, message=str(e)) - except Exception as e: - return self.__send_response(False, code=500, message=str(e)) - - # Main NSC Functionalities - def nsc(self, intent_json, slice_id=None): - """ - 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. Upload to Teraflow (optional) - - Args: - intent_json (dict): Network slice intent in 3GPP or IETF format - slice_id (str, optional): Existing slice identifier for modification - - Returns: - tuple: Response status and HTTP status code - - """ - # Start performance tracking - self.start_time = time.perf_counter() - - # Reset requests and load IETF template - self.__load_template(1, os.path.join(TEMPLATES_PATH, "ietf_template_empty.json")) - requests = {"services":[]} - - # Store the received template for debugging - self.__dump_templates("nbi_template", intent_json) - - # Process intent (translate if 3GPP) - ietf_intents = self.__nbi_processor(intent_json) - if not ietf_intents: - return None # Nothing to process - - # Store the generated template for debugging - self.__dump_templates("ietf_template", ietf_intents) - - for intent in ietf_intents: - # Extract and store slice request details - self.__extract_data(intent) - self.__store_data(intent, slice_id) - # Mapper - self.__mapper(intent) - # Realizer - tfs_request = self.__realizer(intent) - requests["services"].append(tfs_request) - - # Store the generated template for debugging - self.__dump_templates("realizer_template", requests) - - # Optional: Upload template to Teraflow - response = self.__send_controller(self.controller_type, requests) - - if not response: - raise Exception("Controller upload failed") - - # End performance tracking - self.end_time = time.perf_counter() - setup_time = (self.end_time - self.start_time) * 1000 - - slices = self.__build_response(self.answer) - - return { - "slices": slices, - "setup_time": setup_time - } - - def __nbi_processor(self, 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. - Supports multiple slice subnets in 3GPP 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 = self.__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(self.__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 - - def __mapper(self, ietf_intent): - """ - Map an IETF network slice intent to the most suitable Network Resource Partition (NRP). - - This method: - 1. 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 - - Args: - ietf_intent (dict): IETF-formatted network slice intent. - - Raises: - Exception: If no suitable NRP is found and slice creation fails. - """ - if NRP_ENABLED: - # Retrieve NRP view - self.__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, self.__slo_viability(slos, nrp)[1]) - for nrp in self.__nrp_view - if self.__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 - self.__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 = self.__realizer(ietf_intent, True, "CREATE", best_nrp) - if not answer: - raise Exception("Slice rejected due to lack of NRPs") - # TODO Here we should put how the slice is attached to the new nrp - - if PLANNER_ENABLED: - optimal_path = Planner().planner(ietf_intent) - - logging.info(f"Optimal path: {optimal_path}") - - def __realizer(self, ietf_intent, need_nrp=False, order=None, nrp=None): - """ - Manage the slice creation workflow. - - 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 - - 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. - """ - if need_nrp: - # Perform NRP-related operations - self.__nrp(order, nrp) - else: - # Select slice service method - way = ietf_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["service-tags"]["tag-type"]["value"] - way = "L3VPN" - return self.__select_way(controller=self.controller_type, way=way, ietf_intent=ietf_intent) - - ### Generic functionalities - def __load_template(self, which, dir_t): - """ - Load and process JSON templates for different network slice formats. - - Args: - which (int): Template selector (0: 3GPP, 1: IETF, other: Teraflow) - dir_t (str): Directory path to the template file - """ - try: - # Open and read the template file - with open(dir_t, 'r') as source: - # Clean up the JSON template - template = source.read().replace('\t', '').replace('\n', '').replace("'", '"').strip() - - # Store template based on selector - if which == 0: - self.__gpp_template = template - elif which == 1: - self.__ietf_template = template - else: - self.__teraflow_template = template - - except Exception as e: - logging.error(f"Template loading error: {e}") - return self.__send_response(False, code=500, message=f"Template loading error: {e}") - - def __dump_templates(self, name, file): - if DUMP_TEMPLATES: - with open(os.path.join(TEMPLATES_PATH, f"{name}.json"), "w") as archivo: - archivo.write(json.dumps(file,indent=2)) - - def __build_response(self, answer): - slices = [] - if hasattr(self, "answer") and isinstance(self.answer, dict): - for subnet, data in self.answer.items(): - slices.append({ - "id": subnet, - "source": data.get("Source"), - "destination": data.get("Destination"), - "vlan": data.get("VLAN"), - "requirements": data.get("QoS Requirements"), - }) - return slices - - def __send_response(self, result, message=None, code=None, data=None): - """ - Generate and send a standardized API response for the 3GPP client. - - Args: - result (bool): Indicates whether the slice request was successful. - message (str, optional): Additional message (success or error). - code (int, optional): HTTP response code. If not provided, defaults - to 200 for success and 400 for error. - - Returns: - tuple: (response_dict, http_status_code) - """ - if result: - # Ensure code is 200 if not provided - code = code or 200 - - response = { - "success": True, - "data": data or {}, - "error": None, - } - - else: - # Ensure code is 400 if not provided - code = code or 400 - - logging.warning(f"Request failed. Reason: {message}") - - response = { - "success": False, - "data": None, - "error": message or "An error occurred while processing the request." - } - - return response, code - - def __extract_data(self, intent_json): - """ - Extract source and destination IP addresses from the IETF intent. - - Args: - intent_json (dict): IETF-formatted network slice intent - """ - # Extract source and destination IP addresses - source = intent_json["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][0]["sdp-ip-address"] - destination = intent_json["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][1]["sdp-ip-address"] - - logging.info(f"Intent generated between {source} and {destination}") - - # Store slice and connection details - self.subnet = intent_json["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"] - self.subnet = intent_json["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"] - self.answer[self.subnet] = { - "Source": source, - "Destination": destination - } - - def __store_data(self, intent, slice_id): - """ - 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. - """ - file_path = os.path.join(SRC_PATH, "slice_ddbb.json") - # Create initial JSON file if it doesn't exist - if not os.path.exists(file_path): - with open(file_path, 'w') as file: - json.dump([], file, indent=4) - - # Read existing content - with open(file_path, 'r') as file: - content = json.load(file) - - # Update or add new slice intent - if slice_id: - # Update existing slice intent - for slice in content: - if slice["slice_id"] == slice_id: - slice["intent"] = intent - else: - # Add new slice intent - content.append( - { - "slice_id": intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"], - "intent": intent, - "controller": self.controller_type, - }) - - # # Write updated content back to file - with open(file_path, 'w') as file: - json.dump(content, file, indent=4) - - def __send_controller(self, controller_type, requests): - if not DUMMY_MODE: - if controller_type == "TFS": - if UPLOAD_TYPE == "WEBUI": - response = tfs_connector().webui_post(TFS_IP, requests) - elif UPLOAD_TYPE == "NBI": - for intent in requests["services"]: - # Send each separate NBI request - response = tfs_connector().nbi_post(TFS_IP, intent, self.path) - - if not response.ok: - return self.__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 TFS_L2VPN_SUPPORT: - self.__tfs_l2vpn_support(requests["services"]) - - logging.info("Request sent to Teraflow") - elif controller_type == "IXIA": - neii_controller = NEII_controller() - for intent in requests["services"]: - # Send each separate IXIA request - response = neii_controller.nscNEII(intent) - logging.info("Requests sent to Ixia") - return response - else: return True - ### NBI processor functionalities - def __detect_format(self,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: - return "IETF" - # Check for 3GPP-specific keys - if any(key in json_data for key in ["NetworkSlice1", "TopSliceSubnet1", "CNSliceSubnet1", "RANSliceSubnet1"]): - return "3GPP" - - return None - - def __translator(self, 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. - - 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 - - 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 - """ - # Load IETF template and create a copy to modify - ietf_i = json.loads(str(self.__ietf_template)) - - # 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-policy"]["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"] = gpp_intent[ep_transport_objects[0]]["logicalInterfaceInfo"]["logicalInterfaceType"] - ietf_i["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][0]["service-match-criteria"]["match-criterion"][0]["value"] = 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"] = gpp_intent[ep_transport_objects[1]]["logicalInterfaceInfo"]["logicalInterfaceType"] - ietf_i["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][1]["service-match-criteria"]["match-criterion"][0]["value"] = 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 - - ### Mapper functionalities - def __slo_viability(self, 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"] - } - 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"]: - 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"]: - 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 # Si pasó todas las verificaciones, la NRP es viable - - ### Realizer functionalities. - def __nrp(self, 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). - - 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. - - 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 - - Notes: - - Uses a local JSON file "nrp_ddbb.json" to store NRP information as controller operation is not yet defined - """ - 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(SRC_PATH, "nrp_ddbb.json"), "r") as archivo: - self.__nrp_view = json.load(archivo) - - # Append new NRP to the view - self.__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(SRC_PATH, "nrp_ddbb.json"), "r") as archivo: - self.__nrp_view = json.load(archivo) - - elif request == "UPDATE": - # TODO: Implement request to Controller to update NRP - logging.debug("Updating NRP") - answer = "" - - def __select_way(self, controller=None, way=None, ietf_intent=None): - """ - Determine the method of slice realization. - - Args: - controller (str): The controller to use for slice realization. - Supported values: - - "IXIA": IXIA NEII for network testing - - "TFS": TeraFlow Service for network slice management - way (str): The type of technology to use. - Supported values: - - "L2VPN": Layer 2 Virtual Private Network - - "L3VPN": Layer 3 Virtual Private Network - - ietf_intent (dict): IETF-formatted network slice intent. - - Returns: - dict: A realization request for the specified network slice type. - - """ - realizing_request = None - if controller == "TFS": - if way == "L2VPN": - realizing_request = self.__tfs_l2vpn(ietf_intent) - elif way == "L3VPN": - realizing_request = self.__tfs_l3vpn(ietf_intent) - else: - logging.warning(f"Unsupported way: {way}. Defaulting to L2VPN realization.") - realizing_request = self.__tfs_l2vpn(ietf_intent) - elif controller == "IXIA": - realizing_request = self.__ixia(ietf_intent) - else: - logging.warning(f"Unsupported controller: {controller}. Defaulting to TFS L2VPN realization.") - realizing_request = self.__tfs_l2vpn(ietf_intent) - return realizing_request - - def __tfs_l2vpn(self, ietf_intent): - """ - 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 - - Args: - ietf_intent (dict): IETF-formatted network slice intent. - - Returns: - dict: A TeraFlow service request for L2VPN configuration. - - """ - # 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' - - # Extract QoS Profile from intent - QoSProfile = ietf_intent["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["id"] - vlan_value = 0 - - self.answer[self.subnet]["QoS Requirements"] = [] - - # Populate response with QoS requirements and VLAN from intent - slo_policy = ietf_intent["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["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"]) - self.answer[self.subnet]["QoS Requirements"].append({ - "constraint_type": constraint_type, - "constraint_value": constraint_value - }) - - # Availability - if "availability" in slo_policy: - self.answer[self.subnet]["QoS Requirements"].append({ - "constraint_type": "availability[%]", - "constraint_value": str(slo_policy["availability"]) - }) - - # MTU - if "mtu" in slo_policy: - self.answer[self.subnet]["QoS Requirements"].append({ - "constraint_type": "mtu[bytes]", - "constraint_value": str(slo_policy["mtu"]) - }) - - # VLAN - vlan_value = ietf_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][0]["service-match-criteria"]["match-criterion"][0]["value"] - self.answer[self.subnet]["VLAN"] = vlan_value - - if UPLOAD_TYPE == "WEBUI": - # Load L2VPN service template - self.__load_template(2, os.path.join(TEMPLATES_PATH, "L2-VPN_template_empty.json")) - tfs_request = json.loads(str(self.__teraflow_template))["services"][0] - - # Generate unique service UUID - tfs_request["service_id"]["service_uuid"]["uuid"] += "-" + str(int(datetime.now().timestamp() * 1e7)) - - # 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 self.answer[self.subnet]["QoS 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]["value"] - 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"] = 'ELAN{:s}'.format(str(vlan_value)) - config_rule["custom"]["resource_key"] = f"/device[{router_id}]/endpoint[{router_if}]/settings" - - elif UPLOAD_TYPE == "NBI": - self.path = NBI_L2_PATH - # Load IETF L2VPN service template - self.__load_template(2, os.path.join(TEMPLATES_PATH, "ietfL2VPN_template_empty.json")) - tfs_request = json.loads(str(self.__teraflow_template)) - - # 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(f"L2VPN Intent realized\n") - return tfs_request - - def __tfs_l2vpn_support(self, requests): - """ - Configuration support for L2VPN with path selection based on MPLS traffic-engineering tunnels - - Args: - requests (list): A list of configuration parameters. - - """ - 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 - 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) - cisco_source = cisco_connector(sources["source"], sources["config"]) - commands = cisco_source.full_create_command_template() - cisco_source.execute_commands(commands) - - #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) - - def __tfs_l2vpn_delete(self): - """ - 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 - 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()) - - def __tfs_l3vpn(self, ietf_intent): - """ - 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 - - Args: - ietf_intent (dict): IETF-formatted network slice intent. - - Returns: - dict: A TeraFlow service request for L3VPN configuration. - """ - # 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' - - # Extract QoS Profile from intent - QoSProfile = ietf_intent["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["id"] - vlan_value = 0 - - self.answer[self.subnet]["QoS Requirements"] = [] - - # Populate response with QoS requirements and VLAN from intent - slo_policy = ietf_intent["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["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"]) - self.answer[self.subnet]["QoS Requirements"].append({ - "constraint_type": constraint_type, - "constraint_value": constraint_value - }) - - # Availability - if "availability" in slo_policy: - self.answer[self.subnet]["QoS Requirements"].append({ - "constraint_type": "availability[%]", - "constraint_value": str(slo_policy["availability"]) - }) - - # MTU - if "mtu" in slo_policy: - self.answer[self.subnet]["QoS Requirements"].append({ - "constraint_type": "mtu[bytes]", - "constraint_value": str(slo_policy["mtu"]) - }) - - # VLAN - vlan_value = ietf_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][0]["service-match-criteria"]["match-criterion"][0]["value"] - self.answer[self.subnet]["VLAN"] = vlan_value - - if UPLOAD_TYPE == "WEBUI": - # Load L3VPN service template - self.__load_template(2, os.path.join(TEMPLATES_PATH, "L3-VPN_template_empty.json")) - tfs_request = json.loads(str(self.__teraflow_template))["services"][0] - - # Generate unique service UUID - tfs_request["service_id"]["service_uuid"]["uuid"] += "-" + str(int(datetime.now().timestamp() * 1e7)) - - # 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 self.answer[self.subnet]["QoS 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]["value"] - 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"] = 'ELAN{:s}'.format(str(vlan_value)) - config_rule["custom"]["resource_key"] = f"/device[{router_id}]/endpoint[{router_if}]/settings" - - elif UPLOAD_TYPE == "NBI": - self.path = NBI_L3_PATH - # Load IETF L3VPN service template - self.__load_template(2, os.path.join(TEMPLATES_PATH, "ietfL3VPN_template_empty.json")) - tfs_request = json.loads(str(self.__teraflow_template)) - - # 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 self.answer[self.subnet]["QoS 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(f"L3VPN Intent realized\n") - self.answer[self.subnet]["VLAN"] = vlan_value - return tfs_request - - def __ixia(self, ietf_intent): - """ - Prepare an Ixia service request based on the IETF intent. - - 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 - - Args: - ietf_intent (dict): IETF-formatted network slice intent. - - Returns: - dict: An Ixia service request for configuration. - """ - self.answer[self.subnet]["QoS Requirements"] = [] - # Add service constraints - for i, constraint in enumerate(ietf_intent["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["slo-policy"]["metric-bound"]): - bound = ietf_intent["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["slo-policy"]["metric-bound"][i]["bound"] - metric_type = ietf_intent["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["slo-policy"]["metric-bound"][i]["metric-type"] - metric_unit = ietf_intent["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["slo-policy"]["metric-bound"][i]["metric-unit"] - service_constraint ={ - "custom": { - "constraint_type": f"{metric_type}[{metric_unit}]", - "constraint_value": f"{bound}" - } - } - self.answer[self.subnet]["QoS Requirements"].append(service_constraint["custom"]) - self.answer[self.subnet]["VLAN"] = ietf_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][0]["service-match-criteria"]["match-criterion"][0]["value"] - # Extraer la lista de métricas de forma segura - 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 - - # Asignar valores según el tipo de métrica - for metric in metric_bounds: - metric_type = metric.get("metric-type") - bound = metric.get("bound") - - 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 - - # Construcción del diccionario intent - 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("value"), - - "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"), - } - - logging.info(f"IXIA Intent realized\n") - return intent - diff --git a/src/planner/planner.py b/src/planner/planner.py index b5fb1ba..c2613bd 100644 --- a/src/planner/planner.py +++ b/src/planner/planner.py @@ -15,12 +15,8 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. import logging, random, os, json, heapq -from src.Constants import SRC_PATH, PCE_EXTERNAL, DEFAULT_LOGGING_LEVEL - -# Configure logging to provide clear and informative log messages -logging.basicConfig( - level=DEFAULT_LOGGING_LEVEL, - format='%(levelname)s - %(message)s') +from src.config.constants import SRC_PATH +from flask import current_app class Planner: """ @@ -37,8 +33,8 @@ class Planner: destination = intent.get("ietf-network-slice-service:network-slice-services", {}).get("slice-service", [])[0].get("sdps", {}).get("sdp", [])[1].get("id") or "B" optimal_path = [] # If using an external PCE - if PCE_EXTERNAL: - logging.info("Using external PCE for path planning") + if current_app.config["PCE_EXTERNAL"]: + logging.debug("Using external PCE for path planning") def build_slice_input(node_source, node_destination): return { "clientName": "demo-client", @@ -121,9 +117,9 @@ class Planner: optimal_path.append(next((node for node in topology["nodes"] if node["nodeId"] == hop['nodeId']), None)["name"]) else: - logging.info("Using internal PCE for path planning") + 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.info(ietf_dlos), + logging.debug(ietf_dlos), # Solo asigna los DLOS que existan, el resto a None dlos = { "EC": next((item.get("bound") for item in ietf_dlos if item.get("metric-type") == "energy_consumption"), None), @@ -148,7 +144,7 @@ class Planner: return energy_metrics def __retrieve_topology(self): - if PCE_EXTERNAL: + 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/ext_topo_ddbb.json"), "r") as archivo: diff --git a/src/realizers/ixia/NEII_V4.py b/src/realizer/ixia/helpers/NEII_V4.py similarity index 99% rename from src/realizers/ixia/NEII_V4.py rename to src/realizer/ixia/helpers/NEII_V4.py index f9379d2..e9bf61a 100644 --- a/src/realizers/ixia/NEII_V4.py +++ b/src/realizer/ixia/helpers/NEII_V4.py @@ -16,13 +16,12 @@ from .automatizacion_ne2v4 import automatizacion import ipaddress, logging -from src.Constants import IXIA_IP class NEII_controller: - def __init__(self, ixia_ip=IXIA_IP): + def __init__(self, ixia_ip): self.ixia_ip = ixia_ip - def menu_principal(self, ip=IXIA_IP): + def menu_principal(self, ip): ''' Inputs: Outputs: diff --git a/src/realizers/ixia/automatizacion_ne2v4.py b/src/realizer/ixia/helpers/automatizacion_ne2v4.py similarity index 100% rename from src/realizers/ixia/automatizacion_ne2v4.py rename to src/realizer/ixia/helpers/automatizacion_ne2v4.py diff --git a/src/realizer/ixia/ixia_connect.py b/src/realizer/ixia/ixia_connect.py new file mode 100644 index 0000000..c001fc3 --- /dev/null +++ b/src/realizer/ixia/ixia_connect.py @@ -0,0 +1,9 @@ +from .helpers.NEII_V4 import NEII_controller + +def ixia_connect(requests, ixia_ip): # The IP should be sent by parameter + response = None + neii_controller = NEII_controller(ixia_ip) + for intent in requests["services"]: + # Send each separate IXIA request + response = neii_controller.nscNEII(intent) + return response \ No newline at end of file diff --git a/src/realizer/ixia/main.py b/src/realizer/ixia/main.py new file mode 100644 index 0000000..8935396 --- /dev/null +++ b/src/realizer/ixia/main.py @@ -0,0 +1,77 @@ +import logging + +def ixia(ietf_intent): + """ + Prepare an Ixia service request based on the IETF intent. + + 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 + + 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 + + # Asignar valores según el tipo de métrica + for metric in metric_bounds: + metric_type = metric.get("metric-type") + bound = metric.get("bound") + + 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 + + # Construcción del diccionario intent + 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("value"), + + "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"), + } + + logging.info(f"IXIA Intent realized\n") + return intent \ No newline at end of file diff --git a/src/realizer/main.py b/src/realizer/main.py new file mode 100644 index 0000000..b2e5be3 --- /dev/null +++ b/src/realizer/main.py @@ -0,0 +1,27 @@ +from .select_way import select_way +from .nrp_handler import nrp_handler + +def realizer(ietf_intent, need_nrp=False, order=None, nrp=None, controller_type=None, response=None): + """ + Manage the slice creation workflow. + + 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 + + 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. + """ + if need_nrp: + # Perform NRP-related operations + nrp_view = nrp_handler(order, nrp) + return nrp_view + else: + # Select slice service method + way = ietf_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["service-tags"]["tag-type"]["value"] + way = "L2VPN" + request = select_way(controller=controller_type, way=way, ietf_intent=ietf_intent, response=response) + return request diff --git a/src/realizer/nrp_handler.py b/src/realizer/nrp_handler.py new file mode 100644 index 0000000..ba4c1ff --- /dev/null +++ b/src/realizer/nrp_handler.py @@ -0,0 +1,56 @@ +import logging, os, json +from src.config.constants import DATABASE_PATH + +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). + + 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. + + 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 + + Notes: + - Uses a local JSON file "nrp_ddbb.json" to store NRP information as controller operation is not yet defined + """ + 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 diff --git a/src/realizer/select_way.py b/src/realizer/select_way.py new file mode 100644 index 0000000..548dff1 --- /dev/null +++ b/src/realizer/select_way.py @@ -0,0 +1,33 @@ +import logging +from .ixia.main import ixia +from .tfs.main import tfs + +def select_way(controller=None, way=None, ietf_intent=None, response=None): + """ + Determine the method of slice realization. + + Args: + controller (str): The controller to use for slice realization. + Supported values: + - "IXIA": IXIA NEII for network testing + - "TFS": TeraFlow Service for network slice management + way (str): The type of technology to use. + Supported values: + - "L2VPN": Layer 2 Virtual Private Network + - "L3VPN": Layer 3 Virtual Private Network + + ietf_intent (dict): IETF-formatted network slice intent. + + 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) + 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 diff --git a/src/realizer/send_controller.py b/src/realizer/send_controller.py new file mode 100644 index 0000000..53667c8 --- /dev/null +++ b/src/realizer/send_controller.py @@ -0,0 +1,15 @@ +import logging +from flask import current_app +from .tfs.tfs_connect import tfs_connect +from .ixia.ixia_connect import ixia_connect + +def send_controller(controller_type, requests): + if current_app.config["DUMMY_MODE"]: + 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") + return response diff --git a/src/realizer/tfs/helpers/cisco_connector.py b/src/realizer/tfs/helpers/cisco_connector.py new file mode 100644 index 0000000..230e7cb --- /dev/null +++ b/src/realizer/tfs/helpers/cisco_connector.py @@ -0,0 +1,80 @@ +import logging +from netmiko import ConnectHandler + +class cisco_connector(): + def __init__(self, address, configs=None): + self.address=address + self.configs=configs + + def execute_commands(self, commands): + try: + # Configuración del dispositivo + device = { + 'device_type': 'cisco_xr', # Esto depende del tipo de dispositivo (ej: 'cisco_ios', 'cisco_xr', 'linux', etc.) + 'host': self.address, + 'username': 'cisco', + 'password': 'cisco12345', + } + + # Conexión por SSH + connection = ConnectHandler(**device) + + # Enviar comandos + output = connection.send_config_set(commands) + logging.debug(output) + + # Cerrar la conexión + connection.disconnect() + + except Exception as e: + logging.error(f"Failed to execute commands on {self.address}: {str(e)}") + + def create_command_template(self, config): + + commands = [ + "l2vpn", + f"pw-class l2vpn_vpws_profile_example_{config['number']}", + "encapsulation mpls" + ] + + commands.extend([ + "transport-mode vlan passthrough", + "control-word" + ]) + + commands.extend([ + f"preferred-path interface tunnel-te {config['number']}", + "exit", + "exit" + ]) + + commands.extend([ + "xconnect group l2vpn_vpws_group_example", + f"p2p {config['ni_name']}", + f"interface {config['interface']}.{config['vlan']}", + f"neighbor ipv4 {config['remote_router']} pw-id {config['vlan']}", + "no pw-class l2vpn_vpws_profile_example", + f"pw-class l2vpn_vpws_profile_example_{config['number']}" + ]) + + + return commands + + def full_create_command_template(self): + commands =[] + for config in self.configs: + commands_temp = self.create_command_template(config) + commands.extend(commands_temp) + commands.append("commit") + commands.append("end") + return commands + + def create_command_template_delete(self): + commands = [ + "no l2vpn", + ] + + commands.append("commit") + commands.append("end") + + return commands \ No newline at end of file diff --git a/src/realizer/tfs/helpers/tfs_connector.py b/src/realizer/tfs/helpers/tfs_connector.py new file mode 100644 index 0000000..fe52c3a --- /dev/null +++ b/src/realizer/tfs/helpers/tfs_connector.py @@ -0,0 +1,38 @@ +import logging, requests, json + +class tfs_connector(): + def webui_post(self, tfs_ip, service): + user="admin" + password="admin" + token="" + session = requests.Session() + session.auth = (user, password) + url=f'http://{tfs_ip}/webui' + response=session.get(url=url) + for item in response.iter_lines(): + if("csrf_token" in str(item)): + string=str(item).split(' Date: Thu, 25 Sep 2025 16:29:53 +0200 Subject: [PATCH 03/26] Update copyright headers --- app.py | 16 ++++++++++++++++ src/api/main.py | 16 ++++++++++++++++ src/config/.env.example | 16 ++++++++++++++++ src/config/config.py | 16 ++++++++++++++++ src/config/constants.py | 1 + src/database/store_data.py | 16 ++++++++++++++++ src/mapper/main.py | 16 ++++++++++++++++ src/mapper/slo_viability.py | 16 ++++++++++++++++ src/nbi_processor/detect_format.py | 16 ++++++++++++++++ src/nbi_processor/main.py | 16 ++++++++++++++++ src/nbi_processor/translator.py | 16 ++++++++++++++++ src/realizer/ixia/ixia_connect.py | 16 ++++++++++++++++ src/realizer/ixia/main.py | 16 ++++++++++++++++ src/realizer/main.py | 16 ++++++++++++++++ src/realizer/nrp_handler.py | 16 ++++++++++++++++ src/realizer/select_way.py | 16 ++++++++++++++++ src/realizer/send_controller.py | 16 ++++++++++++++++ src/realizer/tfs/helpers/cisco_connector.py | 16 ++++++++++++++++ src/realizer/tfs/helpers/tfs_connector.py | 16 ++++++++++++++++ src/realizer/tfs/main.py | 16 ++++++++++++++++ src/realizer/tfs/service_types/tfs_l2vpn.py | 16 ++++++++++++++++ src/realizer/tfs/service_types/tfs_l3vpn.py | 16 ++++++++++++++++ src/realizer/tfs/tfs_connect.py | 16 ++++++++++++++++ src/utils/build_response.py | 16 ++++++++++++++++ src/utils/dump_templates.py | 16 ++++++++++++++++ src/utils/load_template.py | 16 ++++++++++++++++ src/utils/send_response.py | 16 ++++++++++++++++ swagger/ixia_namespace.py | 16 ++++++++++++++++ 28 files changed, 433 insertions(+) diff --git a/app.py b/app.py index d2ed061..784c904 100644 --- a/app.py +++ b/app.py @@ -1,3 +1,19 @@ +# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This file is an original contribution from Telefonica Innovación Digital S.L. + import logging from flask import Flask from flask_restx import Api diff --git a/src/api/main.py b/src/api/main.py index 56c922b..2896284 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -1,3 +1,19 @@ +# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This file is an original contribution from Telefonica Innovación Digital S.L. + from src.config.constants import DATABASE_PATH from src.utils.send_response import send_response import os, json, logging diff --git a/src/config/.env.example b/src/config/.env.example index e525ebd..4037615 100644 --- a/src/config/.env.example +++ b/src/config/.env.example @@ -1,3 +1,19 @@ +# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This file is an original contribution from Telefonica Innovación Digital S.L. + # ------------------------- # General # ------------------------- diff --git a/src/config/config.py b/src/config/config.py index 32a1307..6d0f8d3 100644 --- a/src/config/config.py +++ b/src/config/config.py @@ -1,3 +1,19 @@ +# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This file is an original contribution from Telefonica Innovación Digital S.L. + import os from dotenv import load_dotenv from flask import Flask diff --git a/src/config/constants.py b/src/config/constants.py index cb04fb4..5f5bb85 100644 --- a/src/config/constants.py +++ b/src/config/constants.py @@ -13,6 +13,7 @@ # limitations under the License. # This file includes original contributions from Telefonica Innovación Digital S.L. + from pathlib import Path # Default port for NSC deployment diff --git a/src/database/store_data.py b/src/database/store_data.py index 5040314..5806d6c 100644 --- a/src/database/store_data.py +++ b/src/database/store_data.py @@ -1,3 +1,19 @@ +# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This file is an original contribution from Telefonica Innovación Digital S.L. + import json, os from src.config.constants import DATABASE_PATH diff --git a/src/mapper/main.py b/src/mapper/main.py index 3d67f5e..84be572 100644 --- a/src/mapper/main.py +++ b/src/mapper/main.py @@ -1,3 +1,19 @@ +# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This file is an original contribution from Telefonica Innovación Digital S.L. + import logging from src.planner.planner import Planner from .slo_viability import slo_viability diff --git a/src/mapper/slo_viability.py b/src/mapper/slo_viability.py index d737052..92b215a 100644 --- a/src/mapper/slo_viability.py +++ b/src/mapper/slo_viability.py @@ -1,3 +1,19 @@ +# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This file is an original contribution from Telefonica Innovación Digital S.L. + import logging def slo_viability(slice_slos, nrp_slos): diff --git a/src/nbi_processor/detect_format.py b/src/nbi_processor/detect_format.py index 290e197..2b48be3 100644 --- a/src/nbi_processor/detect_format.py +++ b/src/nbi_processor/detect_format.py @@ -1,3 +1,19 @@ +# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This file is an original contribution from Telefonica Innovación Digital S.L. + def detect_format(json_data): """ Detect the format of the input network slice intent. diff --git a/src/nbi_processor/main.py b/src/nbi_processor/main.py index ca1acd3..7da1d1e 100644 --- a/src/nbi_processor/main.py +++ b/src/nbi_processor/main.py @@ -1,3 +1,19 @@ +# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This file is an original contribution from Telefonica Innovación Digital S.L. + import logging from .detect_format import detect_format from .translator import translator diff --git a/src/nbi_processor/translator.py b/src/nbi_processor/translator.py index 4f1953a..1b1caae 100644 --- a/src/nbi_processor/translator.py +++ b/src/nbi_processor/translator.py @@ -1,3 +1,19 @@ +# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This file is an original contribution from Telefonica Innovación Digital S.L. + import uuid, os from src.utils.load_template import load_template from src.config.constants import TEMPLATES_PATH diff --git a/src/realizer/ixia/ixia_connect.py b/src/realizer/ixia/ixia_connect.py index c001fc3..d7eb008 100644 --- a/src/realizer/ixia/ixia_connect.py +++ b/src/realizer/ixia/ixia_connect.py @@ -1,3 +1,19 @@ +# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This file is an original contribution from Telefonica Innovación Digital S.L. + from .helpers.NEII_V4 import NEII_controller def ixia_connect(requests, ixia_ip): # The IP should be sent by parameter diff --git a/src/realizer/ixia/main.py b/src/realizer/ixia/main.py index 8935396..4b8d3d7 100644 --- a/src/realizer/ixia/main.py +++ b/src/realizer/ixia/main.py @@ -1,3 +1,19 @@ +# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This file is an original contribution from Telefonica Innovación Digital S.L. + import logging def ixia(ietf_intent): diff --git a/src/realizer/main.py b/src/realizer/main.py index b2e5be3..2bd8983 100644 --- a/src/realizer/main.py +++ b/src/realizer/main.py @@ -1,3 +1,19 @@ +# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This file is an original contribution from Telefonica Innovación Digital S.L. + from .select_way import select_way from .nrp_handler import nrp_handler diff --git a/src/realizer/nrp_handler.py b/src/realizer/nrp_handler.py index ba4c1ff..fa08d8d 100644 --- a/src/realizer/nrp_handler.py +++ b/src/realizer/nrp_handler.py @@ -1,3 +1,19 @@ +# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This file is an original contribution from Telefonica Innovación Digital S.L. + import logging, os, json from src.config.constants import DATABASE_PATH diff --git a/src/realizer/select_way.py b/src/realizer/select_way.py index 548dff1..110b18a 100644 --- a/src/realizer/select_way.py +++ b/src/realizer/select_way.py @@ -1,3 +1,19 @@ +# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This file is an original contribution from Telefonica Innovación Digital S.L. + import logging from .ixia.main import ixia from .tfs.main import tfs diff --git a/src/realizer/send_controller.py b/src/realizer/send_controller.py index 53667c8..9bb81af 100644 --- a/src/realizer/send_controller.py +++ b/src/realizer/send_controller.py @@ -1,3 +1,19 @@ +# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This file is an original contribution from Telefonica Innovación Digital S.L. + import logging from flask import current_app from .tfs.tfs_connect import tfs_connect diff --git a/src/realizer/tfs/helpers/cisco_connector.py b/src/realizer/tfs/helpers/cisco_connector.py index 230e7cb..503c8c7 100644 --- a/src/realizer/tfs/helpers/cisco_connector.py +++ b/src/realizer/tfs/helpers/cisco_connector.py @@ -1,3 +1,19 @@ +# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This file is an original contribution from Telefonica Innovación Digital S.L. + import logging from netmiko import ConnectHandler diff --git a/src/realizer/tfs/helpers/tfs_connector.py b/src/realizer/tfs/helpers/tfs_connector.py index fe52c3a..072acff 100644 --- a/src/realizer/tfs/helpers/tfs_connector.py +++ b/src/realizer/tfs/helpers/tfs_connector.py @@ -1,3 +1,19 @@ +# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This file includes original contributions from Telefonica Innovación Digital S.L. + import logging, requests, json class tfs_connector(): diff --git a/src/realizer/tfs/main.py b/src/realizer/tfs/main.py index a46badd..9e1233e 100644 --- a/src/realizer/tfs/main.py +++ b/src/realizer/tfs/main.py @@ -1,3 +1,19 @@ +# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This file is an original contribution from Telefonica Innovación Digital S.L. + import logging from .service_types.tfs_l2vpn import tfs_l2vpn from .service_types.tfs_l3vpn import tfs_l3vpn diff --git a/src/realizer/tfs/service_types/tfs_l2vpn.py b/src/realizer/tfs/service_types/tfs_l2vpn.py index 99eef86..2845dfe 100644 --- a/src/realizer/tfs/service_types/tfs_l2vpn.py +++ b/src/realizer/tfs/service_types/tfs_l2vpn.py @@ -1,3 +1,19 @@ +# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This file includes original contributions from Telefonica Innovación Digital S.L. + import logging, os from datetime import datetime from src.config.constants import TEMPLATES_PATH, NBI_L2_PATH diff --git a/src/realizer/tfs/service_types/tfs_l3vpn.py b/src/realizer/tfs/service_types/tfs_l3vpn.py index 37ba967..22ae62a 100644 --- a/src/realizer/tfs/service_types/tfs_l3vpn.py +++ b/src/realizer/tfs/service_types/tfs_l3vpn.py @@ -1,3 +1,19 @@ +# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This file includes original contributions from Telefonica Innovación Digital S.L. + import logging, os from datetime import datetime from src.config.constants import TEMPLATES_PATH, NBI_L3_PATH diff --git a/src/realizer/tfs/tfs_connect.py b/src/realizer/tfs/tfs_connect.py index 08de79f..39bb33e 100644 --- a/src/realizer/tfs/tfs_connect.py +++ b/src/realizer/tfs/tfs_connect.py @@ -1,3 +1,19 @@ +# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This file is an original contribution from Telefonica Innovación Digital S.L. + from .helpers.tfs_connector import tfs_connector from flask import current_app from src.utils.send_response import send_response diff --git a/src/utils/build_response.py b/src/utils/build_response.py index ad03faa..c013602 100644 --- a/src/utils/build_response.py +++ b/src/utils/build_response.py @@ -1,3 +1,19 @@ +# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This file is an original contribution from Telefonica Innovación Digital S.L. + def build_response(intent, response): id = intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"] diff --git a/src/utils/dump_templates.py b/src/utils/dump_templates.py index 85f48d4..67cc73f 100644 --- a/src/utils/dump_templates.py +++ b/src/utils/dump_templates.py @@ -1,3 +1,19 @@ +# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This file is an original contribution from Telefonica Innovación Digital S.L. + import json, os from src.config.constants import TEMPLATES_PATH from flask import current_app diff --git a/src/utils/load_template.py b/src/utils/load_template.py index 3fba251..fd2cd8b 100644 --- a/src/utils/load_template.py +++ b/src/utils/load_template.py @@ -1,3 +1,19 @@ +# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This file is an original contribution from Telefonica Innovación Digital S.L. + import logging, json from .send_response import send_response diff --git a/src/utils/send_response.py b/src/utils/send_response.py index ead1a0d..a8759cb 100644 --- a/src/utils/send_response.py +++ b/src/utils/send_response.py @@ -1,3 +1,19 @@ +# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This file is an original contribution from Telefonica Innovación Digital S.L. + import logging, inspect def send_response(result, message=None, code=None, data=None): diff --git a/swagger/ixia_namespace.py b/swagger/ixia_namespace.py index a5d553e..3c16be1 100644 --- a/swagger/ixia_namespace.py +++ b/swagger/ixia_namespace.py @@ -1,3 +1,19 @@ +# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This file is an original contribution from Telefonica Innovación Digital S.L. + from flask import request from flask_restx import Namespace, Resource, reqparse from src.main import NSController -- GitLab From 890895ee6788c08cb540efb5bcb0cb92ec41778d Mon Sep 17 00:00:00 2001 From: velazquez Date: Fri, 26 Sep 2025 14:09:16 +0200 Subject: [PATCH 04/26] Add db.py to handle slices in database --- .gitignore | 1 + app.py | 3 +- src/api/main.py | 33 ++------ src/database/db.py | 138 ++++++++++++++++++++++++++++++++ src/database/slice_ddbb.json | 1 - src/database/store_data.py | 30 +------ src/webui/gui.py | 5 +- swagger/models/create_models.py | 3 +- 8 files changed, 156 insertions(+), 58 deletions(-) create mode 100644 src/database/db.py delete mode 100644 src/database/slice_ddbb.json diff --git a/.gitignore b/.gitignore index 9eac55b..f16afd3 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ __pycache__/ swagger/__pycache__/ src/__pycache__/ venv/ +.env diff --git a/app.py b/app.py index 784c904..ccdbed3 100644 --- a/app.py +++ b/app.py @@ -23,10 +23,11 @@ from swagger.ixia_namespace import ixia_ns from src.config.constants import NSC_PORT from src.webui.gui import gui_bp from src.config.config import create_config - +from src.database.db import init_db def create_app(): """Factory para crear la app Flask con la configuración cargada""" + init_db() app = Flask(__name__) app = create_config(app) CORS(app) diff --git a/src/api/main.py b/src/api/main.py index 2896284..8261a9b 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -14,10 +14,10 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. -from src.config.constants import DATABASE_PATH from src.utils.send_response import send_response -import os, json, logging +import logging from flask import current_app +from src.database.db import get_data, delete_data, get_all_data, delete_all_data class Api: def __init__(self, slice_service): @@ -81,8 +81,7 @@ class Api: """ try: # Read slice database from JSON file - with open(os.path.join(DATABASE_PATH, "slice_ddbb.json"), 'r') as file: - content = json.load(file) + content = get_all_data() # If specific slice ID is provided, find and return matching slice if slice_id: for slice in content: @@ -160,24 +159,14 @@ class Api: - If need_l2vpn_support is True, performs additional L2VPN cleanup """ try: - # Read current slice database - with open(os.path.join(DATABASE_PATH, "slice_ddbb.json"), 'r') as file: - content = json.load(file) - id = None - # Delete specific slice if slice_id is provided if slice_id: - for i, slice in enumerate(content): - if slice["slice_id"] == slice_id and slice.get("controller") == self.slice_service.controller_type: - del content[i] - id = i - break + slice = get_data(slice_id) # Raise error if slice not found - if id is None: + if not slice or slice.get("controller") != self.slice_service.controller_type: raise ValueError("Transport network slice not found") # Update slice database - with open(os.path.join(DATABASE_PATH, "slice_ddbb.json"), 'w') as file: - json.dump(content, file, indent=4) + delete_data(slice_id) logging.info(f"Slice {slice_id} removed successfully") return {}, 204 @@ -189,16 +178,8 @@ class Api: if current_app.config["TFS_L2VPN_SUPPORT"]: self.slice_service.tfs_l2vpn_delete() - data_removed = [slice for slice in content if slice.get("controller") == self.slice_service.controller_type] - - # Verify slices exist before deletion - if len(data_removed) == 0: - raise ValueError("Transport network slices not found") - - filtered_data = [slice for slice in content if slice.get("controller") != self.slice_service.controller_type] # Clear slice database - with open(os.path.join(DATABASE_PATH, "slice_ddbb.json"), 'w') as file: - json.dump(filtered_data, file, indent=4) + delete_all_data() logging.info("All slices removed successfully") return {}, 204 diff --git a/src/database/db.py b/src/database/db.py new file mode 100644 index 0000000..4ace056 --- /dev/null +++ b/src/database/db.py @@ -0,0 +1,138 @@ +# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import sqlite3, json, logging + +# Database file +DB_NAME = "slice.db" + +# Initialize database and create table +def init_db(): + 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 + ) + """) + conn.commit() + conn.close() + +# Save data to the database +def save_data(slice_id: str, intent_dict: dict, controller: str): + 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() + 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): + 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): + 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]: + conn = sqlite3.connect(DB_NAME) + cursor = conn.cursor() + cursor.execute("SELECT * FROM slice WHERE slice_id = ?", (slice_id,)) + row = cursor.fetchone() + conn.close() + + if row: + column_names = [description[0] for description in cursor.description] + result = dict(zip(column_names, row)) + if isinstance(result.get("intent"), str): + try: + result["intent"] = json.loads(result["intent"]) + except json.JSONDecodeError: + 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]: + 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(): + 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() diff --git a/src/database/slice_ddbb.json b/src/database/slice_ddbb.json deleted file mode 100644 index 0637a08..0000000 --- a/src/database/slice_ddbb.json +++ /dev/null @@ -1 +0,0 @@ -[] \ No newline at end of file diff --git a/src/database/store_data.py b/src/database/store_data.py index 5806d6c..1fccbfb 100644 --- a/src/database/store_data.py +++ b/src/database/store_data.py @@ -14,8 +14,7 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. -import json, os -from src.config.constants import DATABASE_PATH +from src.database.db import save_data, update_data def store_data(intent, slice_id, controller_type=None): """ @@ -30,31 +29,10 @@ def store_data(intent, slice_id, controller_type=None): intent (dict): Network slice intent to be stored slice_id (str, optional): Existing slice ID to update. Defaults to None. """ - file_path = os.path.join(DATABASE_PATH, "slice_ddbb.json") - # Create initial JSON file if it doesn't exist - if not os.path.exists(file_path): - with open(file_path, 'w') as file: - json.dump([], file, indent=4) - - # Read existing content - with open(file_path, 'r') as file: - content = json.load(file) - # Update or add new slice intent if slice_id: - # Update existing slice intent - for slice in content: - if slice["slice_id"] == slice_id: - slice["intent"] = intent + update_data(slice_id, intent, controller_type) else: # Add new slice intent - content.append( - { - "slice_id": intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"], - "intent": intent, - "controller": controller_type, - }) - - # # Write updated content back to file - with open(file_path, 'w') as file: - json.dump(content, file, indent=4) \ No newline at end of file + slice_id = intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"] + save_data(slice_id, intent, controller_type) \ No newline at end of file diff --git a/src/webui/gui.py b/src/webui/gui.py index 8874530..6b7d2fe 100644 --- a/src/webui/gui.py +++ b/src/webui/gui.py @@ -141,7 +141,6 @@ def __datos_json(): try: with open(os.path.join(SRC_PATH, 'slice_ddbb.json'), 'r') as fichero: datos =json.load(fichero) - print(datos) rows =[] for source_ip, source_info in datos["source"].items(): vlan = source_info["vlan"] @@ -165,8 +164,8 @@ def home(): session['enter'] = False # Leer las IPs actuales del archivo de configuración try: - tfs_ip = {"TFS_IP": current_app.config["TFS_IP"]} - ixia_ip = {"IXIA_IP": current_app.config["IXIA_IP"]} + tfs_ip = current_app.config["TFS_IP"] + ixia_ip = current_app.config["IXIA_IP"] except Exception: tfs_ip = 'No configurada' ixia_ip = 'No configurada' diff --git a/swagger/models/create_models.py b/swagger/models/create_models.py index 9e965bf..a1f7d3a 100644 --- a/swagger/models/create_models.py +++ b/swagger/models/create_models.py @@ -293,7 +293,8 @@ def create_ietf_network_slice_nbi_yang_model(slice_ns): slice_ddbb_model = slice_ns.model('ddbb_model', { 'slice_id': fields.String(), - 'intent': fields.List(fields.Nested(ietf_network_slice_request_model)) + 'intent': fields.List(fields.Nested(ietf_network_slice_request_model)), + 'controller': fields.String() }) -- GitLab From b034ed7669d3083fdeb14a9e79e0a32708079394 Mon Sep 17 00:00:00 2001 From: velazquez Date: Fri, 26 Sep 2025 14:52:13 +0200 Subject: [PATCH 05/26] Change code to get realization method by tag-type in ietf intent --- .gitignore | 2 +- src/mapper/main.py | 1 - src/realizer/main.py | 7 ++++--- src/realizer/tfs/main.py | 6 +++--- src/templates/ietf_template_empty.json | 10 +++++++--- src/utils/safe_get.py | 26 ++++++++++++++++++++++++++ 6 files changed, 41 insertions(+), 11 deletions(-) create mode 100644 src/utils/safe_get.py diff --git a/.gitignore b/.gitignore index f16afd3..d4f1865 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,4 @@ swagger/__pycache__/ src/__pycache__/ venv/ .env - +slice.db diff --git a/src/mapper/main.py b/src/mapper/main.py index 84be572..b738935 100644 --- a/src/mapper/main.py +++ b/src/mapper/main.py @@ -43,7 +43,6 @@ def mapper(ietf_intent): # 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"] - print(f"SLOs: {slos}") if slos: # Find candidate NRPs that can meet the SLO requirements candidates = [ diff --git a/src/realizer/main.py b/src/realizer/main.py index 2bd8983..e1f08a0 100644 --- a/src/realizer/main.py +++ b/src/realizer/main.py @@ -12,10 +12,12 @@ # 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. +# 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 src.utils.safe_get import safe_get def realizer(ietf_intent, need_nrp=False, order=None, nrp=None, controller_type=None, response=None): """ @@ -37,7 +39,6 @@ def realizer(ietf_intent, need_nrp=False, order=None, nrp=None, controller_type= return nrp_view else: # Select slice service method - way = ietf_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["service-tags"]["tag-type"]["value"] - way = "L2VPN" + way = safe_get(ietf_intent, ['ietf-network-slice-service:network-slice-services', 'slice-service', 0, 'service-tags', 'tag-type', 0, 'tag-type-value', 0]) request = select_way(controller=controller_type, way=way, ietf_intent=ietf_intent, response=response) return request diff --git a/src/realizer/tfs/main.py b/src/realizer/tfs/main.py index 9e1233e..2975127 100644 --- a/src/realizer/tfs/main.py +++ b/src/realizer/tfs/main.py @@ -19,11 +19,11 @@ from .service_types.tfs_l2vpn import tfs_l2vpn from .service_types.tfs_l3vpn import tfs_l3vpn def tfs(ietf_intent, way=None, response=None): - if way == "L2VPN": + if way == "L2": realizing_request = tfs_l2vpn(ietf_intent, response) - elif way == "L3VPN": + elif way == "L3": realizing_request = tfs_l3vpn(ietf_intent, response) else: - logging.warning(f"Unsupported way: {way}. Defaulting to L2VPN realization.") + 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 diff --git a/src/templates/ietf_template_empty.json b/src/templates/ietf_template_empty.json index cdaf66c..c484a4a 100644 --- a/src/templates/ietf_template_empty.json +++ b/src/templates/ietf_template_empty.json @@ -29,10 +29,14 @@ "id":"5GSliceMapping", "description":"example 5G Slice mapping", "service-tags":{ - "tag-type":{ - "tag-type":"", - "value":"" + "tag-type": [ + { + "tag-type": "", + "tag-type-value": [ + "" + ] } + ] }, "slo-sle-policy":{ "slo-sle-template":"" diff --git a/src/utils/safe_get.py b/src/utils/safe_get.py new file mode 100644 index 0000000..a12d885 --- /dev/null +++ b/src/utils/safe_get.py @@ -0,0 +1,26 @@ +# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This file is an original contribution from Telefonica Innovación Digital S.L. + +def safe_get(dct, keys): + """Safely get a nested value from a dictionary or list.""" + 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] + else: + return None + return dct -- GitLab From 69b9621b5709888ef0f6554bc40b5b099c75d77f Mon Sep 17 00:00:00 2001 From: velazquez Date: Tue, 30 Sep 2025 11:07:18 +0200 Subject: [PATCH 06/26] Change API and tfs handlers to for slice deletion and modification in TFS --- src/api/main.py | 30 ++++++++++++++++----- src/realizer/tfs/helpers/tfs_connector.py | 18 +++++++++++++ src/realizer/tfs/service_types/tfs_l2vpn.py | 7 +++-- src/realizer/tfs/service_types/tfs_l3vpn.py | 9 +++---- 4 files changed, 49 insertions(+), 15 deletions(-) diff --git a/src/api/main.py b/src/api/main.py index 8261a9b..344171c 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -18,6 +18,8 @@ from src.utils.send_response import send_response import logging from flask import current_app from src.database.db import get_data, delete_data, get_all_data, delete_all_data +from src.realizer.tfs.helpers.tfs_connector import tfs_connector +from src.utils.safe_get import safe_get class Api: def __init__(self, slice_service): @@ -44,7 +46,7 @@ class Api: result = self.slice_service.nsc(intent) if not result: return send_response(False, code=404, message="No intents found") - + logging.info(f"Slice created successfully") return send_response( True, code=201, @@ -120,7 +122,7 @@ class Api: result = self.slice_service.nsc(intent, slice_id) 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, @@ -165,6 +167,14 @@ class Api: # 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") @@ -173,10 +183,18 @@ class Api: # Delete all slices else: # Optional: Delete in Teraflow if configured - if self.slice_service.controller_type == "TFS": - # TODO: should send a delete request to Teraflow - if current_app.config["TFS_L2VPN_SUPPORT"]: - self.slice_service.tfs_l2vpn_delete() + 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"]: + self.slice_service.tfs_l2vpn_delete() # Clear slice database delete_all_data() diff --git a/src/realizer/tfs/helpers/tfs_connector.py b/src/realizer/tfs/helpers/tfs_connector.py index 072acff..dfcfeef 100644 --- a/src/realizer/tfs/helpers/tfs_connector.py +++ b/src/realizer/tfs/helpers/tfs_connector.py @@ -15,6 +15,7 @@ # This file includes original contributions from Telefonica Innovación Digital S.L. import logging, requests, json +from src.config.constants import NBI_L2_PATH, NBI_L3_PATH class tfs_connector(): def webui_post(self, tfs_ip, service): @@ -50,5 +51,22 @@ class tfs_connector(): logging.debug("Posting to TFS NBI: %s",data) token={'csrf_token':token} response = session.post(url,headers=headers,data=data,timeout=60) + response.raise_for_status() + logging.debug("Http response: %s",response.text) + return response + + def nbi_delete(self, tfs_ip: str, service_type: str , service_id: str) -> requests.Response: + user="admin" + password="admin" + url = f'http://{user}:{password}@{tfs_ip}' + if service_type == 'L2': + url = url + f'/{NBI_L2_PATH}/vpn-service={service_id}' + elif service_type == 'L3': + url = url + f'/{NBI_L3_PATH}/vpn-service={service_id}' + else: + raise ValueError("Invalid service type. Use 'L2' or 'L3'.") + response = requests.delete(url, timeout=60) + response.raise_for_status() + logging.debug('Service deleted successfully') logging.debug("Http response: %s",response.text) return response \ No newline at end of file diff --git a/src/realizer/tfs/service_types/tfs_l2vpn.py b/src/realizer/tfs/service_types/tfs_l2vpn.py index 2845dfe..ca190fb 100644 --- a/src/realizer/tfs/service_types/tfs_l2vpn.py +++ b/src/realizer/tfs/service_types/tfs_l2vpn.py @@ -15,7 +15,6 @@ # This file includes original contributions from Telefonica Innovación Digital S.L. import logging, os -from datetime import datetime from src.config.constants import TEMPLATES_PATH, NBI_L2_PATH from src.utils.load_template import load_template from ..helpers.cisco_connector import cisco_connector @@ -53,8 +52,8 @@ def tfs_l2vpn(ietf_intent, response): # Load L2VPN service template tfs_request = load_template(os.path.join(TEMPLATES_PATH, "L2-VPN_template_empty.json"))["services"][0] - # Generate unique service UUID - tfs_request["service_id"]["service_uuid"]["uuid"] += "-" + str(int(datetime.now().timestamp() * 1e7)) + # 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"]: @@ -62,7 +61,7 @@ def tfs_l2vpn(ietf_intent, response): 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("QoS Requirements", []): + for constraint in slice.get("requirements", []): tfs_request["service_constraints"].append({"custom": constraint}) # Add configuration rules diff --git a/src/realizer/tfs/service_types/tfs_l3vpn.py b/src/realizer/tfs/service_types/tfs_l3vpn.py index 22ae62a..ff8f081 100644 --- a/src/realizer/tfs/service_types/tfs_l3vpn.py +++ b/src/realizer/tfs/service_types/tfs_l3vpn.py @@ -15,7 +15,6 @@ # This file includes original contributions from Telefonica Innovación Digital S.L. import logging, os -from datetime import datetime from src.config.constants import TEMPLATES_PATH, NBI_L3_PATH from src.utils.load_template import load_template from flask import current_app @@ -50,8 +49,8 @@ def tfs_l3vpn(ietf_intent, response): 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] - # Generate unique service UUID - tfs_request["service_id"]["service_uuid"]["uuid"] += "-" + str(int(datetime.now().timestamp() * 1e7)) + # 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"]: @@ -59,7 +58,7 @@ def tfs_l3vpn(ietf_intent, response): 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("QoS Requirements", []): + for constraint in slice.get("requirements", []): tfs_request["service_constraints"].append({"custom": constraint}) # Add configuration rules @@ -112,7 +111,7 @@ def tfs_l3vpn(ietf_intent, response): access["vpn-attachment"]["vpn-id"] = full_id # Aplicar restricciones QoS - for constraint in slice.get("QoS Requirements", []): + for constraint in slice.get("requirements", []): ctype = constraint["constraint_type"] cvalue = float(constraint["constraint_value"]) if constraint["constraint_type"].startswith("one-way-bandwidth"): -- GitLab From 86c4a9c802856b43996f2381cd42693dffccd112 Mon Sep 17 00:00:00 2001 From: velazquez Date: Tue, 30 Sep 2025 12:09:44 +0200 Subject: [PATCH 07/26] Update README --- README.md | 155 +++++++++++++++++++++++++++--------- images/NSC_Architecture.png | Bin 493804 -> 190721 bytes 2 files changed, 119 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 6eda6c1..626f670 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,57 @@ -# Network Slice Controller (NSC) Architecture - -The Network Slice Controller (NSC) is a component defined by the IETF to orchestrate the request, realization, and lifecycle control of network slices. It consists of two main modules: the mapper and the realizer. +# Network Slice Controller (NSC) + +The Network Slice Controller (NSC) is a component defined by the IETF to orchestrate the request, realization, and lifecycle control of IETF Network Slices. + +--- + +## 📑 Table of Contents +1. [Overview](#overview) +2. [Main Components](#main-components) + - [NBI Processor](#nbi-processor) + - [Mapper](#mapper) + - [Realizer](#realizer) + - [Slice Database](#slice-database) +3. [Workflow](#workflow) +4. [Architecture](#architecture) +5. [API](#api) +6. [WebUI](#webui) +7. [Requirements](#requirements) +8. [Configuration](#configuration) + - [Logging](#logging) + - [General](#general) + - [Mapper](#mapper-1) + - [Realizer](#realizer-1) + - [Teraflow Configuration](#teraflow-configuration) + - [Ixia Configuration](#ixia-configuration) + - [WebUI](#webui-1) +9. [Usage](#usage) + +--- ## Overview -The NSC handles end-to-end network slice requests originating from 5G customers. These requests are managed by the 5G end-to-end orchestrator, which configures RAN and Core Network elements accordingly and passes the request to the NSC for processing. The NSC then interacts with relevant network controllers to implement the network slice into the transport network. +The NSC takes requests for IETF Network Slice Services and implements them using a suitable underlay technology. The NSC is the key component for control and management of the IETF Network Slices. It provides the creation/modification/deletion, monitoring, and optimization of IETF Network Slices in a multi-domain, multi-technology, and multi-vendor environment. + +The main task of the NSC is to map abstract IETF Network Slice Service requirements to concrete technologies and establish required connectivity, ensuring that resources are allocated to slice as necessary. +The IETF Network Slice Service Interface is used for communicating details of an IETF Network Slice Service (configuration, selected policies, operational state, etc.) as well as information about status and performance of the IETF Network Slice. + +The NSC also handles end-to-end network slice requests originating from 5G customers. These requests are managed by the 5G end-to-end orchestrator, which configures RAN and Core Network elements accordingly and passes the request to the NSC for processing. The NSC then interacts with relevant network controllers to implement the network slice into the transport network. + +## Main Components -## Main Modules +### NBI Processor + +This component manages the requests entering the system. There are 4 kinds of requests: +- Create: when this request arrives, the NBI processor checks the body of the request to analyze the format. If it is 3GPP NRM, it translates it into IETF Slice Service Request. If it is the latter, it propagates the request into the mapper. This function is able to process each request independently, even if several requests with different formats come together in the same request. Once created, the slice is stored in the slice database. +- Delete: this request can be referring to a specific slice or to all slices configured. In any case, a deletion request is send to the southbound controller and the referred slices are deleted from the slice database +- Get: this request can be referring to a specific slice or to all slices configured. In any case, the referred slices in the request are returned from the slice database. +- Modify: this request allows changing the configuration of an existing slice. It works similarly to the create request, depending on the format present in the request, it is translated or not. Once processed, a modify request is sent to the controller and the referred slice is updated in the slice database. + +Detail of how customers can make different requests is provided in the API section ### Mapper -The mapper processes client network slice requests and correlates them with existing slices. When a slice request arrives, the mapper translates it by converting the request expressed in 3GPP NRM terms into the IETF NBI data model. This involves identifying the service demarcation points (SDPs) that define the connectivity in the transport network. Once these parameters are identified and mapped into the data model, the next step is to check the feasibility of implementing the slice request. +As defined by IETF the mapper receives slices service requests from customers and process them to obtain an overall view of how this new request complements the rest of slices. Realizing a slice requires an existing network resource partition (NRP) with the specified slice requirements, which may not be available at the time of the request. This information will be retrieved from an external module, which is beyond the scope of this definition. This module will provide a response regarding the feasibility of realizing the slice. @@ -18,52 +59,102 @@ If there are no available NRPs for instantiating the slice, the mapper will requ ### Realizer -The realizer module determines the realization of each slice by interacting with specific network controllers. This version is currently working with Teraflow SDN controller. It receives requests from the mapper and decides on the technologies to be used to instantiate the slice based on the selected NRP associated with the slice. For example, Layer 2 VPN is the technology employed to realize network slices in this version. To achieve this, the realizer generates a request for the network controller to establish a Layer 2 VPN between two SDPs with the requirements specified in the slice request. +The realizer implements slices by interacting with specific network controllers. It receives requests from the mapper and should have the intelligence to select the most adequate realizing technology to realize the slice. As it currently does not have that intelligence, it is supposed to use a fixed technology or one that selects the customer. -## Workflow +The NSC operates over the TeraflowSDN controller and the IXIA NE II device. -1. **Request Initiation**: Network slice request originates from a 5G customer and is managed by the 5G end-to-end orchestrator. +[TeraflowSDN](https://labs.etsi.org/rep/tfs/controller) is an open-source cloud native SDN controller enabling smart connectivity services for future networks beyond 5G. It allows establishing different services into a connected topology. The services currently supported are layer 2 VPNs and layer 3 VPNs. Therefore, the NSC has two specific functions that will be called “Realizing modules” that translate the IETF Slice Service Request into a request for deploying a VPN in TeraflowSDN: ``tfs_l2vpn`` and ``tfs_l3vpn``. TFS accepts two options to deploy its services, uploading a service via it webUI using a proprietary descriptor, or using an standardized interface via its NBI based on the L2SM and L3SM IETF YANG Models. The second option is the preferred one, as it follows and standardized approach, although both options are supported. -2. **Mapper Processing**: Converts the request into the IETF NBI data model, identifies SDPs, and checks feasibility. +The IXIA NEII is a device that allows emulating network impairments. In this context, it is used to simplify configurations in the data plane and offered channel characteristics as a proof of concept while focusing on the specific configurations on the control plane. The characteristics that can be emulated over a channel are: Ips, VLAN, bandwidth, latency, delay variance (which is requested as tolerance), packet disorder (which is requested as reliability). When the realizer receives a request, it translates it into a proprietary template with the specified characteristics to be consumed by the device API. -3. **Realizer Action**: Determines technology (e.g., Layer 2 VPN) and interacts with network controllers to instantiate the slice. +### Slice Database + +The slice database is updated after each request by adding, removing or updating the stored slices. It contains two fields: +- ``slice_id``: it stores a unique identifier for the slice, serving as primary key, and is mapped from the id value present in the IETF Slice Service Model +- ``intent``: it stores an object with the whole IETF Slice Service Model, that contains the characteristics and endpoints of the slice + + +## Workflow + +1. A request comes into the NSC NBI +2. If it is a GET request, the slice database is consulted. If it is a POST request, the NBI processor inspects the body of the request +3. If it is in 3GPP format, it is translated to IETF Slice Service Request. If not, it is sent directly to the mapper +4. The mapper processes the request and interacts with the planner when activated +5. The planner processes the request, populates it with the optimal path and returns it to the mapper +6. The mapper sends the request to the realizer, which selects a realization technology +7. The realization module translate the request to the controller specific configuration +8. The realizer sends the request to the controller and updates the database with the new slice -4. **Implementation**: Network controllers configure the transport network as per the slice requirements. ## Arquitecture NSC Architecture +## API + +The API has two namespaces: tfs and ixia, one dedicated to each controller, with the operations POST, GET, PUT and DELETE +- `GET /{namespace}/slice`: returns a list with all transport network slices currently available in the controller. +- `POST /{namespace}/slice`: allows the submission of a new network slice request +- `DELETE /{namespace}/slice`: deletes all transport network slices stored in the controller. +- `GET /{namespace}/slice/{slice_id}`: retrieves detailed information about a specific transport network slice identified by its slice_id +- `DELETE /{namespace}/slice/{slice_id}`: deletes a specific transport network slice identified by its slice_id +- `PUT /{namespace}/slice/{slice_id}`: modifies a specific transport network slice identified by its slice_id + +The API is available in the swagger documentation panel at `{ip}:{NSC_PORT}/nsc` + +## WebUI + +The WebUI is a graphical interface that allows operating the NSC. Currently, it has more limited operations than the API. It supports the creation of slices in both Teraflow and IXIA controllers, as well as getting information of the current slices. Modify and deletion is not yet supported. + +It is accessed at `{ip}:{NSC_PORT}/webui` + ## Requirements - Python3.12 - python3-pip - python3-venv -## Configuration Constants +## Configuration -In the main configuration file, several constants can be adjusted to customize the Network Slice Controller (NSC) behavior: +In the `.env` file, several constants can be adjusted to customize the Network Slice Controller (NSC) behavior: ### Logging - `DEFAULT_LOGGING_LEVEL`: Sets logging verbosity - - Default: `logging.INFO` - - Options: `logging.DEBUG`, `logging.INFO`, `logging.WARNING`, `logging.ERROR` + - Default: `INFO` + - Options: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `NOTSET`, `CRITICAL` -### Server -- `NSC_PORT`: Server port - - Default: `8081` +### General +- `DUMP_TEMPLATES`: Flag to deploy templates for debugging + - Default: `false` -### Paths -- `SRC_PATH`: Absolute path to source directory -- `TEMPLATES_PATH`: Path to templates directory +## Mapper +- `NRP_ENABLED`: Flag to determine if the NSC performs NRPs + - Default: `false` +- `PLANNER_ENABLED`: Flag to activate the planner + - Default: `false` +- `PCE_EXTERNAL`: Flag to determine if external PCE is used + - Default: `false` + +## Realizer +- `DUMMY_MODE`: If true, no config sent to controllers + - Default: `true` ### Teraflow Configuration -- `TFS_UPLOAD`: Enable/disable uploading slice service to Teraflow - - Default: `False` +- `UPLOAD_TYPE`: Configure type of upload to Teraflow + - Default: `WEBUI` + - Options: `WEBUI`, `NBI` - `TFS_IP`: Teraflow SDN controller IP - - Default: `"192.168.165.10"` + - Default: `"127.0.0.1"` - `TFS_L2VPN_SUPPORT`: Enable additional L2VPN configuration support - Default: `False` +### Ixia Configuration +- `IXIA_IP`: Ixia NEII IP + - Default: `"127.0.0.1"` + +### WebUI +- `WEBUI_DEPLOY`: Flag to deploy WebUI + - Default: `False` + ## Usage To deploy and execute the NSC, follow these steps: @@ -75,6 +166,7 @@ To deploy and execute the NSC, follow these steps: python3 -m venv venv source venv/bin/activate pip install -r requirements.txt + cp ./src/config/.env.example ./.env ``` 1. **Start NSC Server**: @@ -82,16 +174,7 @@ To deploy and execute the NSC, follow these steps: python3 app.py ``` -2. **Generate Slice Requests**: - - To send slice request, the NSC accepts POST request at the endpoint /slice. It is available in the swagger documentation panel at {ip}:{NSC_PORT}/nsc - - + Send slice requests via **API** (/nsc) or **WebUI** (/webui) diff --git a/images/NSC_Architecture.png b/images/NSC_Architecture.png index 852437d55f3fadcb9c6a4303be7c70a264977e30..7abb89ba0da61ad538251f5537a7731e44b2319a 100644 GIT binary patch literal 190721 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%0D}`^~;o}Wpf42@IA}9 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

-BU9!FWb2)(IYDA*78@&Za>aqky5|= zRbkt|ZK3_UWitQgp1M4_^moQ2OO?ikrQFjWeSMz(TE~MkPQ24_Z7TjMb?xYz{*_Cy9>WR*tpAT#g^Ubzg2E4JG!1cRrB}o^^03vh4;p#Gqf}wdvx^0Dm&)Pz=)0I zc9YI}f)?1^>3IiAE~S&d@BRFB@^#zwKcDPn?tg3N1wZ4|lpL$qc8e))SQ^%sTLt}efPU4Q9EIfr%kg@lAQU|spxmA&BO zN%i?MX=bzS&1U+z%4PKQcOMU)JpUY@$*Hq-eyL2SU-%p?M@f%Lp6hPETQ_@MkZI-_W~<3B>$m^s51uz`O>^0D|CYx;kIy^4&c6KR zf`r*+YfBddu1eki^U~~y@0Y7$MR&$ESM5mtBm2tx1nYTQtJw3u-)Ns-Q2hPekIJLx zE8cB<$-%*O;Ih7LtNgydg8Tn|n$4%;rEa?Tc>kfZ+wZMmo>ukW*mz^s&F?>+tnC*p z{AOcgYjd%_!2Zk6_;?$WcZzq9%?YWlt-n~kt5|a7H=Vhc{%m-|KF6ZUiQV__pFbrV zzP$VW-fgYb)=kW=LMLoC{pYw>`8W2@$KUc*_9{DW?Q~D~nPC$*y)@0p%J4$Oo*6uO zJ67oZ63d;>&2R2sl5Y_c&oS*-{Pa#Q`LCk72aA9DnW!sk|Ky2z#Lujm&e*-T+4$7# z_T8NIZ|u5X%uxCABEoyF;nm8?_mihD?CUQ1mGFtVCVK_-$^#m*X)8ZkZSZxu zZk;*VP}i7Knor;Pa@X4e=ib?0$|wA3h`IHI$0YC8mX-I*@7Hbzt2m!;(M z*Y8(P*F5bJep~VM)~L6~U6!5Rm3nj2|8ui7CG~Cnma8m%(Xgy%y4n9bHg_JZ-DaO2 zsm>}n-xw!U=7(LsB!zw zzql*c8UEZ;+%@l{ZPOk7wbnaC79P|sa6UWl)nom)N2b4XP&SrYuMb**?x@Fn)qPQ(Wpy=^s> zr%E{2om4nzCz&26?nA6gdguC&YV5lau_IpI&N+llsM;U;l6A(O zt4GKKI%CSMjQO?SB3~WrmHu7#_v`i7vXfV| zBrlk#!C!Q6jUCrftqV6cr=LH-#w#TwVVV{4^Viqc;u;!L?>i{RTJxBQfdY4`)4Du| zLx~T*cZ!0y{_w@tF+6Ia4fE@Fd0mOtKAqNRsmyqBdA;+WpRx`C#tX9K z4=7&{e4@7KwJ>w9U(tJI(`SnoFBW%kbMwo+wB)4gaTTqH5*aF|4u^7>Je#W)!4S6k z>d~Z)72a(!f`WpgO{+OQY}sAbG#`}dfAGp&X-62p#GO5gdk?)nm$2lO2|HhS@`*`& z>v;-3^SHGBVh-GPtoKCMEjE|cj_aIMeW!G+5SfsW?BL*#wb-rq(|tV&LBY&cJ`=7| zrERB9&gkgq_^0|)ZtJYZ8_W)m3a6~R!o_&i>c(n0k9L_eI?J;iH%-5y@V0T<+Iy0X zq1Fno&zvvV`C<01tS<#3N=m!_%{-gtTvTL~U)0&bwZel5wC``-nka7po*SCQGqG|f(16N;g{{Q6r;AJqM*~^bMZdLBXXZo|EHN z2QGG7_AYnk%w8847uLniJs&ry&TU;DpZa6k>ecUxjAVs7|7jQbK4+TjE-8`7cxmz5 z#T^~%k}s)5)YRC7t%)%F@cF!b{u2pb7nh$QFC3CHGBU!ZYK2bfaZ}c}1v!;BTzNv3 zV23?Z^KN;ccd1Kag^s%{`aj_;kN*EvAM}D{&9Zk@#e1Ek=@gbG62qI<5V&gjr|PL)jXJB}wca|b9l$R8 zbDzqhOGl0#ZB@5bTEwI^$zgGOJA3t9H6|x z=|a6#Pki-2!zjP}mL(o;(_`n8;W%~T!~;)IVgPL+HVF?8e_3kf%yt~SCC-Um%Kq02 z<^TNle?F|7tnTk6Ew<}pjFOVlEqa4^SV$@{wAA}(k?v@v*dsP0kZ+wsF`8)N<^%)TX8?6&OQyemsYsuKcvUuv%O=;&-d zx@OzMEH>%b*sFC>i6>1~e_iq1vd@3kt+v&>@|2WzEmK)+2--AS`uf@>3td4$QHP03 z6jlgtY@G?(hkTv$r{DK)M-J(SDK2@*V3K1jan`!eVKd+M*9&((-Wb>Ye_`nJi5$_N zl+PHQzq&{4!?$CvOryViv-$mK_p6=zrS=&7UhBGJ;q8n!H%bLQ6qJkTCB14hR~7^ z^{HG+P26pT$8#<}HB6Vh$>(2wraIqHuKr!_jVD#=?pJfeL(TeCpGBq3Q+@Mz?&Ud8 zi`UQZ=vZgzxyW#)Pnf*CJb0h+gk7_AWttC8Sn7AE^V16nR~HwT>l3A|w;egLJMZv~ zK<2Ln*H1(nlznl~;i%>{UUs*=gZpB{@7MAtjJ&rdxliz6%1~Qe#TQ|$`|Z_LqjM{c zJyknsp}BgU%9^xpxrAWwzi%%%c6-gG&D)!G&R^WOIA?xN`n0QA@yj=B?TG3Pnd#@@<5pC(>d%fYo$e?% zFCn-4_xO*0ujzJI?+-dJ{Pt<$y?U|#Gwb4}!a z-}UiKCHwPl_FBHLSvYO8Np;2ZX8n%`)$c!;%fJ7~`q}@kJWbhawpw)K#EUmJBwFA5 z`$Bu~+(hsCBM+O^Cursx?%MtM(b0^W&0pBx+$v^~oGBUc>+Jc6?Ny7T*b07~sjS)g zMqI|K=+r&Uo`BQS&YZrxoB!eB*3%!KS$A&;dK;eNvSeT3)teKt>Wz$yK0emp|9JcT z@&jwOCI9}mx8nWd^&k6g^{tKm{v!3s-uo^tC8nNc$v!?jhYlTTJ3GsC^-Abi{Q3lE zXXnkULRU}AFp-i>Q~?zuk|hZjGWaJwmDyw@C_H_^=^eXgCls;VUi0eX@~it72e02K zQT1H7qV(PKio|Q~FRWiU_UwLd>Ee29mE@fg1>MW6KORh8e__jA1D_ct%=+ge8zS>{ zj@U8Vvr8&X zKj8G7e)Z-1d)wz%F~{r2FD^U5vHGO!>9BRqtEJx`cyKY;@bOpwuVsGoen~~GE?j@+ zpNN<^{~T-iR(|`xY`4o~9Eyr&@tM@Ao;H}ZA@Q=G&6g)KI`KQ@3W9I1*!KKUYlcrv z0o$&xa~qzg?duPJ-uiIe?`4ZapIdKu@cDg0S?KYzDK1`FR(5eebf2}aJ@7g!`Zxc) zCh_>+BIo!2QLMWEi$QM2A0eSfLT``H?C4mh`7`lE(#9RL&!(lBfVTZl{1Y;7-n?^W zpzZDJf;&4kJG#4_Vp4jNJDhVubf)S5DhTdUp#8k6wW{X?!I{P^<}O% zzW!EvbN8cZSlv&xS*9zOZ{O_v`&mP~{I>u5WY6#WAeWRR^x@JM&#bwpH&lNwIy3XD z!IMeP=JM}9v?@PrLE-GQACJ0kN*HgFKQ)#2fckr%!`_?a81L9z`RdD0*|77E4TDpeso%iL%YL4Guq)pdPzgJdnE^HV5r}*qux)?EwTpT}GC>1H{{?@#Po*G}i$BqVq-^zoY)A-12uEs-vj3ojl%JP0Y4 zjr00=Tu(}@30!QQW%8!~)xy64dlTbLG76>TDxQeCxE+7k*?uhH(ckT-_4`kFoj>sF z{_y)0+Ui_zG=U|2MaS=B{Kqyp?%k05J@L)m>X2O5d!DL6Yj0;5c=QB*{`lFkAa!fc zsi%T#rb)ke+^N6s5995!H^(3I-|alO{X6?6qX`1D8%(yY<@b?j+xF>gjRgsbh8|@@&zYFa7^(aMU`I5x9yVh<{RbP=?cJ*`g+OQs(YwXJJ zWY(OjT3FkEZbz;|_*~!OFFz!=N|pAX⪙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

FGlvCtq66}#tTvQQanq%r5m{(EgW>%Gxmu?D5 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�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

>TfwZ5gQn`$auF{|U# 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 A%K!iX -- GitLab From 883071dfec292c16e1cf1f0657a4936c745a5512 Mon Sep 17 00:00:00 2001 From: armingol Date: Wed, 1 Oct 2025 08:28:49 +0000 Subject: [PATCH 08/26] Add pandas and dotenv to requirements --- requirements.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/requirements.txt b/requirements.txt index e01b558..8e50394 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,5 @@ flask-cors flask-restx netmiko requests +pandas +dotenv \ No newline at end of file -- GitLab From 7ee0b385b59f8041d1733710490e9899e9ac4f09 Mon Sep 17 00:00:00 2001 From: armingol Date: Thu, 2 Oct 2025 09:49:16 +0000 Subject: [PATCH 09/26] feat(e2e): Implement E2E orchestrator functionality - Added e2e_connect.py to handle E2E requests to the controller. - Created main.py to manage E2E realization logic based on intent and rules. - Introduced service_types for L3oWDM and L2VPN slices, including deletion capabilities. - Developed templates for Optical and IPoWDM services to standardize requests. - Enhanced main.py and select_way.py to support E2E controller type. - Updated send_controller.py to route requests to the E2E controller. - Added E2E namespace in Swagger for API documentation and interaction. - Modified build_response.py to accommodate E2E-specific response structures. --- app.py | 2 + src/config/config.py | 2 + src/main.py | 35 +-- src/mapper/main.py | 18 +- src/planner/energy_planner/energy.py | 275 ++++++++++++++++++ src/planner/hrat_planner/hrat.py | 52 ++++ src/planner/planner.py | 268 +---------------- .../tfs_optical_planner/tfs_optical.py | 254 ++++++++++++++++ src/realizer/e2e/e2e_connect.py | 49 ++++ src/realizer/e2e/main.py | 28 ++ .../e2e/service_types/del_l3ipowdm_slice.py | 177 +++++++++++ .../e2e/service_types/l3ipowdm_slice.py | 192 ++++++++++++ src/realizer/main.py | 37 ++- src/realizer/select_way.py | 7 +- src/realizer/send_controller.py | 5 +- src/templates/IPoWDM_orchestrator.json | 31 ++ src/templates/Optical_slice.json | 28 ++ src/templates/TAPI_service.json | 43 +++ src/utils/build_response.py | 97 +++--- swagger/E2E_namespace.py | 152 ++++++++++ 20 files changed, 1423 insertions(+), 329 deletions(-) create mode 100644 src/planner/energy_planner/energy.py create mode 100644 src/planner/hrat_planner/hrat.py create mode 100644 src/planner/tfs_optical_planner/tfs_optical.py create mode 100644 src/realizer/e2e/e2e_connect.py create mode 100644 src/realizer/e2e/main.py create mode 100644 src/realizer/e2e/service_types/del_l3ipowdm_slice.py create mode 100644 src/realizer/e2e/service_types/l3ipowdm_slice.py create mode 100644 src/templates/IPoWDM_orchestrator.json create mode 100644 src/templates/Optical_slice.json create mode 100644 src/templates/TAPI_service.json create mode 100644 swagger/E2E_namespace.py diff --git a/app.py b/app.py index ccdbed3..ceec6ae 100644 --- a/app.py +++ b/app.py @@ -20,6 +20,7 @@ from flask_restx import Api from flask_cors import CORS from swagger.tfs_namespace import tfs_ns from swagger.ixia_namespace import ixia_ns +from swagger.E2E_namespace import e2e_ns from src.config.constants import NSC_PORT from src.webui.gui import gui_bp from src.config.config import create_config @@ -50,6 +51,7 @@ def create_app(): # Register namespaces api.add_namespace(tfs_ns, path="/tfs") api.add_namespace(ixia_ns, path="/ixia") + api.add_namespace(e2e_ns, path="/e2e") if app.config["WEBUI_DEPLOY"]: app.secret_key = "clave-secreta-dev" diff --git a/src/config/config.py b/src/config/config.py index 6d0f8d3..c7ac302 100644 --- a/src/config/config.py +++ b/src/config/config.py @@ -42,6 +42,7 @@ def create_config(app: Flask): # 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["PLANNER_TYPE"] = os.getenv("PLANNER_TYPE", "ENERGY") app.config["PCE_EXTERNAL"] = os.getenv("PCE_EXTERNAL", "false").lower() == "true" # Realizer @@ -51,6 +52,7 @@ def create_config(app: Flask): 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_E2E"] = os.getenv("TFS_E2E", "127.0.0.1") # IXIA app.config["IXIA_IP"] = os.getenv("IXIA_IP", "127.0.0.1") diff --git a/src/main.py b/src/main.py index 076c843..6a90dc5 100644 --- a/src/main.py +++ b/src/main.py @@ -14,6 +14,7 @@ # This file includes original contributions from Telefonica Innovación Digital S.L. +import logging import time from src.utils.dump_templates import dump_templates from src.utils.build_response import build_response @@ -25,11 +26,11 @@ from src.realizer.send_controller import send_controller class NSController: """ - Network Slice Controller (NSC) - A class to manage network slice creation, + 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 + This controller handles the translation, mapping, and realization of network + slice intents from different formats (3GPP and IETF) to network-specific configurations. Key Functionalities: @@ -39,14 +40,14 @@ class NSController: - Slice Realization: Convert intents to specific network configurations (L2VPN, L3VPN) """ - def __init__(self, controller_type = "TFS"): + def __init__(self, controller_type = "TFS"): """ Initialize the Network Slice Controller. Args: - controller_type (str): Flag to determine if configurations + 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 answer (dict): Stores slice creation responses @@ -61,7 +62,7 @@ class NSController: self.start_time = 0 self.end_time = 0 self.setup_time = 0 - + def nsc(self, intent_json, slice_id=None): """ Main Network Slice Controller method to process and realize network slice intents. @@ -81,36 +82,36 @@ class NSController: Returns: tuple: Response status and HTTP status code - + """ # Start performance tracking self.start_time = time.perf_counter() # Reset requests requests = {"services":[]} - + # Process intent (translate if 3GPP) ietf_intents = nbi_processor(intent_json) - for intent in ietf_intents: + for intent in ietf_intents: # Mapper - mapper(intent) + rules = mapper(intent) # Store slice request details and build response - store_data(intent, slice_id, controller_type=self.controller_type) - self.response = build_response(intent, self.response) + store_data(intent, slice_id, controller_type=self.controller_type) + self.response = build_response(intent, self.response, controller_type= self.controller_type) # Realizer - request = realizer(intent, controller_type=self.controller_type, response = self.response) + request = realizer(intent, controller_type=self.controller_type, response = self.response, rules = rules) requests["services"].append(request) - + # Store the generated template for debugging dump_templates(intent_json, ietf_intents, requests) - + # Send config to controllers response = send_controller(self.controller_type, requests) if not response: raise Exception("Controller upload failed") - + # End performance tracking self.end_time = time.perf_counter() setup_time = (self.end_time - self.start_time) * 1000 diff --git a/src/mapper/main.py b/src/mapper/main.py index b738935..baeae47 100644 --- a/src/mapper/main.py +++ b/src/mapper/main.py @@ -14,7 +14,7 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. -import logging +import logging from src.planner.planner import Planner from .slo_viability import slo_viability from src.realizer.main import realizer @@ -36,7 +36,7 @@ def mapper(ietf_intent): Raises: Exception: If no suitable NRP is found and slice creation fails. - """ + """ if current_app.config["NRP_ENABLED"]: # Retrieve NRP view nrp_view = realizer(None, True, "READ") @@ -46,8 +46,8 @@ def mapper(ietf_intent): if slos: # Find candidate NRPs that can meet the SLO requirements candidates = [ - (nrp, slo_viability(slos, nrp)[1]) - for nrp in nrp_view + (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}") @@ -61,13 +61,15 @@ def mapper(ietf_intent): # Update NRP view realizer(ietf_intent, True, "UPDATE") # TODO Here we should put how the slice is attached to an already created nrp - else: + else: # Request the controller to create a new NRP that meets the SLOs answer = realizer(ietf_intent, True, "CREATE", best_nrp) if not answer: - raise Exception("Slice rejected due to lack of NRPs") + raise Exception("Slice rejected due to lack of NRPs") # TODO Here we should put how the slice is attached to the new nrp if current_app.config["PLANNER_ENABLED"]: - optimal_path = Planner().planner(ietf_intent) - logging.debug(f"Optimal path: {optimal_path}") \ No newline at end of file + optimal_path = Planner().planner(ietf_intent, current_app.config["PLANNER_TYPE"]) + logging.debug(f"Optimal path: {optimal_path}") + return optimal_path + return None \ No newline at end of file diff --git a/src/planner/energy_planner/energy.py b/src/planner/energy_planner/energy.py new file mode 100644 index 0000000..aee53bc --- /dev/null +++ b/src/planner/energy_planner/energy.py @@ -0,0 +1,275 @@ +# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This file is an original contribution from Telefonica Innovación Digital S.L. + +import logging, random, os, json, heapq +from src.config.constants import SRC_PATH +from flask import current_app + + +def energy_planner(self, intent): + energy_metrics = self.__retrieve_energy() + topology = self.__retrieve_topology() + source = intent.get("ietf-network-slice-service:network-slice-services", {}).get("slice-service", [])[0].get("sdps", {}).get("sdp", [])[0].get("id") or "A" + destination = intent.get("ietf-network-slice-service:network-slice-services", {}).get("slice-service", [])[0].get("sdps", {}).get("sdp", [])[1].get("id") or "B" + optimal_path = [] + # 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): + 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) + + # POST /sss/v1/slice/compute + def simulate_slice_output(input_data): + 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) + # Mostrar resultado + 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), + # Solo asigna los DLOS que existan, el resto a None + 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 = self.__calculate_optimal_path(topology, energy_metrics, source, destination, dlos) + + if not optimal_path: + logging.error("No valid path found") + raise Exception("No valid energy path found") + + return optimal_path + +def __retrieve_energy(self): + # TODO : Implement the logic to retrieve energy consumption data from controller + # Taking it from static file + with open(os.path.join(SRC_PATH, "planner/energy_ddbb.json"), "r") as archivo: + energy_metrics = json.load(archivo) + return energy_metrics + +def __retrieve_topology(self): + 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/ext_topo_ddbb.json"), "r") as archivo: + topology = json.load(archivo) + else: + # TODO : Implement the logic to retrieve topology data from controller + # Taking it from static file + with open(os.path.join(SRC_PATH, "planner/topo_ddbb.json"), "r") as archivo: + topology = json.load(archivo) + return topology + + + +def __calculate_optimal_path(self, topology, energy_metrics, source, destination, dlos): + 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 = self.__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 = [(0, source, [], 0, 0, 0, 1)] # (accumulated cost, current node, path, sum_ec, sum_ce, sum_ee, min_ure) + 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}") + + 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"Qeue -> 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(self, ec, ce, ee, ure, total_power_supply, total_power_boards, total_power_components, total_power_transceivers, alpha=1, beta=1, gamma=1, delta=1): + """ + Calcula el peso de un nodo con la fórmula: + w(v) = α·EC + β·CE + γ/EE + δ·(1 - URE) + """ + 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 + + diff --git a/src/planner/hrat_planner/hrat.py b/src/planner/hrat_planner/hrat.py new file mode 100644 index 0000000..c03560e --- /dev/null +++ b/src/planner/hrat_planner/hrat.py @@ -0,0 +1,52 @@ +# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This file is an original contribution from Telefonica Innovación Digital S.L. + +import logging +import requests + +def hrat_planner(data: str, action: str = "create") -> dict: + + data = {'network-slice-uuid': 'ecoc25-short-path-a7764e55-9bdb-4e38-9386-02ff47a33225', 'viability': True, 'actions': [{'type': 'CREATE_OPTICAL_SLICE', 'layer': 'OPTICAL', 'content': {'tenant-uuid': 'ea4ade23-1444-4f93-aabc-4fcbe2ae74dd', 'service-interface-point': [{'uuid': 'e7444187-119b-5b2e-8a60-ee26b30c441a'}, {'uuid': 'b32b1623-1f64-59d2-8148-b035a8f77625'}], 'node': [{'uuid': '68eb48ac-b686-5653-bdaf-7ccaeecd0709', 'owned-node-edge-point': [{'uuid': '7fd74b80-2b5a-55e2-8ef7-82bf589c9591', 'media-channel-node-edge-point-spec': {'mc-pool': {'supportable-spectrum': [{'lower-frequency': '191325000', 'upper-frequency': '192225000'}, {'lower-frequency': '194325000', 'upper-frequency': '195225000'}]}}}, {'uuid': '7b9f0b65-2387-5352-bc36-7173639463f0', 'media-channel-node-edge-point-spec': {'mc-pool': {'supportable-spectrum': [{'lower-frequency': '191325000', 'upper-frequency': '192225000'}, {'lower-frequency': '194325000', 'upper-frequency': '195225000'}]}}}]}, {'uuid': 'f55351ce-a5c8-50a7-b506-95b40e08bce4', 'owned-node-edge-point': [{'uuid': 'da6d924d-9cb4-5add-817d-f83e910beb2e', 'media-channel-node-edge-point-spec': {'mc-pool': {'supportable-spectrum': [{'lower-frequency': '191325000', 'upper-frequency': '192225000'}, {'lower-frequency': '194325000', 'upper-frequency': '195225000'}]}}}, {'uuid': '577ec899-ad92-5a19-a140-405a3cdbaa17', 'media-channel-node-edge-point-spec': {'mc-pool': {'supportable-spectrum': [{'lower-frequency': '191325000', 'upper-frequency': '192225000'}, {'lower-frequency': '194325000', 'upper-frequency': '195225000'}]}}}]}], 'link': [{'uuid': '3beef785-bb26-5741-af10-c5e1838c1701'}, {'uuid': '6144c664-246a-58ed-bf0a-7ec4286625da'}]}, 'controller-uuid': 'TAPI Optical Controller'}, {'type': 'PROVISION_MEDIA_CHANNEL_OLS_PATH', 'layer': 'OPTICAL', 'content': {'ols-path-uuid': 'cfeae4cb-c305-4884-9945-8b0c0f040c98', 'src-sip-uuid': 'e7444187-119b-5b2e-8a60-ee26b30c441a', 'dest-sip-uuid': 'b32b1623-1f64-59d2-8148-b035a8f77625', 'direction': 'BIDIRECTIONAL', 'layer-protocol-name': 'PHOTONIC_MEDIA', 'layer-protocol-qualifier': 'tapi-photonic-media:PHOTONIC_LAYER_QUALIFIER_MC', 'bandwidth-ghz': 100, 'link-uuid-path': ['3beef785-bb26-5741-af10-c5e1838c1701'], 'lower-frequency-mhz': '194700000', 'upper-frequency-mhz': '194800000', 'adjustment-granularity': 'G_6_25GHZ', 'grid-type': 'FLEX'}, 'controller-uuid': 'TAPI Optical Controller', 'tenant-uuid': 'ea4ade23-1444-4f93-aabc-4fcbe2ae74dd'}, {'type': 'ACTIVATE_TRANSCEIVER', 'layer': 'OPTICAL', 'content': {'node-uuid': 'Phoenix-1', 'termination-point-uuid': 'Ethernet110', 'transceiver-type': 'CFP2', 'frequency-ghz': 194700.0, 'spectrum-width-ghz': 100.0, 'tx-power-dbm': 0.0}, 'controller-uuid': 'IP Controller'}, {'type': 'ACTIVATE_TRANSCEIVER', 'layer': 'OPTICAL', 'content': {'node-uuid': 'Phoenix-2', 'termination-point-uuid': 'Ethernet220', 'transceiver-type': 'CFP2', 'frequency-ghz': 194700.0, 'spectrum-width-ghz': 100.0, 'tx-power-dbm': 0.0}, 'controller-uuid': 'IP Controller'}, {'type': 'CONFIG_VPNL3', 'layer': 'IP', 'content': {'tunnel-uuid': '9aae851a-eea9-4a28-969f-0e2c2196e936', 'src-node-uuid': 'Phoenix-1', 'src-ip-address': '10.10.1.1', 'src-ip-mask': '/24', 'src-vlan-id': 100, 'dest-node-uuid': 'Phoenix-2', 'dest-ip-address': '10.10.2.1', 'dest-ip-mask': '/24', 'dest-vlan-id': 100}, 'controller-uuid': 'IP Controller'}]} + return data + + # url = 'http://192.168.1.143:9090/api/resource-allocation/transport-network-slice-l3' + # headers = {'Content-Type': 'application/json'} + # try: + # if action == "delete": + # data = { + # "ietf-network-slice-service:network-slice-services": { + # "slice-service": [ + # { + # "id": data + # } + # ] + # } + # } + # response = requests.delete(url, headers=headers, json=data, timeout=15) + # elif action == "create": + # response = requests.post(url, headers=headers, json=data, timeout=15) + # else: + # raise ValueError("Invalid action. Use 'create' or 'delete'.") + # except requests.exceptions.RequestException as e: + # logging.error(f"HTTP request failed: {e}") + # return {} + + # # Check and return the response + # if response.ok: + # return response.json() + # else: + # print(f"Request failed with status code {response.status_code}: {response.text}") + # response.raise_for_status() diff --git a/src/planner/planner.py b/src/planner/planner.py index c2613bd..b44dc4c 100644 --- a/src/planner/planner.py +++ b/src/planner/planner.py @@ -14,269 +14,23 @@ # 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 +import logging +from src.planner.energy_planner.energy import energy_planner +from src.planner.hrat_planner.hrat import hrat_planner +from src.planner.tfs_optical_planner.tfs_optical import tfs_optical_planner + class Planner: """ Planner class to compute the optimal path for a network slice based on energy consumption and topology. """ - def planner(self, intent): + def planner(self, intent, type): """ Plan the optimal path for a network slice based on energy consumption and topology. """ - energy_metrics = self.__retrieve_energy() - topology = self.__retrieve_topology() - source = intent.get("ietf-network-slice-service:network-slice-services", {}).get("slice-service", [])[0].get("sdps", {}).get("sdp", [])[0].get("id") or "A" - destination = intent.get("ietf-network-slice-service:network-slice-services", {}).get("slice-service", [])[0].get("sdps", {}).get("sdp", [])[1].get("id") or "B" - optimal_path = [] - # 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): - 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) - - # POST /sss/v1/slice/compute - def simulate_slice_output(input_data): - 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) - # Mostrar resultado - 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), - # Solo asigna los DLOS que existan, el resto a None - 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 = self.__calculate_optimal_path(topology, energy_metrics, source, destination, dlos) - - if not optimal_path: - logging.error("No valid path found") - raise Exception("No valid energy path found") - - return optimal_path - - def __retrieve_energy(self): - # TODO : Implement the logic to retrieve energy consumption data from controller - # Taking it from static file - with open(os.path.join(SRC_PATH, "planner/energy_ddbb.json"), "r") as archivo: - energy_metrics = json.load(archivo) - return energy_metrics - - def __retrieve_topology(self): - 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/ext_topo_ddbb.json"), "r") as archivo: - topology = json.load(archivo) - else: - # TODO : Implement the logic to retrieve topology data from controller - # Taking it from static file - with open(os.path.join(SRC_PATH, "planner/topo_ddbb.json"), "r") as archivo: - topology = json.load(archivo) - return topology - - - - def __calculate_optimal_path(self, topology, energy_metrics, source, destination, dlos): - 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 = self.__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 = [(0, source, [], 0, 0, 0, 1)] # (accumulated cost, current node, path, sum_ec, sum_ce, sum_ee, min_ure) - 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}") - - 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"Qeue -> 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(self, ec, ce, ee, ure, total_power_supply, total_power_boards, total_power_components, total_power_transceivers, alpha=1, beta=1, gamma=1, delta=1): - """ - Calcula el peso de un nodo con la fórmula: - w(v) = α·EC + β·CE + γ/EE + δ·(1 - URE) - """ - 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 - - + logging.info(f"Planner type selected: {type}") + if type == "ENERGY" : return energy_planner(intent) + elif type == "HRAT" : return hrat_planner(intent) + elif type == "TFS_OPTICAL": return tfs_optical_planner(intent, action = "create") + else : return None diff --git a/src/planner/tfs_optical_planner/tfs_optical.py b/src/planner/tfs_optical_planner/tfs_optical.py new file mode 100644 index 0000000..cebc61b --- /dev/null +++ b/src/planner/tfs_optical_planner/tfs_optical.py @@ -0,0 +1,254 @@ +# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This file is an original contribution from Telefonica Innovación Digital S.L. +import logging +import requests +import os +import uuid +import json +from src.config.constants import TEMPLATES_PATH + +def tfs_optical_planner(intent, action: str = "create") -> dict: + if action == 'delete': + logging.info("DELETE REQUEST RECEIVED: %s", intent) + with open(os.path.join(TEMPLATES_PATH, "slice.db"), 'r', encoding='utf-8') as file: + slices = json.load(file) + + for slice_obj in slices: + if 'slice_id' in slice_obj and slice_obj['slice_id'] == intent: + logging.info("Slice found: %s", slice_obj['slice_id']) + source = None + destination = None + services = slice_obj['intent']['ietf-network-slice-service:network-slice-services']['slice-service'] + for service in services: + c_groups = service.get("connection-groups", {}).get("connection-group", []) + for cg in c_groups: + constructs = cg.get("connectivity-construct", []) + for construct in constructs: + if "p2mp-sdp" in construct: + source = construct["p2mp-sdp"]["root-sdp-id"] + destination = construct["p2mp-sdp"]["leaf-sdp-id"] + break + if source and destination: + break + response = send_request(source, destination) + summary = { + "source": source, + "destination": destination, + "connectivity-service": response + } + rules = generate_rules(summary, intent, action) + else: + services = intent["ietf-network-slice-service:network-slice-services"]["slice-service"] + source = None + destination = None + for service in services: + c_groups = service.get("connection-groups", {}).get("connection-group", []) + for cg in c_groups: + constructs = cg.get("connectivity-construct", []) + for construct in constructs: + if "p2mp-sdp" in construct: + source = construct["p2mp-sdp"]["root-sdp-id"] + destination = construct["p2mp-sdp"]["leaf-sdp-id"] + break + if source and destination: + break + + response = send_request(source, destination) + + summary = { + "source": source, + "destination": destination, + "connectivity-service": response + } + logging.info(summary) + rules = generate_rules(summary, intent,action) + return rules + +def send_request(source, destination): + url = "http://10.30.7.66:31060/OpticalTFS/restconf/operations/tapi-path-computation:compute-p2mp" + + headers = { + "Content-Type": "application/json", + "Accept": "*/*" + } + + if isinstance(source, str): + sources_list = [source] + else: + sources_list = list(source) + + if isinstance(destination, str): + destinations_list = [destination] + else: + destinations_list = list(destination) + + payload = { + "sources": sources_list, + "destinations": destinations_list, + "bitrate": 100, + "bidirectional": True, + "band": 200, + "subcarriers_per_source": [4] * len(sources_list) + } + logging.info(f"Payload for path computation: {json.dumps(payload, indent=2)}") + + response = requests.post(url, headers=headers, data=json.dumps(payload)) + return json.loads(response.text) + +def group_block(group, action, group_id_override=None, node = None): + active = "true" if action == 'create' else "false" + group_id = group_id_override if group_id_override is not None else group["digital_sub_carriers_group_id"] + if node == "leaf": + return { + "digital_sub_carriers_group_id": group_id, + "digital_sub_carrier_id": [ + {'sub_carrier_id': 1, 'active': active}, + {'sub_carrier_id': 2, 'active': active}, + {'sub_carrier_id': 3, 'active': active}, + {'sub_carrier_id': 4, 'active': active} + ] + } + else: + return { + "digital_sub_carriers_group_id": group_id, + "digital_sub_carrier_id": [ + { + "sub_carrier_id": sid, + "active": active, + } + for sid in group["subcarrier-id"] + ] + } + +def generate_rules(connectivity_service, intent, action): + src_name = connectivity_service.get("source", "FALTA VALOR") + dest_list = connectivity_service.get("destination", ["FALTA VALOR"]) + dest_str = ",".join(dest_list) + config_rules = [] + + network_slice_uuid_str = f"{src_name}_to_{dest_str}" + tunnel_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, network_slice_uuid_str)) + provisionamiento = { + "network-slice-uuid": network_slice_uuid_str, + "viability": True, + "actions": [] + } + + attributes = connectivity_service["connectivity-service"]["tapi-connectivity:connectivity-service"]["connection"][0]["optical-connection-attributes"] + groups = attributes["subcarrier-attributes"]["digital-subcarrier-group"] + operational_mode = attributes["modulation"]["operational-mode"] + hub_groups = [ + group_block(group, action, group_id_override=index + 1) + for index, group in enumerate(groups) + ] + hub = { + "name": "channel-1", + "frequency": 195000000, + "target_output_power": 0, + "operational_mode": operational_mode, + "operation" : "merge", + "digital_sub_carriers_group": hub_groups + } + + leaves = [] + for dest, group in zip(connectivity_service["destination"], groups): + if dest == "T1.1": + name = "channel-1" + freq = 195006250 + if dest == "T1.2": + name = "channel-3" + freq = 195018750 + if dest == "T1.3": + name = "channel-5" + freq = 195031250 + leaf = { + "name": name, + "frequency": freq, + "target_output_power": group["Tx-power"], + "operational_mode": int(group["operational-mode"]), + "operation" : "merge", + "digital_sub_carriers_group": [group_block(group, action, group_id_override=1, node = "leaf")] + } + + leaves.append(leaf) + + final_json = {"components": [hub] + leaves} + if action == 'create': + provisionamiento["actions"].append({ + "type": "XR_AGENT_ACTIVATE_TRANSCEIVER", + "layer": "OPTICAL", + "content": final_json, + "controller-uuid": "IPoWDM Controller" + }) + + nodes = {} + sdp_list = intent['ietf-network-slice-service:network-slice-services']['slice-service'][0]['sdps']['sdp'] + + for sdp in sdp_list: + node = sdp['node-id'] + attachments = sdp['attachment-circuits']['attachment-circuit'] + for ac in attachments: + ip = ac.get('ac-ipv4-address', None) + prefix = ac.get('ac-ipv4-prefix-length', None) + vlan = 500 + nodes[node] = { + "ip-address": ip, + "ip-mask": prefix, + "vlan-id": vlan + } + + provisionamiento["actions"].append({ + "type": "CONFIG_VPNL3", + "layer": "IP", + "content": { + "tunnel-uuid": tunnel_uuid, + "src-node-uuid": src_name, + "src-ip-address": nodes[src_name]["ip-address"], + "src-ip-mask": str(nodes[src_name]["ip-mask"]), + "src-vlan-id": nodes[src_name]["vlan-id"], + "dest1-node-uuid": dest_list[0], + "dest1-ip-address": nodes[dest_list[0]]["ip-address"], + "dest1-ip-mask": str(nodes[dest_list[0]]["ip-mask"]), + "dest1-vlan-id": nodes[dest_list[0]]["vlan-id"], + "dest2-node-uuid": dest_list[1], + "dest2-ip-address": nodes[dest_list[1]]["ip-address"], + "dest2-ip-mask": str(nodes[dest_list[1]]["ip-mask"]), + "dest2-vlan-id": nodes[dest_list[1]]["vlan-id"], + "dest3-node-uuid": dest_list[2], + "dest3-ip-address": nodes[dest_list[2]]["ip-address"], + "dest3-ip-mask": str(nodes[dest_list[2]]["ip-mask"]), + "dest3-vlan-id": nodes[dest_list[2]]["vlan-id"] + }, + "controller-uuid": "IP Controller" + }) + + config_rules.append(provisionamiento) + else: + nodes = [] + nodes.append(src_name) + for dst in dest_list: nodes.append(dst) + aux = tunnel_uuid + '-' + src_name + '-' + '-'.join(dest_list) + provisionamiento["actions"].append({ + "type": "DEACTIVATE_XR_AGENT_TRANSCEIVER", + "layer": "OPTICAL", + "content": final_json, + "controller-uuid": "IPoWDM Controller", + "uuid" : aux, + "nodes": nodes + }) + config_rules.append(provisionamiento) + + return config_rules diff --git a/src/realizer/e2e/e2e_connect.py b/src/realizer/e2e/e2e_connect.py new file mode 100644 index 0000000..ad100d3 --- /dev/null +++ b/src/realizer/e2e/e2e_connect.py @@ -0,0 +1,49 @@ +# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This file is an original contribution from Telefonica Innovación Digital S.L. + +import json +import logging +from flask import current_app +from src.utils.send_response import send_response + +def e2e_connect(requests, controller_ip): + for request in requests["services"]: + for service in request: + logging.info(f"DATOS A ENVIAR: {service}") + # user="admin" + # password="admin" + # token="" + # session = requests.Session() + # session.auth = (user, password) + # url=f'http://{controller_ip}/webui' + # response=session.get(url=url) + # for item in response.iter_lines(): + # if"csrf_token" in str(item): + # string=str(item).split(' 0: rules = rules[0] + actions = rules.get("actions", []) + + 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: raise ValueError("Cannot determine the realization way from rules") + way = selected_way + else: + way = safe_get(ietf_intent, ['ietf-network-slice-service:network-slice-services', 'slice-service', 0, 'service-tags', 'tag-type', 0, 'tag-type-value', 0]) + logging.info(f"Selected way: {way}") + request = select_way(controller=controller_type, way=way, ietf_intent=ietf_intent, response=response, rules = rules) return request diff --git a/src/realizer/select_way.py b/src/realizer/select_way.py index 110b18a..2b9d2da 100644 --- a/src/realizer/select_way.py +++ b/src/realizer/select_way.py @@ -16,9 +16,10 @@ import logging from .ixia.main import ixia -from .tfs.main import tfs +from .tfs.main import tfs +from .e2e.main import e2e -def select_way(controller=None, way=None, ietf_intent=None, response=None): +def select_way(controller=None, way=None, ietf_intent=None, response=None, rules = None): """ Determine the method of slice realization. @@ -43,6 +44,8 @@ def select_way(controller=None, way=None, ietf_intent=None, response=None): 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) else: logging.warning(f"Unsupported controller: {controller}. Defaulting to TFS realization.") realizing_request = tfs(ietf_intent, way, response) diff --git a/src/realizer/send_controller.py b/src/realizer/send_controller.py index 9bb81af..a59e7bb 100644 --- a/src/realizer/send_controller.py +++ b/src/realizer/send_controller.py @@ -18,9 +18,10 @@ import logging 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 def send_controller(controller_type, requests): - if current_app.config["DUMMY_MODE"]: + if current_app.config["DUMMY_MODE"]: return True if controller_type == "TFS": response = tfs_connect(requests, current_app.config["TFS_IP"]) @@ -28,4 +29,6 @@ def send_controller(controller_type, requests): 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"]) return response diff --git a/src/templates/IPoWDM_orchestrator.json b/src/templates/IPoWDM_orchestrator.json new file mode 100644 index 0000000..60eb0db --- /dev/null +++ b/src/templates/IPoWDM_orchestrator.json @@ -0,0 +1,31 @@ +{ + "services": [ + { + "service_id": { + "context_id": {"context_uuid": {"uuid": "admin"}}, + "service_uuid": {"uuid": "TAPI LSP"} + }, + "service_type": 12, + "service_status": {"service_status": 1}, + "service_endpoint_ids": [ + {"device_id": {"device_uuid": {"uuid": "TFS-OPTICAL"}},"endpoint_uuid": {"uuid": "mgmt"}}, + {"device_id": {"device_uuid": {"uuid": "TFS-PACKET"}},"endpoint_uuid": {"uuid": "mgmt"}} + + ], + "service_constraints": [], + + "service_config": {"config_rules": [ + {"action": 1, "ipowdm": { + "endpoint_id": { + "device_id": {"device_uuid": {"uuid": "TFS-PACKET"}}, + "endpoint_uuid": {"uuid": "mgmt"} + }, + "rule_set": { + "src" : [], + "dst" : [] + } + }} + ]} + } + ] +} \ No newline at end of file diff --git a/src/templates/Optical_slice.json b/src/templates/Optical_slice.json new file mode 100644 index 0000000..94c87fe --- /dev/null +++ b/src/templates/Optical_slice.json @@ -0,0 +1,28 @@ +{ + "tapi-common:context" : { + "name" : [ + { + "value" : "" + } + ], + "service-interface-point" : [ + { + "uuid" : "" + }, + { + "uuid" : "" + } + ], + "tapi-topology:topology-context" : { + "topology" : [ + { + "link" : [ + ], + "node" : [ + ] + } + ] + }, + "uuid" : "" + } +} diff --git a/src/templates/TAPI_service.json b/src/templates/TAPI_service.json new file mode 100644 index 0000000..1b09a04 --- /dev/null +++ b/src/templates/TAPI_service.json @@ -0,0 +1,43 @@ +{ + "services": [ + { + "service_id": { + "context_id": {"context_uuid": {"uuid": "admin"}}, + "service_uuid": {"uuid": "TAPI LSP"} + }, + "service_type": 11, + "service_status": {"service_status": 1}, + "service_endpoint_ids": [ + {"device_id": {"device_uuid": {"uuid": "TFS-OPTICAL"}},"endpoint_uuid": {"uuid": "mgmt"}}, + {"device_id": {"device_uuid": {"uuid": "TFS-PACKET"}},"endpoint_uuid": {"uuid": "mgmt"}} + + ], + "service_constraints": [], + + "service_config": {"config_rules": [ + {"action": 1, "tapi_lsp": { + "endpoint_id": { + "device_id": {"device_uuid": {"uuid": "TFS-OPTICAL"}}, + "endpoint_uuid": {"uuid": "mgmt"} + }, + "rule_set": { + "src": "", + "dst": "", + "uuid": "", + "bw": "", + "tenant_uuid": "", + "direction": "", + "layer_protocol_name": "", + "layer_protocol_qualifier": "", + "lower_frequency_mhz": "", + "upper_frequency_mhz": "", + "link_uuid_path": [ + ], + "granularity": "", + "grid_type": "" + } + }} + ]} + } + ] +} \ No newline at end of file diff --git a/src/utils/build_response.py b/src/utils/build_response.py index c013602..0f96cfa 100644 --- a/src/utils/build_response.py +++ b/src/utils/build_response.py @@ -14,48 +14,63 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. -def build_response(intent, response): - - id = intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"] - source = intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][0]["sdp-ip-address"] - destination = intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][1]["sdp-ip-address"] - vlan = intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][0]["service-match-criteria"]["match-criterion"][0]["value"] - - # Extract QoS Profile from intent - #QoSProfile = ietf_intent["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["id"] - - qos_requirements = [] - - # Populate response with QoS requirements and VLAN from intent - slo_policy = intent["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["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 - }) +def build_response(intent, response, controller_type = None): - # Availability - if "availability" in slo_policy: - qos_requirements.append({ - "constraint_type": "availability[%]", - "constraint_value": str(slo_policy["availability"]) - }) + if controller_type == "E2E": + id = intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"] + source = intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][0]["id"] + destination = intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][1]["id"] + vlan = None + qos_requirements = [] + + response.append({ + "id": id, + "source": source, + "destination": destination, + "vlan": vlan, + "requirements": qos_requirements, + }) + else: + id = intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"] + source = intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][0]["sdp-ip-address"] + destination = intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][1]["sdp-ip-address"] + vlan = intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][0]["service-match-criteria"]["match-criterion"][0]["value"] + + # Extract QoS Profile from intent + #QoSProfile = ietf_intent["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["id"] + + qos_requirements = [] + + # Populate response with QoS requirements and VLAN from intent + slo_policy = intent["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["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"]) + # 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, }) - response.append({ - "id": id, - "source": source, - "destination": destination, - "vlan": vlan, - "requirements": qos_requirements, - }) return response \ No newline at end of file diff --git a/swagger/E2E_namespace.py b/swagger/E2E_namespace.py new file mode 100644 index 0000000..86c9915 --- /dev/null +++ b/swagger/E2E_namespace.py @@ -0,0 +1,152 @@ +# Copyright 2022-2025 ETSI SDG TeraFlowSDN (E2E) (https://E2E.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 flask import request +from flask_restx import Namespace, Resource, reqparse +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 + +e2e_ns = Namespace( + "E2E", + 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) + +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") + +# 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.") + @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""" + 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) + @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 + 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.") + @e2e_ns.response(204, "All transport network slices deleted successfully.") + @e2e_ns.response(500, "Internal server error") + def delete(self): + """Delete all slices""" + controller = NSController(controller_type="E2E") + data, code = Api(controller).delete_flows() + return data, code + + +@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") + @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""" + controller = NSController(controller_type="E2E") + data, code = Api(controller).get_flows(slice_id) + return data, code + + @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""" + controller = NSController(controller_type="E2E") + data, code = Api(controller).delete_flows(slice_id) + return data, code + + @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.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""" + json_data = request.get_json() + controller = NSController(controller_type="E2E") + data, code = Api(controller).modify_flow(slice_id, json_data) + return data, code -- GitLab From 9e160aa9b92316fcabe890c967722c152c617bf0 Mon Sep 17 00:00:00 2001 From: velazquez Date: Tue, 14 Oct 2025 12:45:20 +0200 Subject: [PATCH 10/26] General Slices are stored only when the request is processed, to avoid cases where slice is stored but the request is not fully processed Add new error (RuntimeError) to handle cases were the request is processed but no service is created Change some error raised to be consistent in the whole app Create flags to select the IP of HRAT and optical planner IPs Update env.example with new flags Change build_response to be the same for every request Planner Both HRAT and optical planner are configured through ip in the config Add exception handling in HRAT and optical planner to avoid errors Add "allowed_ids" variable in energy planner to handle only requests involving specific ids, to avoid errors in energy planning Realizer Change e2e realizer to handle cases with no rules avoiding errors Change e2e_connect to use the tfs_connector class --- src/api/main.py | 3 + src/config/.env.example | 11 +++ src/config/config.py | 6 +- src/main.py | 13 ++- src/mapper/main.py | 3 +- src/planner/energy_planner/energy.py | 38 ++++---- .../{ => energy_planner}/energy_ddbb.json | 0 .../{ => energy_planner}/ext_topo_ddbb.json | 0 .../{ => energy_planner}/topo_ddbb.json | 0 src/planner/hrat_planner/hrat.py | 75 ++++++++------- src/planner/planner.py | 5 +- .../tfs_optical_planner/tfs_optical.py | 52 +++++----- src/realizer/e2e/e2e_connect.py | 34 +------ src/realizer/e2e/main.py | 2 +- .../e2e/service_types/l3ipowdm_slice.py | 14 +-- src/realizer/main.py | 6 +- src/realizer/send_controller.py | 1 + src/realizer/tfs/service_types/tfs_l2vpn.py | 13 ++- src/realizer/tfs/service_types/tfs_l3vpn.py | 13 ++- src/utils/build_response.py | 94 ++++++++----------- swagger/E2E_namespace.py | 1 + swagger/ixia_namespace.py | 1 + swagger/tfs_namespace.py | 1 + 23 files changed, 204 insertions(+), 182 deletions(-) rename src/planner/{ => energy_planner}/energy_ddbb.json (100%) rename src/planner/{ => energy_planner}/ext_topo_ddbb.json (100%) rename src/planner/{ => energy_planner}/topo_ddbb.json (100%) diff --git a/src/api/main.py b/src/api/main.py index 344171c..441bed0 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -52,6 +52,9 @@ class Api: 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)) diff --git a/src/config/.env.example b/src/config/.env.example index 4037615..784d74e 100644 --- a/src/config/.env.example +++ b/src/config/.env.example @@ -29,6 +29,12 @@ NRP_ENABLED=false PLANNER_ENABLED=true # Flag to determine if external PCE is used PCE_EXTERNAL=false +# Type of planner to be used. Options: ENERGY, HRAT, TFS_OPTICAL +PLANNER_TYPE=ENERGY +# HRAT +HRAT_IP=10.0.0.1 +# TFS_OPTICAL +OPTICAL_PLANNER_IP=10.0.0.1 # ------------------------- # Realizer @@ -49,6 +55,11 @@ TFS_L2VPN_SUPPORT=false # ------------------------- IXIA_IP=127.0.0.1 +# ------------------------- +# E2E Controller +# ------------------------- +TFS_E2E_IP=127.0.0.1 + # ------------------------- # WebUI # ------------------------- diff --git a/src/config/config.py b/src/config/config.py index c7ac302..04b72aa 100644 --- a/src/config/config.py +++ b/src/config/config.py @@ -44,6 +44,8 @@ def create_config(app: Flask): app.config["PLANNER_ENABLED"] = os.getenv("PLANNER_ENABLED", "false").lower() == "true" app.config["PLANNER_TYPE"] = os.getenv("PLANNER_TYPE", "ENERGY") app.config["PCE_EXTERNAL"] = os.getenv("PCE_EXTERNAL", "false").lower() == "true" + app.config["HRAT_IP"] = os.getenv("HRAT_IP", "192.168.1.143") + app.config["OPTICAL_PLANNER_IP"] = os.getenv("OPTICAL_PLANNER_IP", "10.30.7.66") # Realizer app.config["DUMMY_MODE"] = os.getenv("DUMMY_MODE", "true").lower() == "true" @@ -52,11 +54,13 @@ def create_config(app: Flask): 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_E2E"] = os.getenv("TFS_E2E", "127.0.0.1") # IXIA app.config["IXIA_IP"] = os.getenv("IXIA_IP", "127.0.0.1") + # E2E Controller + 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" diff --git a/src/main.py b/src/main.py index 6a90dc5..5354c38 100644 --- a/src/main.py +++ b/src/main.py @@ -89,6 +89,7 @@ class NSController: # Reset requests requests = {"services":[]} + response = None # Process intent (translate if 3GPP) ietf_intents = nbi_processor(intent_json) @@ -96,16 +97,22 @@ class NSController: for intent in ietf_intents: # Mapper rules = mapper(intent) - # Store slice request details and build response - store_data(intent, slice_id, controller_type=self.controller_type) + # Build response self.response = build_response(intent, self.response, controller_type= self.controller_type) # Realizer request = realizer(intent, controller_type=self.controller_type, response = self.response, rules = rules) - requests["services"].append(request) + # Store slice request details + if request: + requests["services"].append(request) + store_data(intent, slice_id, controller_type=self.controller_type) # Store the generated template for debugging dump_templates(intent_json, ietf_intents, requests) + # Check if there are services to process + if not requests.get("services"): + raise RuntimeError("No service to process.") + # Send config to controllers response = send_controller(self.controller_type, requests) diff --git a/src/mapper/main.py b/src/mapper/main.py index baeae47..27a9ef4 100644 --- a/src/mapper/main.py +++ b/src/mapper/main.py @@ -65,7 +65,8 @@ def mapper(ietf_intent): # Request the controller to create a new NRP that meets the SLOs answer = realizer(ietf_intent, True, "CREATE", best_nrp) if not answer: - raise Exception("Slice rejected due to lack of NRPs") + 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"]: diff --git a/src/planner/energy_planner/energy.py b/src/planner/energy_planner/energy.py index aee53bc..c44a210 100644 --- a/src/planner/energy_planner/energy.py +++ b/src/planner/energy_planner/energy.py @@ -17,14 +17,20 @@ 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(self, intent): - energy_metrics = self.__retrieve_energy() - topology = self.__retrieve_topology() - source = intent.get("ietf-network-slice-service:network-slice-services", {}).get("slice-service", [])[0].get("sdps", {}).get("sdp", [])[0].get("id") or "A" - destination = intent.get("ietf-network-slice-service:network-slice-services", {}).get("slice-service", [])[0].get("sdps", {}).get("sdp", [])[1].get("id") or "B" +def energy_planner(intent): + 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") @@ -121,37 +127,37 @@ def energy_planner(self, intent): "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 = self.__calculate_optimal_path(topology, energy_metrics, source, destination, dlos) + optimal_path = calculate_optimal_path(topology, energy_metrics, source, destination, dlos) if not optimal_path: - logging.error("No valid path found") - raise Exception("No valid energy path found") + logging.error("No valid energy path found") + return None return optimal_path -def __retrieve_energy(self): +def retrieve_energy(): # TODO : Implement the logic to retrieve energy consumption data from controller # Taking it from static file - with open(os.path.join(SRC_PATH, "planner/energy_ddbb.json"), "r") as archivo: + 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(self): +def retrieve_topology(): 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/ext_topo_ddbb.json"), "r") as archivo: + 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 # Taking it from static file - with open(os.path.join(SRC_PATH, "planner/topo_ddbb.json"), "r") as archivo: + 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(self, topology, energy_metrics, source, destination, dlos): +def calculate_optimal_path(topology, energy_metrics, source, destination, dlos): logging.debug("Starting optimal path calculation...") # Create a dictionary with the weights of each node @@ -171,7 +177,7 @@ def __calculate_optimal_path(self, topology, energy_metrics, source, destination 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 = self.__compute_node_weight(ec, ce, ee, ure, + weight = compute_node_weight(ec, ce, ee, ure, total_power_supply, total_power_boards, total_power_components, @@ -254,7 +260,7 @@ def __calculate_optimal_path(self, topology, energy_metrics, source, destination return [] -def __compute_node_weight(self, ec, ce, ee, ure, total_power_supply, total_power_boards, total_power_components, total_power_transceivers, alpha=1, beta=1, gamma=1, delta=1): +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): """ Calcula el peso de un nodo con la fórmula: w(v) = α·EC + β·CE + γ/EE + δ·(1 - URE) diff --git a/src/planner/energy_ddbb.json b/src/planner/energy_planner/energy_ddbb.json similarity index 100% rename from src/planner/energy_ddbb.json rename to src/planner/energy_planner/energy_ddbb.json diff --git a/src/planner/ext_topo_ddbb.json b/src/planner/energy_planner/ext_topo_ddbb.json similarity index 100% rename from src/planner/ext_topo_ddbb.json rename to src/planner/energy_planner/ext_topo_ddbb.json diff --git a/src/planner/topo_ddbb.json b/src/planner/energy_planner/topo_ddbb.json similarity index 100% rename from src/planner/topo_ddbb.json rename to src/planner/energy_planner/topo_ddbb.json diff --git a/src/planner/hrat_planner/hrat.py b/src/planner/hrat_planner/hrat.py index c03560e..363d8fe 100644 --- a/src/planner/hrat_planner/hrat.py +++ b/src/planner/hrat_planner/hrat.py @@ -14,39 +14,42 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. -import logging -import requests - -def hrat_planner(data: str, action: str = "create") -> dict: - - data = {'network-slice-uuid': 'ecoc25-short-path-a7764e55-9bdb-4e38-9386-02ff47a33225', 'viability': True, 'actions': [{'type': 'CREATE_OPTICAL_SLICE', 'layer': 'OPTICAL', 'content': {'tenant-uuid': 'ea4ade23-1444-4f93-aabc-4fcbe2ae74dd', 'service-interface-point': [{'uuid': 'e7444187-119b-5b2e-8a60-ee26b30c441a'}, {'uuid': 'b32b1623-1f64-59d2-8148-b035a8f77625'}], 'node': [{'uuid': '68eb48ac-b686-5653-bdaf-7ccaeecd0709', 'owned-node-edge-point': [{'uuid': '7fd74b80-2b5a-55e2-8ef7-82bf589c9591', 'media-channel-node-edge-point-spec': {'mc-pool': {'supportable-spectrum': [{'lower-frequency': '191325000', 'upper-frequency': '192225000'}, {'lower-frequency': '194325000', 'upper-frequency': '195225000'}]}}}, {'uuid': '7b9f0b65-2387-5352-bc36-7173639463f0', 'media-channel-node-edge-point-spec': {'mc-pool': {'supportable-spectrum': [{'lower-frequency': '191325000', 'upper-frequency': '192225000'}, {'lower-frequency': '194325000', 'upper-frequency': '195225000'}]}}}]}, {'uuid': 'f55351ce-a5c8-50a7-b506-95b40e08bce4', 'owned-node-edge-point': [{'uuid': 'da6d924d-9cb4-5add-817d-f83e910beb2e', 'media-channel-node-edge-point-spec': {'mc-pool': {'supportable-spectrum': [{'lower-frequency': '191325000', 'upper-frequency': '192225000'}, {'lower-frequency': '194325000', 'upper-frequency': '195225000'}]}}}, {'uuid': '577ec899-ad92-5a19-a140-405a3cdbaa17', 'media-channel-node-edge-point-spec': {'mc-pool': {'supportable-spectrum': [{'lower-frequency': '191325000', 'upper-frequency': '192225000'}, {'lower-frequency': '194325000', 'upper-frequency': '195225000'}]}}}]}], 'link': [{'uuid': '3beef785-bb26-5741-af10-c5e1838c1701'}, {'uuid': '6144c664-246a-58ed-bf0a-7ec4286625da'}]}, 'controller-uuid': 'TAPI Optical Controller'}, {'type': 'PROVISION_MEDIA_CHANNEL_OLS_PATH', 'layer': 'OPTICAL', 'content': {'ols-path-uuid': 'cfeae4cb-c305-4884-9945-8b0c0f040c98', 'src-sip-uuid': 'e7444187-119b-5b2e-8a60-ee26b30c441a', 'dest-sip-uuid': 'b32b1623-1f64-59d2-8148-b035a8f77625', 'direction': 'BIDIRECTIONAL', 'layer-protocol-name': 'PHOTONIC_MEDIA', 'layer-protocol-qualifier': 'tapi-photonic-media:PHOTONIC_LAYER_QUALIFIER_MC', 'bandwidth-ghz': 100, 'link-uuid-path': ['3beef785-bb26-5741-af10-c5e1838c1701'], 'lower-frequency-mhz': '194700000', 'upper-frequency-mhz': '194800000', 'adjustment-granularity': 'G_6_25GHZ', 'grid-type': 'FLEX'}, 'controller-uuid': 'TAPI Optical Controller', 'tenant-uuid': 'ea4ade23-1444-4f93-aabc-4fcbe2ae74dd'}, {'type': 'ACTIVATE_TRANSCEIVER', 'layer': 'OPTICAL', 'content': {'node-uuid': 'Phoenix-1', 'termination-point-uuid': 'Ethernet110', 'transceiver-type': 'CFP2', 'frequency-ghz': 194700.0, 'spectrum-width-ghz': 100.0, 'tx-power-dbm': 0.0}, 'controller-uuid': 'IP Controller'}, {'type': 'ACTIVATE_TRANSCEIVER', 'layer': 'OPTICAL', 'content': {'node-uuid': 'Phoenix-2', 'termination-point-uuid': 'Ethernet220', 'transceiver-type': 'CFP2', 'frequency-ghz': 194700.0, 'spectrum-width-ghz': 100.0, 'tx-power-dbm': 0.0}, 'controller-uuid': 'IP Controller'}, {'type': 'CONFIG_VPNL3', 'layer': 'IP', 'content': {'tunnel-uuid': '9aae851a-eea9-4a28-969f-0e2c2196e936', 'src-node-uuid': 'Phoenix-1', 'src-ip-address': '10.10.1.1', 'src-ip-mask': '/24', 'src-vlan-id': 100, 'dest-node-uuid': 'Phoenix-2', 'dest-ip-address': '10.10.2.1', 'dest-ip-mask': '/24', 'dest-vlan-id': 100}, 'controller-uuid': 'IP Controller'}]} - return data - - # url = 'http://192.168.1.143:9090/api/resource-allocation/transport-network-slice-l3' - # headers = {'Content-Type': 'application/json'} - # try: - # if action == "delete": - # data = { - # "ietf-network-slice-service:network-slice-services": { - # "slice-service": [ - # { - # "id": data - # } - # ] - # } - # } - # response = requests.delete(url, headers=headers, json=data, timeout=15) - # elif action == "create": - # response = requests.post(url, headers=headers, json=data, timeout=15) - # else: - # raise ValueError("Invalid action. Use 'create' or 'delete'.") - # except requests.exceptions.RequestException as e: - # logging.error(f"HTTP request failed: {e}") - # return {} - - # # Check and return the response - # if response.ok: - # return response.json() - # else: - # print(f"Request failed with status code {response.status_code}: {response.text}") - # response.raise_for_status() +import logging, requests + +def hrat_planner(data: str, ip: str, action: str = "create") -> dict: + + data_static = {'network-slice-uuid': 'ecoc25-short-path-a7764e55-9bdb-4e38-9386-02ff47a33225', 'viability': True, 'actions': [{'type': 'CREATE_OPTICAL_SLICE', 'layer': 'OPTICAL', 'content': {'tenant-uuid': 'ea4ade23-1444-4f93-aabc-4fcbe2ae74dd', 'service-interface-point': [{'uuid': 'e7444187-119b-5b2e-8a60-ee26b30c441a'}, {'uuid': 'b32b1623-1f64-59d2-8148-b035a8f77625'}], 'node': [{'uuid': '68eb48ac-b686-5653-bdaf-7ccaeecd0709', 'owned-node-edge-point': [{'uuid': '7fd74b80-2b5a-55e2-8ef7-82bf589c9591', 'media-channel-node-edge-point-spec': {'mc-pool': {'supportable-spectrum': [{'lower-frequency': '191325000', 'upper-frequency': '192225000'}, {'lower-frequency': '194325000', 'upper-frequency': '195225000'}]}}}, {'uuid': '7b9f0b65-2387-5352-bc36-7173639463f0', 'media-channel-node-edge-point-spec': {'mc-pool': {'supportable-spectrum': [{'lower-frequency': '191325000', 'upper-frequency': '192225000'}, {'lower-frequency': '194325000', 'upper-frequency': '195225000'}]}}}]}, {'uuid': 'f55351ce-a5c8-50a7-b506-95b40e08bce4', 'owned-node-edge-point': [{'uuid': 'da6d924d-9cb4-5add-817d-f83e910beb2e', 'media-channel-node-edge-point-spec': {'mc-pool': {'supportable-spectrum': [{'lower-frequency': '191325000', 'upper-frequency': '192225000'}, {'lower-frequency': '194325000', 'upper-frequency': '195225000'}]}}}, {'uuid': '577ec899-ad92-5a19-a140-405a3cdbaa17', 'media-channel-node-edge-point-spec': {'mc-pool': {'supportable-spectrum': [{'lower-frequency': '191325000', 'upper-frequency': '192225000'}, {'lower-frequency': '194325000', 'upper-frequency': '195225000'}]}}}]}], 'link': [{'uuid': '3beef785-bb26-5741-af10-c5e1838c1701'}, {'uuid': '6144c664-246a-58ed-bf0a-7ec4286625da'}]}, 'controller-uuid': 'TAPI Optical Controller'}, {'type': 'PROVISION_MEDIA_CHANNEL_OLS_PATH', 'layer': 'OPTICAL', 'content': {'ols-path-uuid': 'cfeae4cb-c305-4884-9945-8b0c0f040c98', 'src-sip-uuid': 'e7444187-119b-5b2e-8a60-ee26b30c441a', 'dest-sip-uuid': 'b32b1623-1f64-59d2-8148-b035a8f77625', 'direction': 'BIDIRECTIONAL', 'layer-protocol-name': 'PHOTONIC_MEDIA', 'layer-protocol-qualifier': 'tapi-photonic-media:PHOTONIC_LAYER_QUALIFIER_MC', 'bandwidth-ghz': 100, 'link-uuid-path': ['3beef785-bb26-5741-af10-c5e1838c1701'], 'lower-frequency-mhz': '194700000', 'upper-frequency-mhz': '194800000', 'adjustment-granularity': 'G_6_25GHZ', 'grid-type': 'FLEX'}, 'controller-uuid': 'TAPI Optical Controller', 'tenant-uuid': 'ea4ade23-1444-4f93-aabc-4fcbe2ae74dd'}, {'type': 'ACTIVATE_TRANSCEIVER', 'layer': 'OPTICAL', 'content': {'node-uuid': 'Phoenix-1', 'termination-point-uuid': 'Ethernet110', 'transceiver-type': 'CFP2', 'frequency-ghz': 194700.0, 'spectrum-width-ghz': 100.0, 'tx-power-dbm': 0.0}, 'controller-uuid': 'IP Controller'}, {'type': 'ACTIVATE_TRANSCEIVER', 'layer': 'OPTICAL', 'content': {'node-uuid': 'Phoenix-2', 'termination-point-uuid': 'Ethernet220', 'transceiver-type': 'CFP2', 'frequency-ghz': 194700.0, 'spectrum-width-ghz': 100.0, 'tx-power-dbm': 0.0}, 'controller-uuid': 'IP Controller'}, {'type': 'CONFIG_VPNL3', 'layer': 'IP', 'content': {'tunnel-uuid': '9aae851a-eea9-4a28-969f-0e2c2196e936', 'src-node-uuid': 'Phoenix-1', 'src-ip-address': '10.10.1.1', 'src-ip-mask': '/24', 'src-vlan-id': 100, 'dest-node-uuid': 'Phoenix-2', 'dest-ip-address': '10.10.2.1', 'dest-ip-mask': '/24', 'dest-vlan-id': 100}, 'controller-uuid': 'IP Controller'}]} + url = f'http://{ip}:9090/api/resource-allocation/transport-network-slice-l3' + headers = {'Content-Type': 'application/json'} + + try: + if action == "delete": + payload = { + "ietf-network-slice-service:network-slice-services": { + "slice-service": [ + { + "id": data + } + ] + } + } + response = requests.delete(url, headers=headers, json=payload, timeout=15) + elif action == "create": + response = requests.post(url, headers=headers, json=data, timeout=15) + else: + logging.error("Invalid action. Use 'create' or 'delete'.") + return data_static + + if response.ok: + return response.json() + else: + logging.error(f"Request failed with status code {response.status_code}: {response.text}") + return data_static + + except requests.exceptions.RequestException as e: + logging.error(f"HTTP request failed: {e}. Returning default data") + return data_static + except Exception as e: + logging.error(f"Unexpected error: {e}") + return data_static + diff --git a/src/planner/planner.py b/src/planner/planner.py index b44dc4c..b5ba22d 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.tfs_optical_planner.tfs_optical import tfs_optical_planner +from flask import current_app class Planner: @@ -31,6 +32,6 @@ class Planner: """ logging.info(f"Planner type selected: {type}") if type == "ENERGY" : return energy_planner(intent) - elif type == "HRAT" : return hrat_planner(intent) - elif type == "TFS_OPTICAL": return tfs_optical_planner(intent, action = "create") + elif type == "HRAT" : return hrat_planner(intent, current_app.config["HRAT_IP"]) + elif type == "TFS_OPTICAL": return tfs_optical_planner(intent, current_app.config["OPTICAL_PLANNER_IP"], action = "create") else : return None diff --git a/src/planner/tfs_optical_planner/tfs_optical.py b/src/planner/tfs_optical_planner/tfs_optical.py index cebc61b..41af023 100644 --- a/src/planner/tfs_optical_planner/tfs_optical.py +++ b/src/planner/tfs_optical_planner/tfs_optical.py @@ -19,16 +19,17 @@ import os import uuid import json from src.config.constants import TEMPLATES_PATH +from src.utils.safe_get import safe_get -def tfs_optical_planner(intent, action: str = "create") -> dict: +def tfs_optical_planner(intent, ip: str, action: str = "create") -> dict: if action == 'delete': - logging.info("DELETE REQUEST RECEIVED: %s", intent) + logging.debug("DELETE REQUEST RECEIVED: %s", intent) with open(os.path.join(TEMPLATES_PATH, "slice.db"), 'r', encoding='utf-8') as file: slices = json.load(file) for slice_obj in slices: if 'slice_id' in slice_obj and slice_obj['slice_id'] == intent: - logging.info("Slice found: %s", slice_obj['slice_id']) + logging.debug("Slice found: %s", slice_obj['slice_id']) source = None destination = None services = slice_obj['intent']['ietf-network-slice-service:network-slice-services']['slice-service'] @@ -59,26 +60,31 @@ def tfs_optical_planner(intent, action: str = "create") -> dict: for cg in c_groups: constructs = cg.get("connectivity-construct", []) for construct in constructs: - if "p2mp-sdp" in construct: - source = construct["p2mp-sdp"]["root-sdp-id"] - destination = construct["p2mp-sdp"]["leaf-sdp-id"] + source = safe_get(construct, ["p2mp-sdp", "root-sdp-id"]) + destination = safe_get(construct, ["p2mp-sdp", "leaf-sdp-id"]) + if source and destination: break if source and destination: break - - response = send_request(source, destination) - - summary = { - "source": source, - "destination": destination, - "connectivity-service": response - } - logging.info(summary) - rules = generate_rules(summary, intent,action) + response = None + if source and destination: + response = send_request(source, destination, ip) + if not response: + return None + summary = { + "source": source, + "destination": destination, + "connectivity-service": response + } + logging.debug(summary) + rules = generate_rules(summary, intent,action) + else: + logging.warning(f"No rules generated. Skipping optical planning.") + return None return rules -def send_request(source, destination): - url = "http://10.30.7.66:31060/OpticalTFS/restconf/operations/tapi-path-computation:compute-p2mp" +def send_request(source, destination, ip): + url = f"http://{ip}:31060/OpticalTFS/restconf/operations/tapi-path-computation:compute-p2mp" headers = { "Content-Type": "application/json", @@ -103,10 +109,14 @@ def send_request(source, destination): "band": 200, "subcarriers_per_source": [4] * len(sources_list) } - logging.info(f"Payload for path computation: {json.dumps(payload, indent=2)}") + logging.debug(f"Payload for path computation: {json.dumps(payload, indent=2)}") - response = requests.post(url, headers=headers, data=json.dumps(payload)) - return json.loads(response.text) + try: + response = requests.post(url, headers=headers, data=json.dumps(payload)) + return json.loads(response.text) + except requests.exceptions.RequestException: + logging.warning("Error connecting to the Optical Planner service. Skipping optical planning.") + return None def group_block(group, action, group_id_override=None, node = None): active = "true" if action == 'create' else "false" diff --git a/src/realizer/e2e/e2e_connect.py b/src/realizer/e2e/e2e_connect.py index ad100d3..b260dd5 100644 --- a/src/realizer/e2e/e2e_connect.py +++ b/src/realizer/e2e/e2e_connect.py @@ -14,36 +14,8 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. -import json -import logging -from flask import current_app -from src.utils.send_response import send_response +from ..tfs.helpers.tfs_connector import tfs_connector def e2e_connect(requests, controller_ip): - for request in requests["services"]: - for service in request: - logging.info(f"DATOS A ENVIAR: {service}") - # user="admin" - # password="admin" - # token="" - # session = requests.Session() - # session.auth = (user, password) - # url=f'http://{controller_ip}/webui' - # response=session.get(url=url) - # for item in response.iter_lines(): - # if"csrf_token" in str(item): - # string=str(item).split(' 0: rules = rules[0] - actions = rules.get("actions", []) + 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) @@ -66,7 +66,9 @@ def realizer(ietf_intent, need_nrp=False, order=None, nrp=None, controller_type= elif del_optical: selected_way = "DEL_OPTIC" elif del_l3: selected_way = "DEL_L3VPN" elif del_l2: selected_way = "DEL_L2VPN" - else: raise ValueError("Cannot determine the realization way from rules") + else: + logging.warning("Cannot determine the realization way from rules. Skipping request.") + return None way = selected_way else: way = safe_get(ietf_intent, ['ietf-network-slice-service:network-slice-services', 'slice-service', 0, 'service-tags', 'tag-type', 0, 'tag-type-value', 0]) diff --git a/src/realizer/send_controller.py b/src/realizer/send_controller.py index a59e7bb..01f9a95 100644 --- a/src/realizer/send_controller.py +++ b/src/realizer/send_controller.py @@ -31,4 +31,5 @@ def send_controller(controller_type, requests): logging.info("Requests sent to Ixia") elif controller_type == "E2E": response = e2e_connect(requests, current_app.config["TFS_E2E"]) + logging.info("Requests sent to Teraflow E2E") return response diff --git a/src/realizer/tfs/service_types/tfs_l2vpn.py b/src/realizer/tfs/service_types/tfs_l2vpn.py index ca190fb..2dab481 100644 --- a/src/realizer/tfs/service_types/tfs_l2vpn.py +++ b/src/realizer/tfs/service_types/tfs_l2vpn.py @@ -17,6 +17,7 @@ import logging, os from src.config.constants import TEMPLATES_PATH, NBI_L2_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 @@ -41,9 +42,15 @@ def tfs_l2vpn(ietf_intent, response): """ # 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_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.") + return None 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_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.") + 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) @@ -104,7 +111,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\n") + logging.info(f"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 ff8f081..3a1f179 100644 --- a/src/realizer/tfs/service_types/tfs_l3vpn.py +++ b/src/realizer/tfs/service_types/tfs_l3vpn.py @@ -17,6 +17,7 @@ import logging, os from src.config.constants import TEMPLATES_PATH, NBI_L3_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): @@ -39,9 +40,15 @@ def tfs_l3vpn(ietf_intent, response): """ # 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_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.") + return None 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_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.") + 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) @@ -128,6 +135,6 @@ def tfs_l3vpn(ietf_intent, response): access["service"]["svc-mtu"] = int(cvalue) - logging.info(f"L3VPN Intent realized\n") + logging.info(f"L3VPN Intent realized") #self.answer[self.subnet]["VLAN"] = vlan_value return tfs_request \ No newline at end of file diff --git a/src/utils/build_response.py b/src/utils/build_response.py index 0f96cfa..7d67c8b 100644 --- a/src/utils/build_response.py +++ b/src/utils/build_response.py @@ -14,63 +14,47 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. -def build_response(intent, response, controller_type = None): - - if controller_type == "E2E": - id = intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"] - source = intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][0]["id"] - destination = intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][1]["id"] - vlan = None - qos_requirements = [] - - response.append({ - "id": id, - "source": source, - "destination": destination, - "vlan": vlan, - "requirements": qos_requirements, - }) - else: - id = intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"] - source = intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][0]["sdp-ip-address"] - destination = intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][1]["sdp-ip-address"] - vlan = intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][0]["service-match-criteria"]["match-criterion"][0]["value"] - - # Extract QoS Profile from intent - #QoSProfile = ietf_intent["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["id"] - - qos_requirements = [] +from .safe_get import safe_get - # Populate response with QoS requirements and VLAN from intent - slo_policy = intent["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["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 - }) +def build_response(intent, response, controller_type = None): + """Build a structured response from the intent.""" + id = safe_get(intent, ["ietf-network-slice-service:network-slice-services","slice-service",0,"id"]) + source = safe_get(intent, ["ietf-network-slice-service:network-slice-services","slice-service",0,"sdps","sdp",0,"id"]) + destination = safe_get(intent, ["ietf-network-slice-service:network-slice-services","slice-service",0,"sdps","sdp",1,"id"]) + vlan = safe_get(intent, ["ietf-network-slice-service:network-slice-services","slice-service",0,"sdps","sdp",0,"service-match-criteria","match-criterion",0,"value"]) + + qos_requirements = [] + + # Populate response with QoS requirements and VLAN from intent + slo_policy = safe_get(intent, ["ietf-network-slice-service:network-slice-services","slo-sle-templates","slo-sle-template",0,"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"]) - }) + # 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, + # 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 diff --git a/swagger/E2E_namespace.py b/swagger/E2E_namespace.py index 86c9915..a53cd0d 100644 --- a/swagger/E2E_namespace.py +++ b/swagger/E2E_namespace.py @@ -53,6 +53,7 @@ class E2ESliceList(Resource): @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) diff --git a/swagger/ixia_namespace.py b/swagger/ixia_namespace.py index 3c16be1..e8f4ac9 100644 --- a/swagger/ixia_namespace.py +++ b/swagger/ixia_namespace.py @@ -52,6 +52,7 @@ class IxiaSliceList(Resource): @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) diff --git a/swagger/tfs_namespace.py b/swagger/tfs_namespace.py index 208da18..0916360 100644 --- a/swagger/tfs_namespace.py +++ b/swagger/tfs_namespace.py @@ -52,6 +52,7 @@ class TfsSliceList(Resource): @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) -- GitLab From d02c1886679afa1026e6590d186cd9759e4b52b1 Mon Sep 17 00:00:00 2001 From: velazquez Date: Tue, 14 Oct 2025 12:51:57 +0200 Subject: [PATCH 11/26] Add timeout to post requests --- src/planner/tfs_optical_planner/tfs_optical.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/planner/tfs_optical_planner/tfs_optical.py b/src/planner/tfs_optical_planner/tfs_optical.py index 41af023..c8034cf 100644 --- a/src/planner/tfs_optical_planner/tfs_optical.py +++ b/src/planner/tfs_optical_planner/tfs_optical.py @@ -112,7 +112,7 @@ def send_request(source, destination, ip): logging.debug(f"Payload for path computation: {json.dumps(payload, indent=2)}") try: - response = requests.post(url, headers=headers, data=json.dumps(payload)) + response = requests.post(url, headers=headers, data=json.dumps(payload), timeout=15) return json.loads(response.text) except requests.exceptions.RequestException: logging.warning("Error connecting to the Optical Planner service. Skipping optical planning.") -- GitLab From ab62dcf944416a5827fe3ce0d5e45f7c601d20f1 Mon Sep 17 00:00:00 2001 From: velazquez Date: Thu, 16 Oct 2025 10:50:20 +0200 Subject: [PATCH 12/26] Create dockerfile to build the nsc image Create deploy script to build and run the nsc container Add docker ignore file --- .dockerignore | 5 ++++ Dockerfile | 38 ++++++++++++++++++++++++++ deploy.sh | 60 +++++++++++++++++++++++++++++++++++++++++ requirements.txt | 16 +++++++++++ src/config/.env.example | 7 +++-- src/config/constants.py | 3 ++- 6 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 deploy.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b895f09 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +*.db +.env +.git/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1d520d5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This file is an original contribution from Telefonica Innovación Digital S.L. + +FROM python:3.12-slim + +# Establece el directorio de trabajo +WORKDIR /app + +# Instala dependencias del sistema +RUN apt-get update -qq && \ + apt-get install -y -qq git python3-dev && \ + apt-get clean && rm -rf /var/lib/apt/lists/* + +# Copia el contenido del proyecto +COPY . /app + +# Instala dependencias de Python +RUN pip install --upgrade pip && \ + pip install -r requirements.txt + +# Expone el puerto +EXPOSE 8081 + +# Comando de inicio +ENTRYPOINT ["python3", "app.py"] diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..8bd99bf --- /dev/null +++ b/deploy.sh @@ -0,0 +1,60 @@ +#!/bin/bash + +# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This file is an original contribution from Telefonica Innovación Digital S.L. + +# Container name +CONTAINER_NAME=nsc + +# Verify if docker is active +if ! docker info > /dev/null 2>&1; then + echo "Error: Docker not running. Please, restart Docker service and try again." + exit 1 +fi + +# Stop container if running +echo "Verify if '$CONTAINER_NAME' is running..." +if [ $(docker ps -q -f name=$CONTAINER_NAME) ]; then + echo "Stopping current container '$CONTAINER_NAME'..." + docker stop $CONTAINER_NAME +fi + +# Cleaning residual containers and images +echo "Cleaning old Docker containers and images..." +docker container prune -f +docker image prune -f + +# Verificar que .env.example existe +if [ ! -f src/config/.env.example ]; then + echo "Error: .env.example not found" + exit 1 +fi + +# Copy .env.example to .env +echo "Generating .env file..." +cp src/config/.env.example .env + +# Read NSC_PORT from .env +NSC_PORT=$(grep '^NSC_PORT=' .env | cut -d '=' -f2) + +# Docker build +echo "Building docker image..." +docker build -t nsc . + +# Executing nsc +echo "Running nsc on port $NSC_PORT..." +docker run -d --env-file .env -p $NSC_PORT:$NSC_PORT --name $CONTAINER_NAME $CONTAINER_NAME +echo "---READY---" diff --git a/requirements.txt b/requirements.txt index 8e50394..1622628 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,19 @@ +# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This file is an original contribution from Telefonica Innovación Digital S.L. + Flask flask-cors flask-restx diff --git a/src/config/.env.example b/src/config/.env.example index 784d74e..fb565b7 100644 --- a/src/config/.env.example +++ b/src/config/.env.example @@ -17,7 +17,9 @@ # ------------------------- # General # ------------------------- -LOGGING_LEVEL=INFO # Options: CRITICAL, ERROR, WARNING, INFO, DEBUG, NOTSET +NSC_PORT=8081 +# Options: CRITICAL, ERROR, WARNING, INFO, DEBUG, NOTSET +LOGGING_LEVEL=INFO DUMP_TEMPLATES=false # ------------------------- @@ -46,7 +48,8 @@ DUMMY_MODE=true # Teraflow # ------------------------- TFS_IP=127.0.0.1 -UPLOAD_TYPE=WEBUI # Options: WEBUI o NBI +# Options: WEBUI or NBI +UPLOAD_TYPE=WEBUI # Flag to determine if additional L2VPN configuration support is required for deploying L2VPNs with path selection TFS_L2VPN_SUPPORT=false diff --git a/src/config/constants.py b/src/config/constants.py index 5f5bb85..b3d6b87 100644 --- a/src/config/constants.py +++ b/src/config/constants.py @@ -15,9 +15,10 @@ # This file includes original contributions from Telefonica Innovación Digital S.L. from pathlib import Path +import os # Default port for NSC deployment -NSC_PORT = 8081 +NSC_PORT = os.getenv("NSC_PORT", "8081") # Paths BASE_DIR = Path(__file__).resolve().parent.parent.parent -- GitLab From 130ac23bfdfdf8c1e80c6bcd59b88df16c516a2b Mon Sep 17 00:00:00 2001 From: velazquez Date: Thu, 16 Oct 2025 10:59:00 +0200 Subject: [PATCH 13/26] Add script to show NSC logs --- scripts/show_logs_nsc.sh | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 scripts/show_logs_nsc.sh diff --git a/scripts/show_logs_nsc.sh b/scripts/show_logs_nsc.sh new file mode 100644 index 0000000..53a02fc --- /dev/null +++ b/scripts/show_logs_nsc.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This file is an original contribution from Telefonica Innovación Digital S.L. + +docker logs nsc \ No newline at end of file -- GitLab From 7c9f869a899bcc818901ab2bde36cd6c0a371342 Mon Sep 17 00:00:00 2001 From: velazquez Date: Mon, 20 Oct 2025 09:41:32 +0200 Subject: [PATCH 14/26] - Add gitlab-ci file - Add tests for api, database, nbi_processor, mapper, utils - Add e2e integration tests - Add initialization tests - Correct minor bugs - Update documentation --- .gitlab-ci.yml | 17 + README.md | 21 +- src/api/main.py | 3 + src/mapper/slo_viability.py | 1 + src/planner/hrat_planner/hrat.py | 4 +- .../tfs_optical_planner/tfs_optical.py | 2 +- src/tests/requests/3ggpp_template_green.json | 176 +++++ .../3gpp_template_UC1PoC2_backhaul.json | 267 ++++++++ ...p_template_UC1PoC2_backhaul_request_1.json | 131 ++++ ...p_template_UC1PoC2_backhaul_request_2.json | 131 ++++ ...p_template_UC1PoC2_backhaul_request_3.json | 131 ++++ .../3gpp_template_UC1PoC2_midhaul.json | 267 ++++++++ src/tests/requests/P2MP.json | 108 +++ src/tests/requests/create_slice_1.json | 81 +++ src/tests/requests/ietf_green_request.json | 172 +++++ src/tests/requests/l3vpn_test.json | 164 +++++ src/tests/requests/slice_request.json | 162 +++++ src/tests/test_api.py | 301 +++++++++ src/tests/test_database.py | 585 ++++++++++++++++ src/tests/test_e2e.py | 101 +++ src/tests/test_initialization.py | 37 + src/tests/test_mapper.py | 639 ++++++++++++++++++ src/tests/test_nbi_processor.py | 222 ++++++ src/tests/test_utils.py | 182 +++++ 24 files changed, 3891 insertions(+), 14 deletions(-) create mode 100644 .gitlab-ci.yml create mode 100644 src/tests/requests/3ggpp_template_green.json create mode 100644 src/tests/requests/3gpp_template_UC1PoC2_backhaul.json create mode 100644 src/tests/requests/3gpp_template_UC1PoC2_backhaul_request_1.json create mode 100644 src/tests/requests/3gpp_template_UC1PoC2_backhaul_request_2.json create mode 100644 src/tests/requests/3gpp_template_UC1PoC2_backhaul_request_3.json create mode 100644 src/tests/requests/3gpp_template_UC1PoC2_midhaul.json create mode 100644 src/tests/requests/P2MP.json create mode 100644 src/tests/requests/create_slice_1.json create mode 100644 src/tests/requests/ietf_green_request.json create mode 100644 src/tests/requests/l3vpn_test.json create mode 100644 src/tests/requests/slice_request.json create mode 100644 src/tests/test_api.py create mode 100644 src/tests/test_database.py create mode 100644 src/tests/test_e2e.py create mode 100644 src/tests/test_initialization.py create mode 100644 src/tests/test_mapper.py create mode 100644 src/tests/test_nbi_processor.py create mode 100644 src/tests/test_utils.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..edd18a7 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,17 @@ +image: python:3.12 + +stages: + - build + - test + +before_script: + - pip3 install -r requirements.txt + +build: + stage: build + script: + - python3 app.py +test: + stage: test + script: + - python3 -m pytest \ No newline at end of file diff --git a/README.md b/README.md index 626f670..cf881f8 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ It is accessed at `{ip}:{NSC_PORT}/webui` ## Configuration -In the `.env` file, several constants can be adjusted to customize the Network Slice Controller (NSC) behavior: +In the `src/config/.env.example` file, several constants can be adjusted to customize the Network Slice Controller (NSC) behaviour: ### Logging - `DEFAULT_LOGGING_LEVEL`: Sets logging verbosity @@ -133,6 +133,13 @@ In the `.env` file, several constants can be adjusted to customize the Network S - Default: `false` - `PCE_EXTERNAL`: Flag to determine if external PCE is used - Default: `false` +- `PLANNER_TYPE`: Type of planner to be used + - Default: `ENERGY` + - Options: `ENERGY`, `HRAT`, `TFS_OPTICAL` +- `HRAT_IP`: HRAT planner IP + - Default: `10.0.0.1` +- `OPTICAL_PLANNER_IP`: Optical planner IP + - Default: `10.0.0.1` ## Realizer - `DUMMY_MODE`: If true, no config sent to controllers @@ -159,19 +166,11 @@ In the `.env` file, several constants can be adjusted to customize the Network S To deploy and execute the NSC, follow these steps: -0. **Preparation** +1. **Deploy** ``` git clone https://labs.etsi.org/rep/tfs/nsc.git cd nsc - python3 -m venv venv - source venv/bin/activate - pip install -r requirements.txt - cp ./src/config/.env.example ./.env - ``` - -1. **Start NSC Server**: - ``` - python3 app.py + ./deploy.sh ``` 2. **Send Slice Requests**: diff --git a/src/api/main.py b/src/api/main.py index 441bed0..4a320ec 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -132,6 +132,9 @@ class Api: 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)) diff --git a/src/mapper/slo_viability.py b/src/mapper/slo_viability.py index 92b215a..a91b929 100644 --- a/src/mapper/slo_viability.py +++ b/src/mapper/slo_viability.py @@ -39,6 +39,7 @@ def slo_viability(slice_slos, nrp_slos): "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']: diff --git a/src/planner/hrat_planner/hrat.py b/src/planner/hrat_planner/hrat.py index 363d8fe..5467372 100644 --- a/src/planner/hrat_planner/hrat.py +++ b/src/planner/hrat_planner/hrat.py @@ -33,9 +33,9 @@ def hrat_planner(data: str, ip: str, action: str = "create") -> dict: ] } } - response = requests.delete(url, headers=headers, json=payload, timeout=15) + response = requests.delete(url, headers=headers, json=payload, timeout=1) elif action == "create": - response = requests.post(url, headers=headers, json=data, timeout=15) + response = requests.post(url, headers=headers, json=data, timeout=1) else: logging.error("Invalid action. Use 'create' or 'delete'.") return data_static diff --git a/src/planner/tfs_optical_planner/tfs_optical.py b/src/planner/tfs_optical_planner/tfs_optical.py index c8034cf..9455f31 100644 --- a/src/planner/tfs_optical_planner/tfs_optical.py +++ b/src/planner/tfs_optical_planner/tfs_optical.py @@ -112,7 +112,7 @@ def send_request(source, destination, ip): logging.debug(f"Payload for path computation: {json.dumps(payload, indent=2)}") try: - response = requests.post(url, headers=headers, data=json.dumps(payload), timeout=15) + response = requests.post(url, headers=headers, data=json.dumps(payload), timeout=1) return json.loads(response.text) except requests.exceptions.RequestException: logging.warning("Error connecting to the Optical Planner service. Skipping optical planning.") diff --git a/src/tests/requests/3ggpp_template_green.json b/src/tests/requests/3ggpp_template_green.json new file mode 100644 index 0000000..67a1367 --- /dev/null +++ b/src/tests/requests/3ggpp_template_green.json @@ -0,0 +1,176 @@ +{ + "NetworkSlice1": { + "operationalState": "", + "administrativeState": "", + "serviceProfileList": [], + "networkSliceSubnetRef": "TopSliceSubnet1" + }, + "TopSliceSubnet1": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "TOP_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "TopId", + "pLMNInfoList": null, + "TopSliceSubnetProfile": { + "EnergyEfficiency": 400, + "EnergyConsumption": 200, + "RenewableEnergyUsage": 0.5, + "CarbonEmissions": 100 + } + } + ], + "networkSliceSubnetRef": [ + "CNSliceSubnet1", + "RANSliceSubnet1" + ] + }, + "CNSliceSubnet1": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "CN_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "CNId", + "pLMNInfoList": null, + "CNSliceSubnetProfile": { + "EnergyEfficiency": 400, + "EnergyConsumption": 200, + "RenewableEnergyUsage": 0.5, + "CarbonEmissions": 100 + } + } + ] + }, + "RANSliceSubnet1": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "RAN_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "RANId", + "pLMNInfoList": null, + "RANSliceSubnetProfile": { + "dLThptPerSliceSubnet": { + "GuaThpt": 40, + "MaxThpt": 80 + }, + "uLThptPerSliceSubnet": { + "GuaThpt": 40, + "MaxThpt": 80 + }, + "dLLatency": 8, + "uLLatency": 8, + "EnergyEfficiency": 400, + "EnergyConsumption": 200, + "RenewableEnergyUsage": 0.5, + "CarbonEmissions": 100 + } + } + ], + "networkSliceSubnetRef": [ + "MidhaulSliceSubnet1" + ] + }, + "MidhaulSliceSubnet1": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "RAN_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "MidhaulId", + "pLMNInfoList": null, + "RANSliceSubnetProfile": { + "EnergyEfficiency": 5, + "EnergyConsumption": 18000, + "RenewableEnergyUsage": 0.5, + "CarbonEmissions": 650 + } + } + ], + "EpTransport": [ + "EpTransport CU-UP1", + "EpTransport DU3" + ] + }, + "BackhaulSliceSubnet1": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "RAN_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "BackhaulId", + "pLMNInfoList": null, + "RANSliceSubnetProfile": { + "dLThptPerSliceSubnet": { + "GuaThpt": 40, + "MaxThpt": 80 + }, + "uLThptPerSliceSubnet": { + "GuaThpt": 40, + "MaxThpt": 80 + }, + "dLLatency": 8, + "uLLatency": 8, + "EnergyEfficiency": 400, + "EnergyConsumption": 200, + "RenewableEnergyUsage": 0.5, + "CarbonEmissions": 100 + } + } + ], + "EpTransport": [ + "EpTransport CU-UP2", + "EpTransport UPF" + ] + }, + "EpTransport CU-UP1": { + "IpAddress": "1.1.1.100", + "logicalInterfaceInfo": { + "logicalInterfaceType": "VLAN", + "logicalInterfaceId": "300" + }, + "NextHopInfo": "1.1.1.1", + "qosProfile": "5QI100", + "EpApplicationRef": [ + "EP_F1U CU-UP1" + ] + }, + "EP_F1U CU-UP1": { + "localAddress": "100.1.1.100", + "remoteAddress": "200.1.1.100", + "epTransportRef": [ + "EpTransport CU-UP1" + ] + }, + "EpTransport DU3": { + "IpAddress": "2.2.2.100", + "logicalInterfaceInfo": { + "logicalInterfaceType": "VLAN", + "logicalInterfaceId": "300" + }, + "NextHopInfo": "2.2.2.2", + "qosProfile": "5QI100", + "EpApplicationRef": [ + "EP_F1U DU3" + ] + }, + "EP_F1U DU3": { + "localAddress": "200.1.1.100", + "remoteAddress": "100.1.1.100", + "epTransportRef": [ + "EpTransport DU3" + ] + } +} \ No newline at end of file diff --git a/src/tests/requests/3gpp_template_UC1PoC2_backhaul.json b/src/tests/requests/3gpp_template_UC1PoC2_backhaul.json new file mode 100644 index 0000000..ed80ec0 --- /dev/null +++ b/src/tests/requests/3gpp_template_UC1PoC2_backhaul.json @@ -0,0 +1,267 @@ +{ + "NetworkSlice1": { + "operationalState": "", + "administrativeState": "", + "serviceProfileList": [], + "networkSliceSubnetRef": "TopSliceSubnet1" + }, + "TopSliceSubnet1": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "TOP_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "TopId", + "pLMNInfoList": null, + "TopSliceSubnetProfile": { + "dLThptPerSliceSubnet": { + "GuaThpt": 310, + "MaxThpt": 620 + }, + "uLThptPerSliceSubnet": { + "GuaThpt": 160, + "MaxThpt": 320 + }, + "dLLatency": 20, + "uLLatency": 20 + } + } + ], + "networkSliceSubnetRef": [ + "RANSliceSubnet1" + ] + }, + "RANSliceSubnet1": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "RAN_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "RANId", + "pLMNInfoList": null, + "RANSliceSubnetProfile": { + "dLThptPerSliceSubnet": { + "GuaThpt": 310, + "MaxThpt": 620 + }, + "uLThptPerSliceSubnet": { + "GuaThpt": 160, + "MaxThpt": 320 + }, + "dLLatency": 20, + "uLLatency": 20 + } + } + ], + "networkSliceSubnetRef": [ + "BackhaulSliceSubnetN2", + "BackhaulSliceSubnetN31", + "BackhaulSliceSubnetN32" + ] + }, + "BackhaulSliceSubnetN2": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "RAN_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "BackhaulId", + "pLMNInfoList": null, + "RANSliceSubnetProfile": { + "dLThptPerSliceSubnet": { + "GuaThpt": 10, + "MaxThpt": 20 + }, + "uLThptPerSliceSubnet": { + "GuaThpt": 10, + "MaxThpt": 20 + }, + "dLLatency": 20, + "uLLatency": 20 + } + } + ], + "EpTransport": [ + "EpTransport CU-N2", + "EpTransport AMF-N2" + ] + }, + "BackhaulSliceSubnetN31": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "RAN_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "BackhaulId", + "pLMNInfoList": null, + "RANSliceSubnetProfile": { + "dLThptPerSliceSubnet": { + "GuaThpt": 100, + "MaxThpt": 200 + }, + "uLThptPerSliceSubnet": { + "GuaThpt": 50, + "MaxThpt": 100 + }, + "dLLatency": 10, + "uLLatency": 10 + } + } + ], + "EpTransport": [ + "EpTransport CU-N31", + "EpTransport UPF-N31" + ] + }, + "BackhaulSliceSubnetN32": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "RAN_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "BackhaulId", + "pLMNInfoList": null, + "RANSliceSubnetProfile": { + "dLThptPerSliceSubnet": { + "GuaThpt": 200, + "MaxThpt": 400 + }, + "uLThptPerSliceSubnet": { + "GuaThpt": 100, + "MaxThpt": 200 + }, + "dLLatency": 5, + "uLLatency": 5 + } + } + ], + "EpTransport": [ + "EpTransport CU-N32", + "EpTransport UPF-N32" + ] + }, + "EpTransport CU-N2": { + "IpAddress": "10.60.11.3", + "logicalInterfaceInfo": { + "logicalInterfaceType": "VLAN", + "logicalInterfaceId": "100" + }, + "NextHopInfo": "4.4.4.4", + "qosProfile": "A", + "EpApplicationRef": [ + "EP_N2 CU-N2" + ] + }, + "EP_N2 CU-N2": { + "localAddress": "10.60.11.3", + "remoteAddress": "10.60.60.105", + "epTransportRef": [ + "EpTransport CU-N2" + ] + }, + "EpTransport AMF-N2": { + "IpAddress": "10.60.60.105", + "logicalInterfaceInfo": { + "logicalInterfaceType": "VLAN", + "logicalInterfaceId": "100" + }, + "NextHopInfo": "5.5.5.5", + "qosProfile": "A", + "EpApplicationRef": [ + "EP_N2 AMF-N2" + ] + }, + "EP_N2 AMF-N2": { + "localAddress": "10.60.60.105", + "remoteAddress": "10.60.11.3", + "epTransportRef": [ + "EpTransport UPF-N2" + ] + }, + "EpTransport CU-N32": { + "IpAddress": "10.60.11.3", + "logicalInterfaceInfo": { + "logicalInterfaceType": "VLAN", + "logicalInterfaceId": "101" + }, + "NextHopInfo": "4.4.4.4", + "qosProfile": "B", + "EpApplicationRef": [ + "EP_N3 CU-N32" + ] + }, + "EP_N3 CU-N32": { + "localAddress": "10.60.11.3", + "remoteAddress": "10.60.10.6", + "epTransportRef": [ + "EpTransport CU-N32" + ] + }, + "EpTransport UPF-N32": { + "IpAddress": "10.60.10.6", + "logicalInterfaceInfo": { + "logicalInterfaceType": "VLAN", + "logicalInterfaceId": "101" + }, + "NextHopInfo": "5.5.5.5", + "qosProfile": "B", + "EpApplicationRef": [ + "EP_N3 UPF-N32" + ] + }, + "EP_N3 UPF-N32": { + "localAddress": "10.60.10.6", + "remoteAddress": "10.60.11.3", + "epTransportRef": [ + "EpTransport UPF-N32" + ] + }, + "EpTransport CU-N31": { + "IpAddress": "10.60.11.3", + "logicalInterfaceInfo": { + "logicalInterfaceType": "VLAN", + "logicalInterfaceId": "102" + }, + "NextHopInfo": "4.4.4.4", + "qosProfile": "C", + "EpApplicationRef": [ + "EP_N3 CU-N31" + ] + }, + "EP_N3 CU-N31": { + "localAddress": "10.60.11.3", + "remoteAddress": "10.60.60.106", + "epTransportRef": [ + "EpTransport CU-N31" + ] + }, + "EpTransport UPF-N31": { + "IpAddress": "10.60.60.106", + "logicalInterfaceInfo": { + "logicalInterfaceType": "VLAN", + "logicalInterfaceId": "102" + }, + "NextHopInfo": "5.5.5.5", + "qosProfile": "C", + "EpApplicationRef": [ + "EP_N3 UPF-N31" + ] + }, + "EP_N3 UPF-N31": { + "localAddress": "10.60.60.106", + "remoteAddress": "10.60.11.3", + "epTransportRef": [ + "EpTransport UPF-N31" + ] + } +} \ No newline at end of file diff --git a/src/tests/requests/3gpp_template_UC1PoC2_backhaul_request_1.json b/src/tests/requests/3gpp_template_UC1PoC2_backhaul_request_1.json new file mode 100644 index 0000000..9dab294 --- /dev/null +++ b/src/tests/requests/3gpp_template_UC1PoC2_backhaul_request_1.json @@ -0,0 +1,131 @@ +{ + "NetworkSlice1": { + "operationalState": "", + "administrativeState": "", + "serviceProfileList": [], + "networkSliceSubnetRef": "TopSliceSubnet1" + }, + "TopSliceSubnet1": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "TOP_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "TopId", + "pLMNInfoList": null, + "TopSliceSubnetProfile": { + "dLThptPerSliceSubnet": { + "GuaThpt": 310, + "MaxThpt": 620 + }, + "uLThptPerSliceSubnet": { + "GuaThpt": 160, + "MaxThpt": 320 + }, + "dLLatency": 20, + "uLLatency": 20 + } + } + ], + "networkSliceSubnetRef": [ + "RANSliceSubnet1" + ] + }, + "RANSliceSubnet1": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "RAN_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "RANId", + "pLMNInfoList": null, + "RANSliceSubnetProfile": { + "dLThptPerSliceSubnet": { + "GuaThpt": 310, + "MaxThpt": 620 + }, + "uLThptPerSliceSubnet": { + "GuaThpt": 160, + "MaxThpt": 320 + }, + "dLLatency": 20, + "uLLatency": 20 + } + } + ], + "networkSliceSubnetRef": [ + "BackhaulSliceSubnetN2" + ] + }, + "BackhaulSliceSubnetN2": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "RAN_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "BackhaulId", + "pLMNInfoList": null, + "RANSliceSubnetProfile": { + "dLThptPerSliceSubnet": { + "GuaThpt": 1, + "MaxThpt": 2 + }, + "uLThptPerSliceSubnet": { + "GuaThpt": 1, + "MaxThpt": 2 + }, + "dLLatency": 20, + "uLLatency": 20 + } + } + ], + "EpTransport": [ + "EpTransport CU-N2", + "EpTransport AMF-N2" + ] + }, + "EpTransport CU-N2": { + "IpAddress": "10.60.11.3", + "logicalInterfaceInfo": { + "logicalInterfaceType": "VLAN", + "logicalInterfaceId": "100" + }, + "NextHopInfo": "4.4.4.4", + "qosProfile": "A", + "EpApplicationRef": [ + "EP_N2 CU-N2" + ] + }, + "EP_N2 CU-N2": { + "localAddress": "10.60.11.3", + "remoteAddress": "10.60.60.105", + "epTransportRef": [ + "EpTransport CU-N2" + ] + }, + "EpTransport AMF-N2": { + "IpAddress": "10.60.60.105", + "logicalInterfaceInfo": { + "logicalInterfaceType": "VLAN", + "logicalInterfaceId": "100" + }, + "NextHopInfo": "5.5.5.5", + "qosProfile": "A", + "EpApplicationRef": [ + "EP_N2 AMF-N2" + ] + }, + "EP_N2 AMF-N2": { + "localAddress": "10.60.60.105", + "remoteAddress": "10.60.11.3", + "epTransportRef": [ + "EpTransport UPF-N2" + ] + } +} \ No newline at end of file diff --git a/src/tests/requests/3gpp_template_UC1PoC2_backhaul_request_2.json b/src/tests/requests/3gpp_template_UC1PoC2_backhaul_request_2.json new file mode 100644 index 0000000..d287a04 --- /dev/null +++ b/src/tests/requests/3gpp_template_UC1PoC2_backhaul_request_2.json @@ -0,0 +1,131 @@ +{ + "NetworkSlice1": { + "operationalState": "", + "administrativeState": "", + "serviceProfileList": [], + "networkSliceSubnetRef": "TopSliceSubnet1" + }, + "TopSliceSubnet1": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "TOP_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "TopId", + "pLMNInfoList": null, + "TopSliceSubnetProfile": { + "dLThptPerSliceSubnet": { + "GuaThpt": 310, + "MaxThpt": 620 + }, + "uLThptPerSliceSubnet": { + "GuaThpt": 160, + "MaxThpt": 320 + }, + "dLLatency": 20, + "uLLatency": 20 + } + } + ], + "networkSliceSubnetRef": [ + "RANSliceSubnet1" + ] + }, + "RANSliceSubnet1": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "RAN_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "RANId", + "pLMNInfoList": null, + "RANSliceSubnetProfile": { + "dLThptPerSliceSubnet": { + "GuaThpt": 310, + "MaxThpt": 620 + }, + "uLThptPerSliceSubnet": { + "GuaThpt": 160, + "MaxThpt": 320 + }, + "dLLatency": 20, + "uLLatency": 20 + } + } + ], + "networkSliceSubnetRef": [ + "BackhaulSliceSubnetN32" + ] + }, + "BackhaulSliceSubnetN32": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "RAN_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "BackhaulId", + "pLMNInfoList": null, + "RANSliceSubnetProfile": { + "dLThptPerSliceSubnet": { + "GuaThpt": 200, + "MaxThpt": 400 + }, + "uLThptPerSliceSubnet": { + "GuaThpt": 100, + "MaxThpt": 200 + }, + "dLLatency": 5, + "uLLatency": 5 + } + } + ], + "EpTransport": [ + "EpTransport CU-N32", + "EpTransport UPF-N32" + ] + }, + "EpTransport CU-N32": { + "IpAddress": "10.60.11.3", + "logicalInterfaceInfo": { + "logicalInterfaceType": "VLAN", + "logicalInterfaceId": "101" + }, + "NextHopInfo": "4.4.4.4", + "qosProfile": "B", + "EpApplicationRef": [ + "EP_N3 CU-N32" + ] + }, + "EP_N3 CU-N32": { + "localAddress": "10.60.11.3", + "remoteAddress": "10.60.10.6", + "epTransportRef": [ + "EpTransport CU-N32" + ] + }, + "EpTransport UPF-N32": { + "IpAddress": "10.60.10.6", + "logicalInterfaceInfo": { + "logicalInterfaceType": "VLAN", + "logicalInterfaceId": "101" + }, + "NextHopInfo": "5.5.5.5", + "qosProfile": "B", + "EpApplicationRef": [ + "EP_N3 UPF-N32" + ] + }, + "EP_N3 UPF-N32": { + "localAddress": "10.60.10.6", + "remoteAddress": "10.60.11.3", + "epTransportRef": [ + "EpTransport UPF-N32" + ] + } +} \ No newline at end of file diff --git a/src/tests/requests/3gpp_template_UC1PoC2_backhaul_request_3.json b/src/tests/requests/3gpp_template_UC1PoC2_backhaul_request_3.json new file mode 100644 index 0000000..55232e8 --- /dev/null +++ b/src/tests/requests/3gpp_template_UC1PoC2_backhaul_request_3.json @@ -0,0 +1,131 @@ +{ + "NetworkSlice1": { + "operationalState": "", + "administrativeState": "", + "serviceProfileList": [], + "networkSliceSubnetRef": "TopSliceSubnet1" + }, + "TopSliceSubnet1": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "TOP_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "TopId", + "pLMNInfoList": null, + "TopSliceSubnetProfile": { + "dLThptPerSliceSubnet": { + "GuaThpt": 310, + "MaxThpt": 620 + }, + "uLThptPerSliceSubnet": { + "GuaThpt": 160, + "MaxThpt": 320 + }, + "dLLatency": 20, + "uLLatency": 20 + } + } + ], + "networkSliceSubnetRef": [ + "RANSliceSubnet1" + ] + }, + "RANSliceSubnet1": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "RAN_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "RANId", + "pLMNInfoList": null, + "RANSliceSubnetProfile": { + "dLThptPerSliceSubnet": { + "GuaThpt": 310, + "MaxThpt": 620 + }, + "uLThptPerSliceSubnet": { + "GuaThpt": 160, + "MaxThpt": 320 + }, + "dLLatency": 20, + "uLLatency": 20 + } + } + ], + "networkSliceSubnetRef": [ + "BackhaulSliceSubnetN31" + ] + }, + "BackhaulSliceSubnetN31": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "RAN_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "BackhaulId", + "pLMNInfoList": null, + "RANSliceSubnetProfile": { + "dLThptPerSliceSubnet": { + "GuaThpt": 100, + "MaxThpt": 200 + }, + "uLThptPerSliceSubnet": { + "GuaThpt": 50, + "MaxThpt": 100 + }, + "dLLatency": 10, + "uLLatency": 10 + } + } + ], + "EpTransport": [ + "EpTransport CU-N31", + "EpTransport UPF-N31" + ] + }, + "EpTransport CU-N31": { + "IpAddress": "10.60.11.3", + "logicalInterfaceInfo": { + "logicalInterfaceType": "VLAN", + "logicalInterfaceId": "102" + }, + "NextHopInfo": "4.4.4.4", + "qosProfile": "C", + "EpApplicationRef": [ + "EP_N3 CU-N31" + ] + }, + "EP_N3 CU-N31": { + "localAddress": "10.60.11.3", + "remoteAddress": "10.60.60.106", + "epTransportRef": [ + "EpTransport CU-N31" + ] + }, + "EpTransport UPF-N31": { + "IpAddress": "10.60.60.106", + "logicalInterfaceInfo": { + "logicalInterfaceType": "VLAN", + "logicalInterfaceId": "102" + }, + "NextHopInfo": "5.5.5.5", + "qosProfile": "C", + "EpApplicationRef": [ + "EP_N3 UPF-N31" + ] + }, + "EP_N3 UPF-N31": { + "localAddress": "10.60.60.106", + "remoteAddress": "10.60.11.3", + "epTransportRef": [ + "EpTransport UPF-N31" + ] + } +} \ No newline at end of file diff --git a/src/tests/requests/3gpp_template_UC1PoC2_midhaul.json b/src/tests/requests/3gpp_template_UC1PoC2_midhaul.json new file mode 100644 index 0000000..300f866 --- /dev/null +++ b/src/tests/requests/3gpp_template_UC1PoC2_midhaul.json @@ -0,0 +1,267 @@ +{ + "NetworkSlice1": { + "operationalState": "", + "administrativeState": "", + "serviceProfileList": [], + "networkSliceSubnetRef": "TopSliceSubnet1" + }, + "TopSliceSubnet1": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "TOP_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "TopId", + "pLMNInfoList": null, + "TopSliceSubnetProfile": { + "dLThptPerSliceSubnet": { + "GuaThpt": 410, + "MaxThpt": 820 + }, + "uLThptPerSliceSubnet": { + "GuaThpt": 210, + "MaxThpt": 420 + }, + "dLLatency": 20, + "uLLatency": 20 + } + } + ], + "networkSliceSubnetRef": [ + "RANSliceSubnet1" + ] + }, + "RANSliceSubnet1": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "RAN_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "RANId", + "pLMNInfoList": null, + "RANSliceSubnetProfile": { + "dLThptPerSliceSubnet": { + "GuaThpt": 410, + "MaxThpt": 820 + }, + "uLThptPerSliceSubnet": { + "GuaThpt": 210, + "MaxThpt": 220 + }, + "dLLatency": 20, + "uLLatency": 20 + } + } + ], + "networkSliceSubnetRef": [ + "MidhaulSliceSubnetF1c", + "MidhaulSliceSubnetF1u1", + "MidhaulSliceSubnetF1u2" + ] + }, + "MidhaulSliceSubnetF1c": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "RAN_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "MidhaulId", + "pLMNInfoList": null, + "RANSliceSubnetProfile": { + "dLThptPerSliceSubnet": { + "GuaThpt": 10, + "MaxThpt": 20 + }, + "uLThptPerSliceSubnet": { + "GuaThpt": 10, + "MaxThpt": 20 + }, + "dLLatency": 20, + "uLLatency": 20 + } + } + ], + "EpTransport": [ + "EpTransport CU-F1c", + "EpTransport DU-F1c" + ] + }, + "MidhaulSliceSubnetF1u1": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "RAN_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "MidhaulId", + "pLMNInfoList": null, + "RANSliceSubnetProfile": { + "dLThptPerSliceSubnet": { + "GuaThpt": 200, + "MaxThpt": 400 + }, + "uLThptPerSliceSubnet": { + "GuaThpt": 100, + "MaxThpt": 200 + }, + "dLLatency": 5, + "uLLatency": 5 + } + } + ], + "EpTransport": [ + "EpTransport CU-F1u1", + "EpTransport DU-F1u1" + ] + }, + "MidhaulSliceSubnetF1u2": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "RAN_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "MidhaulId", + "pLMNInfoList": null, + "RANSliceSubnetProfile": { + "dLThptPerSliceSubnet": { + "GuaThpt": 200, + "MaxThpt": 400 + }, + "uLThptPerSliceSubnet": { + "GuaThpt": 100, + "MaxThpt": 200 + }, + "dLLatency": 10, + "uLLatency": 10 + } + } + ], + "EpTransport": [ + "EpTransport CU-F1u2", + "EpTransport DU-F1u2" + ] + }, + "EpTransport CU-F1c": { + "IpAddress": "10.60.10.2", + "logicalInterfaceInfo": { + "logicalInterfaceType": "VLAN", + "logicalInterfaceId": "100" + }, + "NextHopInfo": "5.5.5.5", + "qosProfile": "A", + "EpApplicationRef": [ + "EP_F1C CU-F1c" + ] + }, + "EP_F1C CU-F1c": { + "localAddress": "10.60.10.2", + "remoteAddress": "10.60.11.2", + "epTransportRef": [ + "EpTransport CU-F1c" + ] + }, + "EpTransport DU-F1c": { + "IpAddress": "10.60.11.2", + "logicalInterfaceInfo": { + "logicalInterfaceType": "VLAN", + "logicalInterfaceId": "100" + }, + "NextHopInfo": "4.4.4.4", + "qosProfile": "A", + "EpApplicationRef": [ + "EP_F1C DU-F1c" + ] + }, + "EP_F1C DU-F1c": { + "localAddress": "10.60.11.2", + "remoteAddress": "10.60.10.2", + "epTransportRef": [ + "EpTransport DU-F1c" + ] + }, + "EpTransport CU-F1u1": { + "IpAddress": "10.60.10.2", + "logicalInterfaceInfo": { + "logicalInterfaceType": "VLAN", + "logicalInterfaceId": "101" + }, + "NextHopInfo": "5.5.5.5", + "qosProfile": "B", + "EpApplicationRef": [ + "EP_F1U CU-F1u1" + ] + }, + "EP_F1U CU-F1u1": { + "localAddress": "10.60.10.2", + "remoteAddress": "10.60.11.2", + "epTransportRef": [ + "EpTransport CU-F1c" + ] + }, + "EpTransport DU-F1u1": { + "IpAddress": "10.60.11.2", + "logicalInterfaceInfo": { + "logicalInterfaceType": "VLAN", + "logicalInterfaceId": "101" + }, + "NextHopInfo": "4.4.4.4", + "qosProfile": "B", + "EpApplicationRef": [ + "EP_F1U DU-F1u1" + ] + }, + "EP_F1U DU-F1u1": { + "localAddress": "10.60.11.2", + "remoteAddress": "10.60.10.2", + "epTransportRef": [ + "EpTransport DU-F1u1" + ] + }, + "EpTransport CU-F1u2": { + "IpAddress": "10.60.10.2", + "logicalInterfaceInfo": { + "logicalInterfaceType": "VLAN", + "logicalInterfaceId": "102" + }, + "NextHopInfo": "5.5.5.5", + "qosProfile": "C", + "EpApplicationRef": [ + "EP_F1U CU-F1u2" + ] + }, + "EP_F1U CU-F1u2": { + "localAddress": "10.60.10.2", + "remoteAddress": "10.60.11.2", + "epTransportRef": [ + "EpTransport CU-F1u2" + ] + }, + "EpTransport DU-F1u2": { + "IpAddress": "10.60.11.2", + "logicalInterfaceInfo": { + "logicalInterfaceType": "VLAN", + "logicalInterfaceId": "102" + }, + "NextHopInfo": "4.4.4.4", + "qosProfile": "C", + "EpApplicationRef": [ + "EP_F1U DU-F1u2" + ] + }, + "EP_F1U DU-F1u2": { + "localAddress": "10.60.11.2", + "remoteAddress": "10.60.10.2", + "epTransportRef": [ + "EpTransport DU-F1u2" + ] + } +} \ No newline at end of file diff --git a/src/tests/requests/P2MP.json b/src/tests/requests/P2MP.json new file mode 100644 index 0000000..02875dc --- /dev/null +++ b/src/tests/requests/P2MP.json @@ -0,0 +1,108 @@ +{ + "ietf-network-slice-service:network-slice-services": { + "slo-sle-templates": { + "slo-sle-template": [ + { + "id": "LOW-DELAY", + "description": "Prefer direct link: delay <= 2ms", + "slo-policy": { + "metric-bound": [ + { + "metric-type": "two-way-delay-maximum", + "metric-unit": "milliseconds", + "bound": 2 + } + ] + } + } + ] + }, + "slice-service": [ + { + "id": "slice-long", + "description": "Slice tolerant to intermediate hops", + "slo-sle-policy": { + "slo-sle-template": "LOW-DELAY" + }, + "sdps": { + "sdp": [ + { + "id": "T1.2", + "node-id": "T1.2", + "attachment-circuits": { + "attachment-circuit": [ + { + "id": "ac-r1", + "ac-ipv4-address": "10.10.1.1", + "ac-ipv4-prefix-length": 24 + } + ] + } + }, + { + "id": "T1.1", + "node-id": "T1.1", + "attachment-circuits": { + "attachment-circuit": [ + { + "id": "ac-r2", + "ac-ipv4-address": "10.10.2.1", + "ac-ipv4-prefix-length": 24 + } + ] + } + }, + { + "id": "T2.1", + "node-id": "T2.1", + "attachment-circuits": { + "attachment-circuit": [ + { + "id": "ac-r3", + "ac-ipv4-address": "10.10.3.1", + "ac-ipv4-prefix-length": 24 + } + ] + } + }, + { + "id": "T1.3", + "node-id": "T1.3", + "attachment-circuits": { + "attachment-circuit": [ + { + "id": "ac-r3", + "ac-ipv4-address": "10.10.4.1", + "ac-ipv4-prefix-length": 24 + } + ] + } + } + ] + }, + "connection-groups": { + "connection-group": [ + { + "id": "cg-long", + "connectivity-type": "ietf-vpn-common:any-to-any", + "connectivity-construct": [ + { + "id": "cc-p2mp", + "p2mp-sdp": { + "root-sdp-id": "T2.1", + "leaf-sdp-id": [ + "T1.1", + "T1.2", + "T1.3" + + ] + } + } + ] + } + ] + } + } + ] + } + } \ No newline at end of file diff --git a/src/tests/requests/create_slice_1.json b/src/tests/requests/create_slice_1.json new file mode 100644 index 0000000..bcefe01 --- /dev/null +++ b/src/tests/requests/create_slice_1.json @@ -0,0 +1,81 @@ +{ + "ietf-network-slice-service:network-slice-services": { + "slo-sle-templates": { + "slo-sle-template": [ + { + "id": "LOW-DELAY", + "description": "optical-slice", + "slo-policy": { + "metric-bound": [ + { + "metric-type": "two-way-delay-maximum", + "metric-unit": "milliseconds", + "bound": 2 + } + ] + } + } + ] + }, + "slice-service": [ + { + "id": "slice-long", + "description": "Slice tolerant to intermediate hops", + "slo-sle-policy": { + "slo-sle-template": "LOW-DELAY" + }, + "sdps": { + "sdp": [ + { + "id": "Ethernet110", + "node-id": "Phoenix-1", + "attachment-circuits": { + "attachment-circuit": [ + { + "id": "ac-r1", + "ac-ipv4-address": "10.10.1.1", + "ac-ipv4-prefix-length": 24 + } + ] + } + }, + { + "id": "Ethernet220", + "node-id": "Phoenix-2", + "attachment-circuits": { + "attachment-circuit": [ + { + "id": "ac-r2", + "ac-ipv4-address": "10.10.2.1", + "ac-ipv4-prefix-length": 24 + } + ] + } + } + ] + }, + "connection-groups": { + "connection-group": [ + { + "id": "cg-long", + "connectivity-type": "ietf-vpn-common:any-to-any", + "connectivity-construct": [ + { + "id": "cc-long", + "a2a-sdp": [ + { + "sdp-id": "Ethernet110" + }, + { + "sdp-id": "Ethernet220" + } + ] + } + ] + } + ] + } + } + ] + } +} \ No newline at end of file diff --git a/src/tests/requests/ietf_green_request.json b/src/tests/requests/ietf_green_request.json new file mode 100644 index 0000000..5edae75 --- /dev/null +++ b/src/tests/requests/ietf_green_request.json @@ -0,0 +1,172 @@ +{ + "ietf-network-slice-service:network-slice-services": { + "slo-sle-templates": { + "slo-sle-template": [ + { + "id": "B", + "description": "", + "slo-policy": { + "metric-bound": [ + { + "metric-type": "energy_consumption", + "metric-unit": "kWh", + "bound": 20200 + }, + { + "metric-type": "energy_efficiency", + "metric-unit": "Wats/bps", + "bound": 6 + }, + { + "metric-type": "carbon_emission", + "metric-unit": "grams of CO2 per kWh", + "bound": 750 + }, + { + "metric-type": "renewable_energy_usage", + "metric-unit": "rate", + "bound": 0.5 + } + ] + }, + "sle-policy": { + "security": "", + "isolation": "", + "path-constraints": { + "service-functions": "", + "diversity": { + "diversity": { + "diversity-type": "" + } + } + } + } + } + ] + }, + "slice-service": [ + { + "id": "slice-service-88a585f7-a432-4312-8774-6210fb0b2342", + "description": "Transport network slice mapped with 3GPP slice NetworkSlice1", + "service-tags": { + "tag-type": [ + { + "tag-type": "service", + "tag-type-value": [ + "L2" + ] + } + ] + }, + "slo-sle-policy": { + "slo-sle-template": "B" + }, + "status": {}, + "sdps": { + "sdp": [ + { + "id": "A", + "geo-location": "", + "node-id": "CU-N32", + "sdp-ip-address": "10.60.11.3", + "tp-ref": "", + "service-match-criteria": { + "match-criterion": [ + { + "index": 1, + "match-type": "VLAN", + "value": "101", + "target-connection-group-id": "CU-N32_UPF-N32" + } + ] + }, + "incoming-qos-policy": "", + "outgoing-qos-policy": "", + "sdp-peering": { + "peer-sap-id": "", + "protocols": "" + }, + "ac-svc-ref": [], + "attachment-circuits": { + "attachment-circuit": [ + { + "id": "100", + "ac-ipv4-address": "10.60.11.3", + "ac-ipv4-prefix-length": 0, + "sdp-peering": { + "peer-sap-id": "4.4.4.4" + }, + "status": {} + } + ] + }, + "status": {}, + "sdp-monitoring": "" + }, + { + "id": "B", + "geo-location": "", + "node-id": "UPF-N32", + "sdp-ip-address": "10.60.10.6", + "tp-ref": "", + "service-match-criteria": { + "match-criterion": [ + { + "index": 1, + "match-type": "VLAN", + "value": "101", + "target-connection-group-id": "CU-N32_UPF-N32" + } + ] + }, + "incoming-qos-policy": "", + "outgoing-qos-policy": "", + "sdp-peering": { + "peer-sap-id": "", + "protocols": "" + }, + "ac-svc-ref": [], + "attachment-circuits": { + "attachment-circuit": [ + { + "id": "200", + "ac-ipv4-address": "10.60.10.6", + "ac-ipv4-prefix-length": 0, + "sdp-peering": { + "peer-sap-id": "5.5.5.5" + }, + "status": {} + } + ] + }, + "status": {}, + "sdp-monitoring": "" + } + ] + }, + "connection-groups": { + "connection-group": [ + { + "id": "CU-N32_UPF-N32", + "connectivity-type": "ietf-vpn-common:any-to-any", + "connectivity-construct": [ + { + "id": 1, + "a2a-sdp": [ + { + "sdp-id": "A" + }, + { + "sdp-id": "B" + } + ] + } + ], + "status": {} + } + ] + } + } + ] + } +} \ No newline at end of file diff --git a/src/tests/requests/l3vpn_test.json b/src/tests/requests/l3vpn_test.json new file mode 100644 index 0000000..4564739 --- /dev/null +++ b/src/tests/requests/l3vpn_test.json @@ -0,0 +1,164 @@ +{ + "ietf-network-slice-service:network-slice-services": { + "slo-sle-templates": { + "slo-sle-template": [ + { + "id": "A", + "description": "", + "slo-policy": { + "metric-bound": [ + { + "metric-type": "one-way-bandwidth", + "metric-unit": "kbps", + "bound": 20000000.67 + }, + { + "metric-type": "one-way-delay-maximum", + "metric-unit": "milliseconds", + "bound": 5.5 + } + ], + "availability": 95, + "mtu": 1450 + }, + "sle-policy": { + "security": "", + "isolation": "", + "path-constraints": { + "service-functions": "", + "diversity": { + "diversity": { + "diversity-type": "" + } + } + } + } + } + ] + }, + "slice-service": [ + { + "id": "slice-service-91327140-7361-41b3-aa45-e84a7fb40b79", + "description": "Transport network slice mapped with 3GPP slice NetworkSlice1", + "service-tags": { + "tag-type": [ + { + "tag-type": "service", + "tag-type-value": [ + "L3" + ] + } + ] + }, + "slo-sle-policy": { + "slo-sle-template": "A" + }, + "status": {}, + "sdps": { + "sdp": [ + { + "id": "", + "geo-location": "", + "node-id": "CU-N2", + "sdp-ip-address": "10.60.11.3", + "tp-ref": "", + "service-match-criteria": { + "match-criterion": [ + { + "index": 1, + "match-type": "VLAN", + "value": "100", + "target-connection-group-id": "CU-N2_AMF-N2" + } + ] + }, + "incoming-qos-policy": "", + "outgoing-qos-policy": "", + "sdp-peering": { + "peer-sap-id": "", + "protocols": "" + }, + "ac-svc-ref": [], + "attachment-circuits": { + "attachment-circuit": [ + { + "id": "100", + "ac-ipv4-address": "10.60.11.3", + "ac-ipv4-prefix-length": 0, + "sdp-peering": { + "peer-sap-id": "1.1.1.1" + }, + "status": {} + } + ] + }, + "status": {}, + "sdp-monitoring": "" + }, + { + "id": "", + "geo-location": "", + "node-id": "AMF-N2", + "sdp-ip-address": "10.60.60.105", + "tp-ref": "", + "service-match-criteria": { + "match-criterion": [ + { + "index": 1, + "match-type": "VLAN", + "value": "100", + "target-connection-group-id": "CU-N2_AMF-N2" + } + ] + }, + "incoming-qos-policy": "", + "outgoing-qos-policy": "", + "sdp-peering": { + "peer-sap-id": "", + "protocols": "" + }, + "ac-svc-ref": [], + "attachment-circuits": { + "attachment-circuit": [ + { + "id": "200", + "ac-ipv4-address": "10.60.60.105", + "ac-ipv4-prefix-length": 0, + "sdp-peering": { + "peer-sap-id": "3.3.3.3" + }, + "status": {} + } + ] + }, + "status": {}, + "sdp-monitoring": "" + } + ] + }, + "connection-groups": { + "connection-group": [ + { + "id": "CU-N2_AMF-N2", + "connectivity-type": "ietf-vpn-common:any-to-any", + "connectivity-construct": [ + { + "id": 1, + "a2a-sdp": [ + { + "sdp-id": "01" + }, + { + "sdp-id": "02" + } + ] + } + ], + "status": {} + } + ] + } + } + ] + } + } \ No newline at end of file diff --git a/src/tests/requests/slice_request.json b/src/tests/requests/slice_request.json new file mode 100644 index 0000000..f215078 --- /dev/null +++ b/src/tests/requests/slice_request.json @@ -0,0 +1,162 @@ +{ + "ietf-network-slice-service:network-slice-services": { + "slo-sle-templates": { + "slo-sle-template": [ + { + "id": "A", + "description": "", + "slo-policy": { + "metric-bound": [ + { + "metric-type": "one-way-bandwidth", + "metric-unit": "kbps", + "bound": 2000 + }, + { + "metric-type": "one-way-delay-maximum", + "metric-unit": "milliseconds", + "bound": 5 + } + ] + }, + "sle-policy": { + "security": "", + "isolation": "", + "path-constraints": { + "service-functions": "", + "diversity": { + "diversity": { + "diversity-type": "" + } + } + } + } + } + ] + }, + "slice-service": [ + { + "id": "slice-service-11327140-7361-41b3-aa45-e84a7fb40be9", + "description": "Transport network slice mapped with 3GPP slice NetworkSlice1", + "service-tags": { + "tag-type": [ + { + "tag-type": "service", + "tag-type-value": [ + "L2" + ] + } + ] + }, + "slo-sle-policy": { + "slo-sle-template": "A" + }, + "status": {}, + "sdps": { + "sdp": [ + { + "id": "", + "geo-location": "", + "node-id": "CU-N2", + "sdp-ip-address": "10.60.11.3", + "tp-ref": "", + "service-match-criteria": { + "match-criterion": [ + { + "index": 1, + "match-type": "VLAN", + "value": "100", + "target-connection-group-id": "CU-N2_AMF-N2" + } + ] + }, + "incoming-qos-policy": "", + "outgoing-qos-policy": "", + "sdp-peering": { + "peer-sap-id": "", + "protocols": "" + }, + "ac-svc-ref": [], + "attachment-circuits": { + "attachment-circuit": [ + { + "id": "100", + "ac-ipv4-address": "10.60.11.3", + "ac-ipv4-prefix-length": 0, + "sdp-peering": { + "peer-sap-id": "1.1.1.1" + }, + "status": {} + } + ] + }, + "status": {}, + "sdp-monitoring": "" + }, + { + "id": "", + "geo-location": "", + "node-id": "AMF-N2", + "sdp-ip-address": "10.60.60.105", + "tp-ref": "", + "service-match-criteria": { + "match-criterion": [ + { + "index": 1, + "match-type": "VLAN", + "value": "100", + "target-connection-group-id": "CU-N2_AMF-N2" + } + ] + }, + "incoming-qos-policy": "", + "outgoing-qos-policy": "", + "sdp-peering": { + "peer-sap-id": "", + "protocols": "" + }, + "ac-svc-ref": [], + "attachment-circuits": { + "attachment-circuit": [ + { + "id": "200", + "ac-ipv4-address": "10.60.60.105", + "ac-ipv4-prefix-length": 0, + "sdp-peering": { + "peer-sap-id": "3.3.3.3" + }, + "status": {} + } + ] + }, + "status": {}, + "sdp-monitoring": "" + } + ] + }, + "connection-groups": { + "connection-group": [ + { + "id": "CU-N2_AMF-N2", + "connectivity-type": "ietf-vpn-common:any-to-any", + "connectivity-construct": [ + { + "id": 1, + "a2a-sdp": [ + { + "sdp-id": "01" + }, + { + "sdp-id": "02" + } + ] + } + ], + "status": {} + } + ] + } + } + ] + } + } \ No newline at end of file diff --git a/src/tests/test_api.py b/src/tests/test_api.py new file mode 100644 index 0000000..7264ab9 --- /dev/null +++ b/src/tests/test_api.py @@ -0,0 +1,301 @@ +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 flask import Flask +from src.main import NSController +from src.api.main import Api + + +# Load environment variables +load_dotenv() + +@pytest.fixture(scope="session") +def flask_app(): + """Crea una app Flask mínima para los 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') + }) + return app + + +@pytest.fixture(autouse=True) +def push_flask_context(flask_app): + """Empuja automáticamente un contexto Flask para cada test.""" + with flask_app.app_context(): + yield + +@pytest.fixture +def temp_db(tmp_path): + """Fixture to create and cleanup 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() + cursor.execute(""" + CREATE TABLE IF NOT EXISTS slice ( + slice_id TEXT PRIMARY KEY, + intent TEXT NOT NULL, + controller TEXT NOT NULL + ) + """) + conn.commit() + conn.close() + + yield test_db_name + + # Cleanup - properly close connections and remove file + try: + time.sleep(0.1) + if os.path.exists(test_db_name): + os.remove(test_db_name) + except Exception: + time.sleep(0.5) + try: + if os.path.exists(test_db_name): + os.remove(test_db_name) + except: + pass + + +@pytest.fixture +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'), + } + return env_vars + + +@pytest.fixture +def controller_with_mocked_db(temp_db): + """Crea un NSController con base de datos mockeada.""" + with patch('src.database.db.DB_NAME', temp_db): + yield NSController(controller_type="TFS") + + +@pytest.fixture +def ietf_intent(): + """Intent válido en formato IETF.""" + return { + "ietf-network-slice-service:network-slice-services": { + "slo-sle-templates": { + "slo-sle-template": [ + { + "id": "qos1", + "slo-policy": { + "metric-bound": [ + { + "metric-type": "one-way-bandwidth", + "metric-unit": "kbps", + "bound": 1000 + } + ] + } + } + ] + }, + "slice-service": [ + { + "id": "slice-test-1", + "sdps": { + "sdp": [ + { + "sdp-ip-address": "10.0.0.1", + "node-id": "node1", + "service-match-criteria": { + "match-criterion": [ + { + "match-type": "vlan", + "value": "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": "vlan", + "value": "100" + } + ] + }, + "attachment-circuits": { + "attachment-circuit": [ + { + "sdp-peering": { + "peer-sap-id": "R2" + } + } + ] + }, + }, + ] + }, + "service-tags": {"tag-type": {"value": "L3VPN"}}, + } + ], + } + } + + +class TestBasicApiOperations: + """Tests for basic API operations.""" + + def test_get_flows_empty(self, controller_with_mocked_db): + """Debe devolver error cuando no hay 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): + """Debe poder añadir un flow exitosamente.""" + 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): + """Debe poder añadir un flow y luego recuperarlo.""" + 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" + } + ] + + 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): + """Debe poder modificar un flow existente.""" + 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" + + 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): + """Debe borrar un flow concreto.""" + 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): + """Debe borrar todos los flows.""" + 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): + """Debe poder recuperar un flow específico.""" + 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" + } + + result, code = Api(controller_with_mocked_db).get_flows("slice-test-1") + assert code == 200 + assert result["slice_id"] == "slice-test-1" + + +class TestErrorHandling: + """Tests for error handling.""" + + def test_add_flow_with_empty_intent(self, controller_with_mocked_db): + """Debe fallar si se pasa un intent vacío.""" + 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): + """Debe fallar si se pasa None como 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): + """Debe devolver 404 si se pide un slice inexistente.""" + 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): + """Debe fallar si se intenta modificar un flow inexistente.""" + 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): + """Debe fallar si se intenta eliminar un flow inexistente.""" + 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 + + diff --git a/src/tests/test_database.py b/src/tests/test_database.py new file mode 100644 index 0000000..06034eb --- /dev/null +++ b/src/tests/test_database.py @@ -0,0 +1,585 @@ +import pytest +import sqlite3 +import json +import os +import time +from unittest.mock import patch, MagicMock +from src.database.db import ( + 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 + + +@pytest.fixture +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): + conn = sqlite3.connect(test_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 + ) + """) + 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() + + # 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 as e: + # 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): + os.remove(test_db_name) + except: + pass # If it still fails, let pytest's tmp_path cleanup handle it + + +@pytest.fixture +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" + }] + } + }], + "slo-sle-templates": { + "slo-sle-template": [{ + "id": "profile1", + "slo-policy": { + "metric-bound": [{ + "metric-type": "one-way-bandwidth", + "metric-unit": "kbps", + "bound": 1000 + }] + } + }] + } + } + } + + +@pytest.fixture +def simple_intent(): + """Fixture providing simple intent for basic testing.""" + 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): + 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' + + 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): + 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): + 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): + 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): + 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): + 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): + 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): + 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",)) + tfs_result = cursor.fetchone() + 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): + 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 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): + 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): + 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" + + 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["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): + 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 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): + 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") + count = cursor.fetchone()[0] + 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): + 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 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): + 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): + 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 + assert len(result) == 3 + + +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): + 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): + 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): + 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): + 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): + 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 + + +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): + 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): + delete_all_data() # Should not raise error + result = get_all_data() + assert result == [] + + +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): + 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): + 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" + 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" + + 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): + 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): + 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): + # 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): + # 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(0, 3): + update_data(f"slice-{i}", updated_intent, "TFS") + + # Verify updates + for i in range(0, 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): + 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 + assert first_slice["controller"] == "TFS" \ No newline at end of file diff --git a/src/tests/test_e2e.py b/src/tests/test_e2e.py new file mode 100644 index 0000000..9fb9140 --- /dev/null +++ b/src/tests/test_e2e.py @@ -0,0 +1,101 @@ +import pytest +import json +from pathlib import Path +from itertools import product +from src.api.main import Api +from src.main import NSController +from app import create_app + +# Carpeta donde están los JSON de requests +REQUESTS_DIR = Path(__file__).parent / "requests" + +# Lista de todos los flags booleanos que quieres probar +FLAGS_TO_TEST = ["WEBUI_DEPLOY", "DUMP_TEMPLATES", "PLANNER_ENABLED", "PCE_EXTERNAL", "NRP_ENABLED"] + +# Valores posibles para PLANNER_TYPE +PLANNER_TYPE_VALUES = ["ENERGY", "HRAT", "TFS_OPTICAL"] + + +@pytest.fixture +def app(temp_sqlite_db): + """Crea la app Flask con configuración por defecto.""" + app = create_app() + return app + +@pytest.fixture +def client(app): + """Cliente de test de Flask para hacer requests.""" + return app.test_client() + +@pytest.fixture +def set_flags(app): + """Cambia directamente los flags en app.config""" + def _set(flags: dict): + for k, v in flags.items(): + app.config[k] = v + return _set + +@pytest.fixture +def temp_sqlite_db(monkeypatch, tmp_path): + """Usa una base de datos SQLite temporal durante los tests.""" + temp_db_path = tmp_path / "test_slice.db" + monkeypatch.setattr("src.database.db.DB_NAME", str(temp_db_path)) + + # Inicializa la base de datos temporal + from src.database.db import init_db + init_db() + + yield temp_db_path + + # Limpieza al finalizar + if temp_db_path.exists(): + temp_db_path.unlink() + +# Función para cargar todos los JSONs +def load_request_files(): + test_cases = [] + for f in REQUESTS_DIR.glob("*.json"): + with open(f, "r") as file: + json_data = json.load(file) + test_cases.append(json_data) + return test_cases + +# Generador de todas las combinaciones de flags +def generate_flag_combinations(): + bool_values = [True, False] + for combo in product(bool_values, repeat=len(FLAGS_TO_TEST)): + bool_flags = dict(zip(FLAGS_TO_TEST, combo)) + for planner_type in PLANNER_TYPE_VALUES: + yield {**bool_flags, "PLANNER_TYPE": planner_type} + + +# Fixture que combina cada request con cada combinación de flags +def generate_test_cases(): + requests = load_request_files() + for json_data in requests: + for flags in generate_flag_combinations(): + expected_codes = [200,201] + yield (json_data, flags, expected_codes) + +@pytest.mark.parametrize( + "json_data, flags, expected_codes", + list(generate_test_cases()) +) +def test_add_and_delete_flow(app, json_data, flags, expected_codes, set_flags, temp_sqlite_db): + with app.app_context(): + set_flags(flags) + + controller = NSController(controller_type="TFS") + api = Api(controller) + + # Añadir flujo + data, code = api.add_flow(json_data) + assert code in expected_codes, f"Flags en fallo: {flags}" + + # Eliminar flujo si fue creado + if code == 201 and isinstance(data, dict) and "slice_id" in data: + slice_id = data["slice_id"] + _, delete_code = api.delete_flows(slice_id=slice_id) + assert delete_code == 204, f"No se pudo eliminar el slice {slice_id}" + + diff --git a/src/tests/test_initialization.py b/src/tests/test_initialization.py new file mode 100644 index 0000000..c51cc06 --- /dev/null +++ b/src/tests/test_initialization.py @@ -0,0 +1,37 @@ +import pytest + +# Importa tu clase (ajusta el nombre del módulo si es distinto) +from src.main import NSController + +def test_init_default_values(): + """Test that default initialization sets expected values.""" + controller = NSController() + + # Atributo configurable + assert controller.controller_type == "TFS" + + # Atributos internos + assert controller.path == "" + assert controller.response == [] + assert controller.start_time == 0 + assert controller.end_time == 0 + assert controller.setup_time == 0 + +@pytest.mark.parametrize("controller_type", ["TFS", "IXIA", "custom"]) +def test_init_controller_type(controller_type): + """Test initialization with different controller types.""" + controller = NSController(controller_type=controller_type) + assert controller.controller_type == controller_type + +def test_init_independence_between_instances(): + """Test that each instance has independent state (mutable attrs).""" + c1 = NSController() + c2 = NSController() + + # Modifico una lista en una instancia + c1.response.append("test-response") + + # La otra instancia no debería verse afectada + assert c2.response == [] + assert c1.response == ["test-response"] + diff --git a/src/tests/test_mapper.py b/src/tests/test_mapper.py new file mode 100644 index 0000000..219923f --- /dev/null +++ b/src/tests/test_mapper.py @@ -0,0 +1,639 @@ +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 + + +@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 + } + + result = mapper(sample_ietf_intent) + + assert result is 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 + + result = mapper(sample_ietf_intent) + + assert result == {"path": "node1->node2->node3"} + mock_planner_instance.planner.assert_called_once_with(sample_ietf_intent, "ENERGY") + + @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 + + result = mapper(sample_ietf_intent) + + # Verify realizer was called to READ NRP view + assert mock_realizer.call_args_list[0] == call(None, True, "READ") + assert result is 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 + + result = mapper(sample_ietf_intent) + + assert result is 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 + + result = mapper(sample_ietf_intent) + + # 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 + + result = mapper(sample_ietf_intent) + + # Planner should be called and return the result + assert result == {"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 + + result = mapper(sample_ietf_intent) + + # 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 = [] + + mapper(sample_ietf_intent) + + # 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 + + mapper(sample_ietf_intent) + + # 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 + + result = mapper(sample_ietf_intent) + + # 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 + + result = mapper(sample_ietf_intent) + + assert result == 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: + result = mapper(sample_ietf_intent) + 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 + } + }] + } + } + } + + try: + mapper(invalid_intent) + 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 t ypes.""" + 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 \ No newline at end of file diff --git a/src/tests/test_nbi_processor.py b/src/tests/test_nbi_processor.py new file mode 100644 index 0000000..ef13494 --- /dev/null +++ b/src/tests/test_nbi_processor.py @@ -0,0 +1,222 @@ +import pytest +from unittest.mock import patch +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(): + 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(): + # Estructura mínima consistente con translator + return { + "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 + } + }], + }, + "subnetB": { + "EpTransport": ["EpTransport ep3", "EpTransport ep4"], + }, + "EpTransport ep1": { + "qosProfile": "qosA", + "EpApplicationRef": ["EP_N2 epRef1"], + "logicalInterfaceInfo": {"logicalInterfaceType": "typeA", "logicalInterfaceId": "idA"}, + "IpAddress": "1.1.1.1", + "NextHopInfo": "NH1", + }, + "EpTransport ep2": { + "qosProfile": "qosB", + "EpApplicationRef": ["EP_N2 epRef2"], + "logicalInterfaceInfo": {"logicalInterfaceType": "typeB", "logicalInterfaceId": "idB"}, + "IpAddress": "2.2.2.2", + "NextHopInfo": "NH2", + }, + "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"}, + "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"}, + } + + +@pytest.fixture +def fake_template(): + # Plantilla mínima para que el traductor funcione + return { + "ietf-network-slice-service:network-slice-services": { + "slo-sle-templates": { + "slo-sle-template": [ + {"id": "", "slo-policy": {"metric-bound": []}} + ] + }, + "slice-service": [ + { + "id": "", + "description": "", + "slo-sle-policy": {}, + "sdps": {"sdp": [ + {"service-match-criteria": {"match-criterion": [{}]}, "attachment-circuits": {"attachment-circuit": [{"sdp-peering": {}}]}}, + {"service-match-criteria": {"match-criterion": [{}]}, "attachment-circuits": {"attachment-circuit": [{"sdp-peering": {}}]}} + ]}, + "connection-groups": {"connection-group": [{}]}, + } + ], + } + } + + +# ---------- 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": {}} + result = nbi_processor(gpp_intent) + assert isinstance(result, list) + 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({}) + + +# ---------- 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 + result = translator(gpp_intent, "subnetA") + + assert isinstance(result, dict) + assert "ietf-network-slice-service:network-slice-services" in result + + slice_service = result["ietf-network-slice-service:network-slice-services"]["slice-service"][0] + assert slice_service["id"].startswith("slice-service-") + assert "description" in slice_service + assert slice_service["slo-sle-policy"]["slo-sle-template"] == "qosA" # viene del ep1 + +import re +import uuid + + +# ---------- Extra detect_format ---------- + +@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(): + # Si tiene IETF y 3GPP, debe priorizar IETF + 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): + # Quitar networkSliceSubnetRef debería provocar ValueError en translator loop + broken = gpp_intent.copy() + broken["RANSliceSubnet1"] = {} # no tiene "networkSliceSubnetRef" + with pytest.raises(KeyError): + nbi_processor(broken) + + +# ---------- 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"] + metric_types = {m["metric-type"] for m in metrics} + assert "one-way-delay-maximum" in metric_types + assert "one-way-bandwidth" in metric_types + + +@patch("src.nbi_processor.translator.load_template") +def test_translator_empty_profile(mock_load_template, gpp_intent, fake_template): + mock_load_template.return_value = fake_template + gpp_intent["subnetA"]["SliceProfileList"] = [{}] # vacío + 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"] + assert metrics == [] # no debería añadir nada + +@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 + result = translator(gpp_intent, "subnetA") + slice_service = result["ietf-network-slice-service:network-slice-services"]["slice-service"][0] + + sdp0 = slice_service["sdps"]["sdp"][0] + assert sdp0["node-id"] == "ep1" + assert re.match(r"^\d+\.\d+\.\d+\.\d+$", sdp0["attachment-circuits"]["attachment-circuit"][0]["ac-ipv4-address"]) + assert "target-connection-group-id" in sdp0["service-match-criteria"]["match-criterion"][0] + + sdp1 = slice_service["sdps"]["sdp"][1] + assert sdp1["node-id"] == "ep2" + assert sdp1["attachment-circuits"]["attachment-circuit"][0]["sdp-peering"]["peer-sap-id"].startswith("NH") + + +@patch("src.nbi_processor.translator.load_template") +def test_translator_with_single_endpoint_should_fail(mock_load_template, gpp_intent, fake_template): + mock_load_template.return_value = fake_template + gpp_intent["subnetA"]["EpTransport"] = ["EpTransport ep1"] # solo uno + with pytest.raises(IndexError): + translator(gpp_intent, "subnetA") diff --git a/src/tests/test_utils.py b/src/tests/test_utils.py new file mode 100644 index 0000000..7b887a4 --- /dev/null +++ b/src/tests/test_utils.py @@ -0,0 +1,182 @@ +import json +import pytest +import os + +from src.utils.load_template import load_template +from src.utils.dump_templates import dump_templates +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): + """Crea un archivo JSON temporal válido y devuelve su ruta y contenido.""" + data = {"name": "test"} + file_path = tmp_path / "template.json" + file_path.write_text(json.dumps(data)) + return file_path, data + + +def test_load_template_ok(tmp_json_file): + """Debe cargar correctamente un JSON válido.""" + file_path, expected = tmp_json_file + result = load_template(str(file_path)) + assert result == expected + + +def test_load_template_invalid(tmp_path): + """Debe devolver un response con error si el JSON es inválido.""" + bad_file = tmp_path / "bad.json" + bad_file.write_text("{invalid json}") + + result, code = load_template(str(bad_file)) + assert code == 500 + assert result["success"] is False + assert "Template loading error" in result["error"] + +def test_dump_templates_enabled(monkeypatch, tmp_path): + """Debe volcar múltiples JSON correctamente en src/templates si DUMP_TEMPLATES está activado.""" + templates_dir = tmp_path / "src" / "templates" + templates_dir.mkdir(parents=True) + + monkeypatch.setattr("src.utils.dump_templates.TEMPLATES_PATH", str(templates_dir)) + + app = Flask(__name__) + app.config["DUMP_TEMPLATES"] = True + + with app.app_context(): + nbi = {"nbi": 1} + ietf = {"ietf": 2} + realizer = {"realizer": 3} + + dump_templates(nbi, ietf, realizer) + + for name, data in [("nbi_template.json", nbi), ("ietf_template.json", ietf), ("realizer_template.json", realizer)]: + file_path = templates_dir / name + assert file_path.exists() + assert json.loads(file_path.read_text()) == data + +def test_dump_templates_disabled(monkeypatch, tmp_path): + """No debe escribir nada en src/templates si DUMP_TEMPLATES está desactivado.""" + templates_dir = tmp_path / "src" / "templates" + templates_dir.mkdir(parents=True) + + monkeypatch.setattr("src.utils.dump_templates.TEMPLATES_PATH", str(templates_dir)) + + app = Flask(__name__) + app.config["DUMP_TEMPLATES"] = False + + with app.app_context(): + dump_templates({"nbi": 1}, {"ietf": 2}, {"realizer": 3}) + + for name in ["nbi_template.json", "ietf_template.json", "realizer_template.json"]: + assert not (templates_dir / name).exists() + +def test_send_response_success(): + """Debe devolver success=True y code=200 si el resultado es True.""" + resp, code = send_response(True, data={"k": "v"}) + assert code == 200 + assert resp["success"] is True + assert resp["data"]["k"] == "v" + assert resp["error"] is None + + +def test_send_response_error(): + """Debe devolver success=False y code=400 si el resultado es False.""" + resp, code = send_response(False, message="fallo") + assert code == 400 + assert resp["success"] is False + assert resp["data"] is None + assert "fallo" in resp["error"] + +def ietf_intent(): + """Intento válido en formato IETF simplificado.""" + return { + "ietf-network-slice-service:network-slice-services": { + "slo-sle-templates": { + "slo-sle-template": [ + { + "id": "qos1", + "slo-policy": { + "metric-bound": [ + { + "metric-type": "one-way-bandwidth", + "metric-unit": "kbps", + "bound": 1000 + } + ], + "availability": 99.9, + "mtu": 1500 + } + } + ] + }, + "slice-service": [ + { + "id": "slice-test-1", + "sdps": { + "sdp": [ + { + "id": "CU", + "sdp-ip-address": "10.0.0.1", + "service-match-criteria": { + "match-criterion": [{"match-type": "vlan", "value": "100"}] + }, + }, + { + "id": "DU", + "sdp-ip-address": "10.0.0.2", + "service-match-criteria": { + "match-criterion": [{"match-type": "vlan", "value": "100"}] + }, + }, + ] + }, + } + ], + } + } + + +def test_build_response_ok(): + """Debe construir correctamente el response a partir de un intent IETF válido.""" + intent = ietf_intent() + response = [] + result = build_response(intent, response) + + assert isinstance(result, list) + assert len(result) == 1 + + slice_data = result[0] + assert slice_data["id"] == "slice-test-1" + assert slice_data["source"] == "CU" + assert slice_data["destination"] == "DU" + assert slice_data["vlan"] == "100" + + # Validar 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"] == "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) + + +def test_build_response_empty_policy(): + """Debe devolver lista sin constraints si slo-policy está vacío.""" + intent = ietf_intent() + intent["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["slo-policy"] = {} + response = [] + result = build_response(intent, response) + + assert isinstance(result, list) + assert len(result[0]["requirements"]) == 0 + + +def test_build_response_invalid_intent(): + """Debe fallar limpiamente si el intent no tiene la estructura esperada.""" + bad_intent = {} + response = [] + try: + result = build_response(bad_intent, response) + except Exception: + result = [] + assert result == [] -- GitLab From 402f9a06003c6141fc427f8272ab59f348f34ed3 Mon Sep 17 00:00:00 2001 From: velazquez Date: Mon, 20 Oct 2025 09:44:40 +0200 Subject: [PATCH 15/26] -Update gitlab-ci --- .gitlab-ci.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index edd18a7..79e6500 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,16 +1,11 @@ image: python:3.12 stages: - - build - test before_script: - pip3 install -r requirements.txt -build: - stage: build - script: - - python3 app.py test: stage: test script: -- GitLab From a95fabe95be633f0934bdbddab8fa973921180bb Mon Sep 17 00:00:00 2001 From: velazquez Date: Thu, 23 Oct 2025 17:35:19 +0200 Subject: [PATCH 16/26] Updates code commentaries --- Dockerfile | 12 +- app.py | 4 +- src/api/main.py | 12 +- src/database/db.py | 57 ++++ src/main.py | 8 +- src/mapper/main.py | 7 +- src/nbi_processor/main.py | 1 - src/nbi_processor/translator.py | 10 +- src/planner/energy_planner/energy.py | 152 ++++++++-- src/planner/hrat_planner/hrat.py | 33 ++- src/planner/planner.py | 16 ++ .../tfs_optical_planner/tfs_optical.py | 269 +++++++++++++----- src/realizer/e2e/e2e_connect.py | 7 + src/realizer/ixia/ixia_connect.py | 12 +- src/realizer/main.py | 6 + src/realizer/select_way.py | 13 +- src/realizer/send_controller.py | 32 ++- src/realizer/tfs/helpers/cisco_connector.py | 35 ++- src/realizer/tfs/helpers/tfs_connector.py | 33 ++- src/realizer/tfs/main.py | 11 + src/realizer/tfs/service_types/tfs_l2vpn.py | 1 + src/realizer/tfs/service_types/tfs_l3vpn.py | 1 + src/realizer/tfs/tfs_connect.py | 12 +- src/utils/build_response.py | 32 ++- src/utils/dump_templates.py | 31 +- src/utils/safe_get.py | 9 +- src/utils/send_response.py | 8 +- src/webui/gui.py | 45 ++- 28 files changed, 714 insertions(+), 155 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1d520d5..313b5e3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,23 +16,23 @@ FROM python:3.12-slim -# Establece el directorio de trabajo +# Stablish woking directory WORKDIR /app -# Instala dependencias del sistema +# Install system dependencies RUN apt-get update -qq && \ apt-get install -y -qq git python3-dev && \ apt-get clean && rm -rf /var/lib/apt/lists/* -# Copia el contenido del proyecto +# Copy project content COPY . /app -# Instala dependencias de Python +# Intall python dependencies RUN pip install --upgrade pip && \ pip install -r requirements.txt -# Expone el puerto +# Expose port EXPOSE 8081 -# Comando de inicio +# Init command ENTRYPOINT ["python3", "app.py"] diff --git a/app.py b/app.py index ceec6ae..c7c0695 100644 --- a/app.py +++ b/app.py @@ -27,7 +27,7 @@ from src.config.config import create_config from src.database.db import init_db def create_app(): - """Factory para crear la app Flask con la configuración cargada""" + """Create Flask application with configured API and namespaces.""" init_db() app = Flask(__name__) app = create_config(app) @@ -59,8 +59,6 @@ def create_app(): return app - -# Solo arrancamos el servidor si ejecutamos el script directamente if __name__ == "__main__": app = create_app() app.run(host="0.0.0.0", port=NSC_PORT, debug=True) diff --git a/src/api/main.py b/src/api/main.py index 441bed0..47b6344 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -39,8 +39,8 @@ class Api: POST /slice Raises: - ValueError: If no transport network slices are found - Exception: For unexpected errors during slice creation process + RuntimeError: If there is no content to process + Exception: For unexpected errors """ try: result = self.slice_service.nsc(intent) @@ -82,7 +82,7 @@ class Api: Raises: ValueError: If no transport network slices are found - Exception: For unexpected errors during file processing + Exception: For unexpected errors """ try: # Read slice database from JSON file @@ -120,6 +120,8 @@ class Api: API Endpoint: PUT /slice/{id} + Raises: + Exception: For unexpected errors """ try: result = self.slice_service.nsc(intent, slice_id) @@ -150,14 +152,14 @@ class Api: Defaults to None. Returns: - dict: Response indicating successful deletion or error details + 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 during deletion process + Exception: For unexpected errors Notes: - If controller_type is TFS, attempts to delete from Teraflow diff --git a/src/database/db.py b/src/database/db.py index 4ace056..341d79e 100644 --- a/src/database/db.py +++ b/src/database/db.py @@ -19,6 +19,9 @@ DB_NAME = "slice.db" # 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(""" @@ -33,12 +36,24 @@ def init_db(): # Save data to the database def save_data(slice_id: str, intent_dict: dict, controller: str): + """ + 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 + + Raises: + 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 except sqlite3.IntegrityError: raise ValueError(f"Slice with id '{slice_id}' already exists.") finally: @@ -46,6 +61,17 @@ def save_data(slice_id: str, intent_dict: dict, controller: str): # Update data in the database def update_data(slice_id: str, new_intent_dict: dict, controller: str): + """ + 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 + + Raises: + 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() @@ -59,6 +85,15 @@ def update_data(slice_id: str, new_intent_dict: dict, controller: str): # Delete data from the database def delete_data(slice_id: str): + """ + Delete a slice entry from the database. + + Args: + slice_id (str): Unique identifier for the slice to delete + + Raises: + 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,)) @@ -71,6 +106,19 @@ def delete_data(slice_id: str): # Get data from the database def get_data(slice_id: str) -> dict[str, dict, str]: + """ + Retrieve a specific slice entry from the database. + + Args: + slice_id (str): Unique identifier for the slice to retrieve + + Returns: + dict: Slice data including slice_id, intent (as dict), and controller + + Raises: + ValueError: If no slice is found with the given slice_id + Exception: For JSON decoding errors + """ conn = sqlite3.connect(DB_NAME) cursor = conn.cursor() cursor.execute("SELECT * FROM slice WHERE slice_id = ?", (slice_id,)) @@ -92,6 +140,12 @@ def get_data(slice_id: str) -> dict[str, dict, str]: # Get all slices def get_all_data() -> dict[str, dict, str]: + """ + 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") @@ -107,6 +161,9 @@ def get_all_data() -> dict[str, dict, str]: ] def delete_all_data(): + """ + Delete all slice entries from the database. + """ conn = sqlite3.connect(DB_NAME) cursor = conn.cursor() cursor.execute("DELETE FROM slice") diff --git a/src/main.py b/src/main.py index 5354c38..4ef517d 100644 --- a/src/main.py +++ b/src/main.py @@ -50,10 +50,10 @@ class NSController: Attributes: controller_type (str): Flag for Teraflow or Ixia upload - answer (dict): Stores slice creation responses + response (dict): Stores slice creation responses start_time (float): Tracks slice setup start time end_time (float): Tracks slice setup end time - need_l2vpn_support (bool): Flag for additional L2VPN configuration support + setup_time (float): Total time taken for slice setup in milliseconds """ self.controller_type = controller_type @@ -74,14 +74,14 @@ class NSController: 4. Store slice information 5. Map slice to Network Resource Pool (NRP) 6. Realize slice configuration - 7. Upload to Teraflow (optional) + 7. Send configuration to network controllers Args: intent_json (dict): Network slice intent in 3GPP or IETF format slice_id (str, optional): Existing slice identifier for modification Returns: - tuple: Response status and HTTP status code + dict: Contains slice creation responses and setup time in milliseconds """ # Start performance tracking diff --git a/src/mapper/main.py b/src/mapper/main.py index 27a9ef4..9e0a4c7 100644 --- a/src/mapper/main.py +++ b/src/mapper/main.py @@ -25,17 +25,18 @@ def mapper(ietf_intent): Map an IETF network slice intent to the most suitable Network Resource Partition (NRP). This method: - 1. Retrieves the current NRP view + 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. - Raises: - Exception: If no suitable NRP is found and slice creation fails. + Returns: + dict or None: Optimal path if planner is enabled; otherwise, None. """ if current_app.config["NRP_ENABLED"]: # Retrieve NRP view diff --git a/src/nbi_processor/main.py b/src/nbi_processor/main.py index 7da1d1e..cfa55dd 100644 --- a/src/nbi_processor/main.py +++ b/src/nbi_processor/main.py @@ -23,7 +23,6 @@ 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. - Supports multiple slice subnets in 3GPP format. Args: intent_json (dict): Input network slice intent in either 3GPP or IETF format. diff --git a/src/nbi_processor/translator.py b/src/nbi_processor/translator.py index 1b1caae..6f23d19 100644 --- a/src/nbi_processor/translator.py +++ b/src/nbi_processor/translator.py @@ -25,17 +25,17 @@ def translator(gpp_intent, subnet): 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 - - 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 """ # Load IETF template and create a copy to modify ietf_i = load_template(os.path.join(TEMPLATES_PATH, "ietf_template_empty.json")) diff --git a/src/planner/energy_planner/energy.py b/src/planner/energy_planner/energy.py index c44a210..c6d33c8 100644 --- a/src/planner/energy_planner/energy.py +++ b/src/planner/energy_planner/energy.py @@ -20,7 +20,32 @@ from flask import current_app from src.utils.safe_get import safe_get -def energy_planner(intent): +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"]) @@ -31,10 +56,12 @@ def energy_planner(intent): 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), @@ -76,12 +103,21 @@ def energy_planner(intent): } } } + 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) - # POST /sss/v1/slice/compute 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": { @@ -108,8 +144,9 @@ def energy_planner(intent): }, "error": None } + slice_output = simulate_slice_output(slice_input) - # Mostrar resultado + # Build optimal path from PCE response optimal_path.append(source["name"]) for link in slice_output["slice"]["links"]: for hop in link["path"]["hops"]: @@ -118,8 +155,9 @@ def energy_planner(intent): 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), - # Solo asigna los DLOS que existan, el resto a None + 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), @@ -135,29 +173,77 @@ def energy_planner(intent): return optimal_path + def retrieve_energy(): - # TODO : Implement the logic to retrieve energy consumption data from controller - # Taking it from static file + """ + 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 + # 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 - # Taking it from static file + # 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 @@ -203,11 +289,11 @@ def calculate_optimal_path(topology, energy_metrics, source, destination, dlos): logging.debug(f"Added link: {src} -> {dst} with weight {node_data_map[dst]['weight']}") # Dijkstra's algorithm with restrictions - queue = [(0, source, [], 0, 0, 0, 1)] # (accumulated cost, current node, path, sum_ec, sum_ce, sum_ee, min_ure) + # 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) @@ -227,6 +313,7 @@ def calculate_optimal_path(topology, energy_metrics, source, destination, dlos): 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 @@ -246,7 +333,7 @@ def calculate_optimal_path(topology, energy_metrics, source, destination, dlos): for neighbor, weight in graph.get(node, []): if neighbor not in visited: - logging.debug(f"Qeue -> neighbour: {neighbor}, weight: {weight}") + logging.debug(f"Queue -> neighbour: {neighbor}, weight: {weight}") heapq.heappush(queue, ( cost + weight, neighbor, @@ -256,14 +343,41 @@ def calculate_optimal_path(topology, energy_metrics, source, destination, dlos): 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): +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): """ - Calcula el peso de un nodo con la fórmula: - w(v) = α·EC + β·CE + γ/EE + δ·(1 - URE) + 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 @@ -276,6 +390,4 @@ def compute_node_weight(ec, ce, ee, ure, total_power_supply, total_power_boards, green_index = power_total * time / 1000 * (1 - ure) * ce - return green_index - - + return green_index \ No newline at end of file diff --git a/src/planner/hrat_planner/hrat.py b/src/planner/hrat_planner/hrat.py index 363d8fe..2686af2 100644 --- a/src/planner/hrat_planner/hrat.py +++ b/src/planner/hrat_planner/hrat.py @@ -17,13 +17,43 @@ import logging, requests def hrat_planner(data: str, ip: str, action: str = "create") -> dict: - + """ + Interface with the HRAT (Hierarchical Resource Allocation Tool) for transport network slice management. + + This function communicates with an external HRAT service to create or delete + transport network slices, handling optical layer provisioning and IP layer + configuration. + + Args: + data (str or dict): Network slice UUID for deletion, or full intent data for creation + ip (str): IP address of the HRAT service + action (str, optional): Operation to perform - "create" or "delete". Defaults to "create" + + Returns: + dict: Response from HRAT service containing: + - network-slice-uuid: Unique identifier for the slice + - viability: Boolean indicating if slice is viable + - actions: List of configuration actions including: + * CREATE_OPTICAL_SLICE + * PROVISION_MEDIA_CHANNEL_OLS_PATH + * ACTIVATE_TRANSCEIVER + * CONFIG_VPNL3 + + Notes: + - On timeout or connection errors, returns static fallback data + - HRAT service expected at port 9090 + - Timeout set to 15 seconds for all requests + + Raises: + requests.exceptions.RequestException: On HTTP request failures (logged, not raised) + """ data_static = {'network-slice-uuid': 'ecoc25-short-path-a7764e55-9bdb-4e38-9386-02ff47a33225', 'viability': True, 'actions': [{'type': 'CREATE_OPTICAL_SLICE', 'layer': 'OPTICAL', 'content': {'tenant-uuid': 'ea4ade23-1444-4f93-aabc-4fcbe2ae74dd', 'service-interface-point': [{'uuid': 'e7444187-119b-5b2e-8a60-ee26b30c441a'}, {'uuid': 'b32b1623-1f64-59d2-8148-b035a8f77625'}], 'node': [{'uuid': '68eb48ac-b686-5653-bdaf-7ccaeecd0709', 'owned-node-edge-point': [{'uuid': '7fd74b80-2b5a-55e2-8ef7-82bf589c9591', 'media-channel-node-edge-point-spec': {'mc-pool': {'supportable-spectrum': [{'lower-frequency': '191325000', 'upper-frequency': '192225000'}, {'lower-frequency': '194325000', 'upper-frequency': '195225000'}]}}}, {'uuid': '7b9f0b65-2387-5352-bc36-7173639463f0', 'media-channel-node-edge-point-spec': {'mc-pool': {'supportable-spectrum': [{'lower-frequency': '191325000', 'upper-frequency': '192225000'}, {'lower-frequency': '194325000', 'upper-frequency': '195225000'}]}}}]}, {'uuid': 'f55351ce-a5c8-50a7-b506-95b40e08bce4', 'owned-node-edge-point': [{'uuid': 'da6d924d-9cb4-5add-817d-f83e910beb2e', 'media-channel-node-edge-point-spec': {'mc-pool': {'supportable-spectrum': [{'lower-frequency': '191325000', 'upper-frequency': '192225000'}, {'lower-frequency': '194325000', 'upper-frequency': '195225000'}]}}}, {'uuid': '577ec899-ad92-5a19-a140-405a3cdbaa17', 'media-channel-node-edge-point-spec': {'mc-pool': {'supportable-spectrum': [{'lower-frequency': '191325000', 'upper-frequency': '192225000'}, {'lower-frequency': '194325000', 'upper-frequency': '195225000'}]}}}]}], 'link': [{'uuid': '3beef785-bb26-5741-af10-c5e1838c1701'}, {'uuid': '6144c664-246a-58ed-bf0a-7ec4286625da'}]}, 'controller-uuid': 'TAPI Optical Controller'}, {'type': 'PROVISION_MEDIA_CHANNEL_OLS_PATH', 'layer': 'OPTICAL', 'content': {'ols-path-uuid': 'cfeae4cb-c305-4884-9945-8b0c0f040c98', 'src-sip-uuid': 'e7444187-119b-5b2e-8a60-ee26b30c441a', 'dest-sip-uuid': 'b32b1623-1f64-59d2-8148-b035a8f77625', 'direction': 'BIDIRECTIONAL', 'layer-protocol-name': 'PHOTONIC_MEDIA', 'layer-protocol-qualifier': 'tapi-photonic-media:PHOTONIC_LAYER_QUALIFIER_MC', 'bandwidth-ghz': 100, 'link-uuid-path': ['3beef785-bb26-5741-af10-c5e1838c1701'], 'lower-frequency-mhz': '194700000', 'upper-frequency-mhz': '194800000', 'adjustment-granularity': 'G_6_25GHZ', 'grid-type': 'FLEX'}, 'controller-uuid': 'TAPI Optical Controller', 'tenant-uuid': 'ea4ade23-1444-4f93-aabc-4fcbe2ae74dd'}, {'type': 'ACTIVATE_TRANSCEIVER', 'layer': 'OPTICAL', 'content': {'node-uuid': 'Phoenix-1', 'termination-point-uuid': 'Ethernet110', 'transceiver-type': 'CFP2', 'frequency-ghz': 194700.0, 'spectrum-width-ghz': 100.0, 'tx-power-dbm': 0.0}, 'controller-uuid': 'IP Controller'}, {'type': 'ACTIVATE_TRANSCEIVER', 'layer': 'OPTICAL', 'content': {'node-uuid': 'Phoenix-2', 'termination-point-uuid': 'Ethernet220', 'transceiver-type': 'CFP2', 'frequency-ghz': 194700.0, 'spectrum-width-ghz': 100.0, 'tx-power-dbm': 0.0}, 'controller-uuid': 'IP Controller'}, {'type': 'CONFIG_VPNL3', 'layer': 'IP', 'content': {'tunnel-uuid': '9aae851a-eea9-4a28-969f-0e2c2196e936', 'src-node-uuid': 'Phoenix-1', 'src-ip-address': '10.10.1.1', 'src-ip-mask': '/24', 'src-vlan-id': 100, 'dest-node-uuid': 'Phoenix-2', 'dest-ip-address': '10.10.2.1', 'dest-ip-mask': '/24', 'dest-vlan-id': 100}, 'controller-uuid': 'IP Controller'}]} url = f'http://{ip}:9090/api/resource-allocation/transport-network-slice-l3' headers = {'Content-Type': 'application/json'} try: if action == "delete": + # Build deletion payload with slice ID payload = { "ietf-network-slice-service:network-slice-services": { "slice-service": [ @@ -35,6 +65,7 @@ def hrat_planner(data: str, ip: str, action: str = "create") -> dict: } response = requests.delete(url, headers=headers, json=payload, timeout=15) elif action == "create": + # Send creation request with full intent data response = requests.post(url, headers=headers, json=data, timeout=15) else: logging.error("Invalid action. Use 'create' or 'delete'.") diff --git a/src/planner/planner.py b/src/planner/planner.py index b5ba22d..9797834 100644 --- a/src/planner/planner.py +++ b/src/planner/planner.py @@ -22,6 +22,10 @@ from flask import current_app 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. """ @@ -29,9 +33,21 @@ class Planner: def planner(self, intent, type): """ Plan the optimal path for a network slice based on energy consumption and topology. + + Args: + intent (dict): Network slice intent + type (str): Planner type (ENERGY, HRAT, TFS_OPTICAL) + + Returns: + dict or None: Planner result or None if type is invalid """ + # 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 TFS optical planner with configured IP elif type == "TFS_OPTICAL": return tfs_optical_planner(intent, current_app.config["OPTICAL_PLANNER_IP"], action = "create") + # Return None if planner type is unsupported else : return None diff --git a/src/planner/tfs_optical_planner/tfs_optical.py b/src/planner/tfs_optical_planner/tfs_optical.py index c8034cf..2489915 100644 --- a/src/planner/tfs_optical_planner/tfs_optical.py +++ b/src/planner/tfs_optical_planner/tfs_optical.py @@ -13,48 +13,88 @@ # limitations under the License. # This file is an original contribution from Telefonica Innovación Digital S.L. + import logging import requests import os import uuid import json -from src.config.constants import TEMPLATES_PATH +from src.config.constants import TEMPLATES_PATH from src.utils.safe_get import safe_get + def tfs_optical_planner(intent, ip: str, action: str = "create") -> dict: + """ + Plan optical layer configuration for TeraFlow SDN network slices. + + This function computes optical paths and generates configuration rules for + point-to-multipoint (P2MP) optical connections, including transceiver + activation and Layer 3 VPN configuration. + + Args: + intent (dict or str): For create action - network slice intent with service + delivery points. For delete action - slice ID string + ip (str): IP address of the optical path computation service + action (str, optional): Operation to perform - "create" or "delete". + Defaults to "create" + + Returns: + dict or None: Configuration rules containing: + - network-slice-uuid: Unique identifier + - viability: Boolean indicating success + - actions: List of provisioning actions for: + * XR_AGENT_ACTIVATE_TRANSCEIVER (optical layer) + * CONFIG_VPNL3 (IP layer) + Returns None if source/destination not found or service unavailable + + Notes: + - Supports P2MP (Point-to-Multipoint) connectivity + - Computes optical paths using external TFS optical service + - Configures digital subcarrier groups for wavelength division + - Port 31060 used for optical path computation API + + Raises: + requests.exceptions.RequestException: On connection errors (logged, returns None) + """ if action == 'delete': - logging.debug("DELETE REQUEST RECEIVED: %s", intent) - with open(os.path.join(TEMPLATES_PATH, "slice.db"), 'r', encoding='utf-8') as file: - slices = json.load(file) + logging.debug("DELETE REQUEST RECEIVED: %s", intent) + # Load slice database to retrieve intent for deletion + with open(os.path.join(TEMPLATES_PATH, "slice.db"), 'r', encoding='utf-8') as file: + slices = json.load(file) - for slice_obj in slices: - if 'slice_id' in slice_obj and slice_obj['slice_id'] == intent: - logging.debug("Slice found: %s", slice_obj['slice_id']) - source = None - destination = None - services = slice_obj['intent']['ietf-network-slice-service:network-slice-services']['slice-service'] - for service in services: - c_groups = service.get("connection-groups", {}).get("connection-group", []) - for cg in c_groups: - constructs = cg.get("connectivity-construct", []) - for construct in constructs: - if "p2mp-sdp" in construct: - source = construct["p2mp-sdp"]["root-sdp-id"] - destination = construct["p2mp-sdp"]["leaf-sdp-id"] - break - if source and destination: + for slice_obj in slices: + if 'slice_id' in slice_obj and slice_obj['slice_id'] == intent: + logging.debug("Slice found: %s", slice_obj['slice_id']) + source = None + destination = None + services = slice_obj['intent']['ietf-network-slice-service:network-slice-services']['slice-service'] + + # Extract source and destination from P2MP structure + for service in services: + c_groups = service.get("connection-groups", {}).get("connection-group", []) + for cg in c_groups: + constructs = cg.get("connectivity-construct", []) + for construct in constructs: + if "p2mp-sdp" in construct: + source = construct["p2mp-sdp"]["root-sdp-id"] + destination = construct["p2mp-sdp"]["leaf-sdp-id"] break - response = send_request(source, destination) - summary = { - "source": source, - "destination": destination, - "connectivity-service": response - } - rules = generate_rules(summary, intent, action) + if source and destination: + break + + response = send_request(source, destination) + summary = { + "source": source, + "destination": destination, + "connectivity-service": response + } + rules = generate_rules(summary, intent, action) else: + # Extract source and destination from creation intent services = intent["ietf-network-slice-service:network-slice-services"]["slice-service"] source = None destination = None + for service in services: c_groups = service.get("connection-groups", {}).get("connection-group", []) for cg in c_groups: @@ -66,6 +106,7 @@ def tfs_optical_planner(intent, ip: str, action: str = "create") -> dict: break if source and destination: break + response = None if source and destination: response = send_request(source, destination, ip) @@ -77,13 +118,35 @@ def tfs_optical_planner(intent, ip: str, action: str = "create") -> dict: "connectivity-service": response } logging.debug(summary) - rules = generate_rules(summary, intent,action) + rules = generate_rules(summary, intent, action) else: logging.warning(f"No rules generated. Skipping optical planning.") return None + return rules + def send_request(source, destination, ip): + """ + Send path computation request to the optical TFS service. + + Computes point-to-multipoint optical paths using the TAPI path computation API. + + Args: + source (str or list): Root node identifier(s) for P2MP path + destination (str or list): Leaf node identifier(s) for P2MP path + ip (str): IP address of the TFS optical service + + Returns: + dict or None: Path computation response containing connectivity service + with optical connection attributes, or None on failure + + Notes: + - API endpoint: POST /OpticalTFS/restconf/operations/tapi-path-computation:compute-p2mp + - Assumes 100 Gbps bitrate, bidirectional transmission + - Band width of 200, with 4 subcarriers per source + - 15 second timeout for requests + """ url = f"http://{ip}:31060/OpticalTFS/restconf/operations/tapi-path-computation:compute-p2mp" headers = { @@ -91,6 +154,7 @@ def send_request(source, destination, ip): "Accept": "*/*" } + # Normalize source and destination to lists if isinstance(source, str): sources_list = [source] else: @@ -118,10 +182,34 @@ def send_request(source, destination, ip): logging.warning("Error connecting to the Optical Planner service. Skipping optical planning.") return None -def group_block(group, action, group_id_override=None, node = None): - active = "true" if action == 'create' else "false" + +def group_block(group, action, group_id_override=None, node=None): + """ + Generate a digital subcarrier group configuration block. + + Creates configuration for optical digital subcarriers, which are used for + wavelength division multiplexing in optical networks. + + Args: + group (dict): Subcarrier group data from path computation response + action (str): "create" to activate, "delete" to deactivate + group_id_override (int, optional): Override group ID. Defaults to None + node (str, optional): Node type - "leaf" for simplified config. Defaults to None + + Returns: + dict: Digital subcarrier group configuration with: + - digital_sub_carriers_group_id: Group identifier + - digital_sub_carrier_id: List of subcarrier configs with active status + + Notes: + - Leaf nodes use fixed 4 subcarriers (IDs 1-4) + - Non-leaf nodes use subcarrier IDs from computation response + """ + active = "true" if action == 'create' else "false" group_id = group_id_override if group_id_override is not None else group["digital_sub_carriers_group_id"] + if node == "leaf": + # Simplified configuration for leaf nodes return { "digital_sub_carriers_group_id": group_id, "digital_sub_carrier_id": [ @@ -129,9 +217,10 @@ def group_block(group, action, group_id_override=None, node = None): {'sub_carrier_id': 2, 'active': active}, {'sub_carrier_id': 3, 'active': active}, {'sub_carrier_id': 4, 'active': active} - ] - } + ] + } else: + # Full configuration based on computed path return { "digital_sub_carriers_group_id": group_id, "digital_sub_carrier_id": [ @@ -143,67 +232,103 @@ def group_block(group, action, group_id_override=None, node = None): ] } + def generate_rules(connectivity_service, intent, action): - src_name = connectivity_service.get("source", "FALTA VALOR") - dest_list = connectivity_service.get("destination", ["FALTA VALOR"]) - dest_str = ",".join(dest_list) + """ + Generate provisioning rules for optical and IP layer configuration. + + Transforms path computation results into concrete configuration actions + for transceivers and Layer 3 VPN setup. + + Args: + connectivity_service (dict): Path computation summary containing: + - source: Root node identifier + - destination: List of leaf node identifiers + - connectivity-service: Optical connection attributes + intent (dict): Original network slice intent with IP configuration + action (str): "create" or "delete" operation + + Returns: + list: Configuration rules with provisioning actions + + Notes: + - For create: Generates XR_AGENT_ACTIVATE_TRANSCEIVER and CONFIG_VPNL3 actions + - For delete: Generates DEACTIVATE_XR_AGENT_TRANSCEIVER actions + - Hub node uses channel-1 at 195000000 MHz + - Leaf nodes assigned specific channels (channel-1, channel-3, channel-5) + - Fixed VLAN ID of 500 for all connections + - Tunnel UUID generated from source-destination string + """ + src_name = connectivity_service.get("source", "FALTA VALOR") + dest_list = connectivity_service.get("destination", ["FALTA VALOR"]) + dest_str = ",".join(dest_list) config_rules = [] + # Generate deterministic UUID for tunnel based on endpoints network_slice_uuid_str = f"{src_name}_to_{dest_str}" tunnel_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, network_slice_uuid_str)) + provisionamiento = { "network-slice-uuid": network_slice_uuid_str, "viability": True, "actions": [] } + # Extract optical connection attributes from path computation attributes = connectivity_service["connectivity-service"]["tapi-connectivity:connectivity-service"]["connection"][0]["optical-connection-attributes"] groups = attributes["subcarrier-attributes"]["digital-subcarrier-group"] operational_mode = attributes["modulation"]["operational-mode"] + + # Build hub (root) configuration with all subcarrier groups hub_groups = [ group_block(group, action, group_id_override=index + 1) for index, group in enumerate(groups) ] hub = { - "name": "channel-1", + "name": "channel-1", "frequency": 195000000, "target_output_power": 0, "operational_mode": operational_mode, - "operation" : "merge", + "operation": "merge", "digital_sub_carriers_group": hub_groups } + # Build leaf configurations with specific frequencies per destination leaves = [] for dest, group in zip(connectivity_service["destination"], groups): + # Map destinations to specific channels and frequencies if dest == "T1.1": name = "channel-1" freq = 195006250 if dest == "T1.2": - name = "channel-3" - freq = 195018750 + name = "channel-3" + freq = 195018750 if dest == "T1.3": name = "channel-5" freq = 195031250 + leaf = { "name": name, "frequency": freq, "target_output_power": group["Tx-power"], "operational_mode": int(group["operational-mode"]), - "operation" : "merge", - "digital_sub_carriers_group": [group_block(group, action, group_id_override=1, node = "leaf")] + "operation": "merge", + "digital_sub_carriers_group": [group_block(group, action, group_id_override=1, node="leaf")] } - leaves.append(leaf) final_json = {"components": [hub] + leaves} + if action == 'create': + # Add transceiver activation action provisionamiento["actions"].append({ - "type": "XR_AGENT_ACTIVATE_TRANSCEIVER", - "layer": "OPTICAL", + "type": "XR_AGENT_ACTIVATE_TRANSCEIVER", + "layer": "OPTICAL", "content": final_json, "controller-uuid": "IPoWDM Controller" }) + # Extract IP configuration from intent for L3 VPN setup nodes = {} sdp_list = intent['ietf-network-slice-service:network-slice-services']['slice-service'][0]['sdps']['sdp'] @@ -213,52 +338,56 @@ def generate_rules(connectivity_service, intent, action): for ac in attachments: ip = ac.get('ac-ipv4-address', None) prefix = ac.get('ac-ipv4-prefix-length', None) - vlan = 500 + vlan = 500 # Fixed VLAN ID nodes[node] = { "ip-address": ip, - "ip-mask": prefix, - "vlan-id": vlan + "ip-mask": prefix, + "vlan-id": vlan } + # Add L3 VPN configuration action for P2MP topology provisionamiento["actions"].append({ "type": "CONFIG_VPNL3", "layer": "IP", "content": { - "tunnel-uuid": tunnel_uuid, - "src-node-uuid": src_name, - "src-ip-address": nodes[src_name]["ip-address"], - "src-ip-mask": str(nodes[src_name]["ip-mask"]), - "src-vlan-id": nodes[src_name]["vlan-id"], - "dest1-node-uuid": dest_list[0], + "tunnel-uuid": tunnel_uuid, + "src-node-uuid": src_name, + "src-ip-address": nodes[src_name]["ip-address"], + "src-ip-mask": str(nodes[src_name]["ip-mask"]), + "src-vlan-id": nodes[src_name]["vlan-id"], + "dest1-node-uuid": dest_list[0], "dest1-ip-address": nodes[dest_list[0]]["ip-address"], - "dest1-ip-mask": str(nodes[dest_list[0]]["ip-mask"]), - "dest1-vlan-id": nodes[dest_list[0]]["vlan-id"], - "dest2-node-uuid": dest_list[1], + "dest1-ip-mask": str(nodes[dest_list[0]]["ip-mask"]), + "dest1-vlan-id": nodes[dest_list[0]]["vlan-id"], + "dest2-node-uuid": dest_list[1], "dest2-ip-address": nodes[dest_list[1]]["ip-address"], - "dest2-ip-mask": str(nodes[dest_list[1]]["ip-mask"]), - "dest2-vlan-id": nodes[dest_list[1]]["vlan-id"], - "dest3-node-uuid": dest_list[2], + "dest2-ip-mask": str(nodes[dest_list[1]]["ip-mask"]), + "dest2-vlan-id": nodes[dest_list[1]]["vlan-id"], + "dest3-node-uuid": dest_list[2], "dest3-ip-address": nodes[dest_list[2]]["ip-address"], - "dest3-ip-mask": str(nodes[dest_list[2]]["ip-mask"]), - "dest3-vlan-id": nodes[dest_list[2]]["vlan-id"] - }, + "dest3-ip-mask": str(nodes[dest_list[2]]["ip-mask"]), + "dest3-vlan-id": nodes[dest_list[2]]["vlan-id"] + }, "controller-uuid": "IP Controller" }) config_rules.append(provisionamiento) else: + # For deletion, generate deactivation action nodes = [] nodes.append(src_name) - for dst in dest_list: nodes.append(dst) + for dst in dest_list: + nodes.append(dst) aux = tunnel_uuid + '-' + src_name + '-' + '-'.join(dest_list) + provisionamiento["actions"].append({ - "type": "DEACTIVATE_XR_AGENT_TRANSCEIVER", - "layer": "OPTICAL", - "content": final_json, - "controller-uuid": "IPoWDM Controller", - "uuid" : aux, - "nodes": nodes + "type": "DEACTIVATE_XR_AGENT_TRANSCEIVER", + "layer": "OPTICAL", + "content": final_json, + "controller-uuid": "IPoWDM Controller", + "uuid": aux, + "nodes": nodes }) config_rules.append(provisionamiento) - return config_rules + return config_rules \ No newline at end of file diff --git a/src/realizer/e2e/e2e_connect.py b/src/realizer/e2e/e2e_connect.py index b260dd5..368ac3d 100644 --- a/src/realizer/e2e/e2e_connect.py +++ b/src/realizer/e2e/e2e_connect.py @@ -17,5 +17,12 @@ from ..tfs.helpers.tfs_connector import tfs_connector def e2e_connect(requests, controller_ip): + """ + 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. + """ response = tfs_connector().webui_post(controller_ip, requests) return response \ No newline at end of file diff --git a/src/realizer/ixia/ixia_connect.py b/src/realizer/ixia/ixia_connect.py index d7eb008..456d23a 100644 --- a/src/realizer/ixia/ixia_connect.py +++ b/src/realizer/ixia/ixia_connect.py @@ -16,7 +16,17 @@ from .helpers.NEII_V4 import NEII_controller -def ixia_connect(requests, ixia_ip): # The IP should be sent by parameter +def ixia_connect(requests, ixia_ip): + """ + Connect to the IXIA NEII controller and send the requests. + + Args: + requests (dict): IXIA NEII requests + ixia_ip (str): IXIA NEII controller IP address + + Returns: + response (requests.Response): Response from the IXIA NEII controller + """ response = None neii_controller = NEII_controller(ixia_ip) for intent in requests["services"]: diff --git a/src/realizer/main.py b/src/realizer/main.py index d88a624..eb6908a 100644 --- a/src/realizer/main.py +++ b/src/realizer/main.py @@ -32,6 +32,12 @@ def realizer(ietf_intent, need_nrp=False, order=None, nrp=None, controller_type= 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. + + Returns: + dict: A realization request for the specified network slice type. """ if need_nrp: # Perform NRP-related operations diff --git a/src/realizer/select_way.py b/src/realizer/select_way.py index 2b9d2da..6d3cc53 100644 --- a/src/realizer/select_way.py +++ b/src/realizer/select_way.py @@ -24,16 +24,15 @@ def select_way(controller=None, way=None, ietf_intent=None, response=None, rules Determine the method of slice realization. Args: - controller (str): The controller to use for slice realization. + 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 - way (str): The type of technology to use. - Supported values: - - "L2VPN": Layer 2 Virtual Private Network - - "L3VPN": Layer 3 Virtual Private Network - - ietf_intent (dict): IETF-formatted network slice intent. + - "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. diff --git a/src/realizer/send_controller.py b/src/realizer/send_controller.py index 01f9a95..334de49 100644 --- a/src/realizer/send_controller.py +++ b/src/realizer/send_controller.py @@ -21,8 +21,37 @@ from .ixia.ixia_connect import ixia_connect from .e2e.e2e_connect import e2e_connect def send_controller(controller_type, requests): + """ + 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 + + 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 + """ if current_app.config["DUMMY_MODE"]: return True + if controller_type == "TFS": response = tfs_connect(requests, current_app.config["TFS_IP"]) logging.info("Request sent to Teraflow") @@ -32,4 +61,5 @@ def send_controller(controller_type, requests): elif controller_type == "E2E": response = e2e_connect(requests, current_app.config["TFS_E2E"]) logging.info("Requests sent to Teraflow E2E") - return response + + return response \ 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 503c8c7..4812006 100644 --- a/src/realizer/tfs/helpers/cisco_connector.py +++ b/src/realizer/tfs/helpers/cisco_connector.py @@ -18,35 +18,49 @@ import logging from netmiko import ConnectHandler class cisco_connector(): + """Class to interact with Cisco devices via SSH using Netmiko.""" def __init__(self, address, configs=None): self.address=address self.configs=configs def execute_commands(self, commands): + """ + Execute a list of commands on the Cisco device. + Args: + commands (list): List of commands to execute on the device. + """ try: - # Configuración del dispositivo + # Device configuration device = { - 'device_type': 'cisco_xr', # Esto depende del tipo de dispositivo (ej: 'cisco_ios', 'cisco_xr', 'linux', etc.) + 'device_type': 'cisco_xr', # This depends on the Cisco device type 'host': self.address, 'username': 'cisco', 'password': 'cisco12345', } - # Conexión por SSH + # SSH connection connection = ConnectHandler(**device) - # Enviar comandos + # Send commands output = connection.send_config_set(commands) logging.debug(output) - # Cerrar la conexión + # Close connection connection.disconnect() except Exception as e: logging.error(f"Failed to execute commands on {self.address}: {str(e)}") def create_command_template(self, config): + """ + Create command template for configuring a Cisco device. + Args: + config (dict): Configuration parameters for the device. + + Returns: + list: List of commands to configure the device. + """ commands = [ "l2vpn", f"pw-class l2vpn_vpws_profile_example_{config['number']}", @@ -77,6 +91,12 @@ class cisco_connector(): return commands def full_create_command_template(self): + """ + Create full command template for configuring a Cisco device based on the provided configurations. + + Returns: + list: List of commands to configure the device. + """ commands =[] for config in self.configs: commands_temp = self.create_command_template(config) @@ -86,6 +106,11 @@ class cisco_connector(): return commands def create_command_template_delete(self): + """ + Create command template for deleting L2VPN configuration on a Cisco device. + Returns: + list: List of commands to delete the L2VPN configuration. + """ commands = [ "no l2vpn", ] diff --git a/src/realizer/tfs/helpers/tfs_connector.py b/src/realizer/tfs/helpers/tfs_connector.py index dfcfeef..91c64f4 100644 --- a/src/realizer/tfs/helpers/tfs_connector.py +++ b/src/realizer/tfs/helpers/tfs_connector.py @@ -17,8 +17,21 @@ import logging, requests, json 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. + """ 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="" @@ -39,6 +52,15 @@ class tfs_connector(): return response def nbi_post(self, tfs_ip, service, path): + """ + Post service descriptor to TFS NBI. + Args: + tfs_ip (str): IP address of the TFS instance + service (dict): Service descriptor to be posted + path (str): NBI endpoint path + Returns: + requests.Response: Response object from the POST request + """ token="" user="admin" password="admin" @@ -56,6 +78,15 @@ class tfs_connector(): 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}' diff --git a/src/realizer/tfs/main.py b/src/realizer/tfs/main.py index 2975127..74be42a 100644 --- a/src/realizer/tfs/main.py +++ b/src/realizer/tfs/main.py @@ -19,6 +19,17 @@ 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). + + Args: + ietf_intent (dict): The IETF intent to be realized. Defaults to None. + way (str): The type of service to realize ("L2" or "L3"). Defaults to None. + response (dict): Response built for user feedback. Defaults to None. + + Returns: + dict: A realization request for the specified network slice type. + """ if way == "L2": realizing_request = tfs_l2vpn(ietf_intent, response) elif way == "L3": diff --git a/src/realizer/tfs/service_types/tfs_l2vpn.py b/src/realizer/tfs/service_types/tfs_l2vpn.py index 2dab481..6cf5af9 100644 --- a/src/realizer/tfs/service_types/tfs_l2vpn.py +++ b/src/realizer/tfs/service_types/tfs_l2vpn.py @@ -35,6 +35,7 @@ def tfs_l2vpn(ietf_intent, response): Args: ietf_intent (dict): IETF-formatted network slice intent. + response (dict): Response data containing slice information. Returns: dict: A TeraFlow service request for L2VPN configuration. diff --git a/src/realizer/tfs/service_types/tfs_l3vpn.py b/src/realizer/tfs/service_types/tfs_l3vpn.py index 3a1f179..93befbc 100644 --- a/src/realizer/tfs/service_types/tfs_l3vpn.py +++ b/src/realizer/tfs/service_types/tfs_l3vpn.py @@ -34,6 +34,7 @@ def tfs_l3vpn(ietf_intent, response): Args: ietf_intent (dict): IETF-formatted network slice intent. + response (dict): Response data containing slice information. Returns: dict: A TeraFlow service request for L3VPN configuration. diff --git a/src/realizer/tfs/tfs_connect.py b/src/realizer/tfs/tfs_connect.py index 39bb33e..9c8334d 100644 --- a/src/realizer/tfs/tfs_connect.py +++ b/src/realizer/tfs/tfs_connect.py @@ -19,7 +19,17 @@ from flask import current_app from src.utils.send_response import send_response from .service_types.tfs_l2vpn import tfs_l2vpn_support -def tfs_connect(requests, tfs_ip): +def tfs_connect(requests, tfs_ip): + """ + 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": diff --git a/src/utils/build_response.py b/src/utils/build_response.py index 7d67c8b..d7ddba0 100644 --- a/src/utils/build_response.py +++ b/src/utils/build_response.py @@ -17,7 +17,37 @@ from .safe_get import safe_get def build_response(intent, response, controller_type = None): - """Build a structured response from the intent.""" + """ + 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"]) source = safe_get(intent, ["ietf-network-slice-service:network-slice-services","slice-service",0,"sdps","sdp",0,"id"]) destination = safe_get(intent, ["ietf-network-slice-service:network-slice-services","slice-service",0,"sdps","sdp",1,"id"]) diff --git a/src/utils/dump_templates.py b/src/utils/dump_templates.py index 67cc73f..f3fbb44 100644 --- a/src/utils/dump_templates.py +++ b/src/utils/dump_templates.py @@ -20,18 +20,45 @@ from flask import current_app def dump_templates(nbi_file, ietf_file, realizer_file): """ - Dump multiple templates as JSON into the templates path. + 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) + json.dump(content, f, indent=2) \ No newline at end of file diff --git a/src/utils/safe_get.py b/src/utils/safe_get.py index a12d885..c02b1bf 100644 --- a/src/utils/safe_get.py +++ b/src/utils/safe_get.py @@ -15,7 +15,14 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. def safe_get(dct, keys): - """Safely get a nested value from a dictionary or list.""" + """ + Safely retrieves 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. + Returns: + The value found at the nested location, or None if any key/index is not found. + """ for key in keys: if isinstance(dct, dict) and key in dct: dct = dct[key] diff --git a/src/utils/send_response.py b/src/utils/send_response.py index a8759cb..30c4433 100644 --- a/src/utils/send_response.py +++ b/src/utils/send_response.py @@ -21,10 +21,10 @@ def send_response(result, message=None, code=None, data=None): Generate and send a standardized API response. Args: - result (bool): Indicates success or failure - message (str, optional): Message (success or error) - code (int, optional): HTTP code (default 200 for success, 400 for error) - data (dict, optional): Additional payload + 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) diff --git a/src/webui/gui.py b/src/webui/gui.py index 6b7d2fe..49d8712 100644 --- a/src/webui/gui.py +++ b/src/webui/gui.py @@ -23,7 +23,6 @@ from src.config.constants import SRC_PATH, NSC_PORT, TEMPLATES_PATH from src.realizer.ixia.helpers.NEII_V4 import NEII_controller from flask import current_app -# app =Flask(__name__) 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') #Variables for dev accessing @@ -32,6 +31,15 @@ PASSWORD = 'admin' enter=False def __safe_int(value): + """ + Safely convert a string or numeric input to int or float. + + Args: + value (str|int|float): The input value to convert. + + Returns: + int|float|None: The converted integer or float value, or None if conversion fails. + """ try: if isinstance(value, str): value = value.strip().replace(',', '.') @@ -41,9 +49,13 @@ def __safe_int(value): 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): - ''' - Work: Build the IETF template for the intent - ''' + """ + 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 @@ -109,9 +121,11 @@ def __build_request(ip_version=None, src_node_ip=None, dst_node_ip=None, src_nod reliability=None, packet_reorder=None, num_pack=None, pack_reorder=None, num_reorder=None, max_reorder=None, desv_reorder=None, drop_version=None, packets_drop=None, drops=None, desv_drop=None): - ''' - Work: Build the template for the IXIA NEII - ''' + """ + Build a JSON request formm from inputs. + Args: IPs, VLAN, bandwidth, latency, reliability, etc. + Returns: dict representing the JSON request. + """ json_data = { "ip_version": ip_version, "src_node_ip": src_node_ip, @@ -138,6 +152,11 @@ def __build_request(ip_version=None, src_node_ip=None, dst_node_ip=None, src_nod return json_data def __datos_json(): + """ + Read slice data from JSON file and return as a pandas DataFrame. + Returns: + pd.DataFrame: DataFrame containing slice data. + """ try: with open(os.path.join(SRC_PATH, 'slice_ddbb.json'), 'r') as fichero: datos =json.load(fichero) @@ -347,14 +366,14 @@ def search(): response.raise_for_status() ixia_slices = response.json() - # Combinar los slices de TFS e IXIA + # Combine slices from both controllers slices = tfs_slices + ixia_slices except requests.RequestException as e: logging.error("Error fetching slices: %s", e) return render_template('search.html', error="No se pudieron obtener los slices.", dataframe_html="") - # Extraer datos relevantes y construir un DataFrame + # Extract relevant data for DataFrame rows = [] for item in slices: try: @@ -367,7 +386,7 @@ def search(): vlan = sdp[0]["service-match-criteria"]["match-criterion"][0]["value"] controller = item["controller"] - # Construir atributos dinámicamente + # Build attributes list attributes = [] for metric in metric_bound: if metric.get("metric-type", "") == "one-way-bandwidth": @@ -451,7 +470,7 @@ def update_ips(): tfs_ip = data.get('tfs_ip') ixia_ip = data.get('ixia_ip') - # Cargar datos existentes si el archivo existe + # Load existing IPs from the configuration file config_path = os.path.join(SRC_PATH, 'IPs.json') if os.path.exists(config_path): with open(config_path) as f: @@ -463,13 +482,13 @@ def update_ips(): else: ips = {"TFS_IP": "", "IXIA_IP": ""} - # Actualizar solo los campos recibidos + # Update IPs if provided if tfs_ip: ips['TFS_IP'] = tfs_ip if ixia_ip: ips['IXIA_IP'] = ixia_ip - # Guardar de nuevo el archivo con los valores actualizados + # Save updated IPs back to the file with open(config_path, 'w') as f: json.dump(ips, f, indent=4) -- GitLab From f0d232d6d7e39583ce2f138e42d5706d59c37b0e Mon Sep 17 00:00:00 2001 From: velazquez Date: Fri, 24 Oct 2025 14:04:31 +0200 Subject: [PATCH 17/26] Update README --- README.md | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 626f670..2208b70 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ It is accessed at `{ip}:{NSC_PORT}/webui` ## Configuration -In the `.env` file, several constants can be adjusted to customize the Network Slice Controller (NSC) behavior: +In the `src/config/.env.example` file, several constants can be adjusted to customize the Network Slice Controller (NSC) behaviour: ### Logging - `DEFAULT_LOGGING_LEVEL`: Sets logging verbosity @@ -133,6 +133,13 @@ In the `.env` file, several constants can be adjusted to customize the Network S - Default: `false` - `PCE_EXTERNAL`: Flag to determine if external PCE is used - Default: `false` +- `PLANNER_TYPE`: Type of planner to be used + - Default: `ENERGY` + - Options: `ENERGY`, `HRAT`, `TFS_OPTICAL` +- `HRAT_IP`: HRAT planner IP + - Default: `10.0.0.1` +- `OPTICAL_PLANNER_IP`: Optical planner IP + - Default: `10.0.0.1` ## Realizer - `DUMMY_MODE`: If true, no config sent to controllers @@ -157,21 +164,11 @@ In the `.env` file, several constants can be adjusted to customize the Network S ## Usage -To deploy and execute the NSC, follow these steps: +To use the NSC, just build the image an run it in a container following these steps: -0. **Preparation** +1. **Deploy** ``` - git clone https://labs.etsi.org/rep/tfs/nsc.git - cd nsc - python3 -m venv venv - source venv/bin/activate - pip install -r requirements.txt - cp ./src/config/.env.example ./.env - ``` - -1. **Start NSC Server**: - ``` - python3 app.py + ./deploy.sh ``` 2. **Send Slice Requests**: -- GitLab From 31928591a65b8416c5036cb8dbbcc12fe8974018 Mon Sep 17 00:00:00 2001 From: Lluis Gifre Renom Date: Thu, 30 Oct 2025 08:59:24 +0000 Subject: [PATCH 18/26] Edit .gitlab-ci.yml --- .gitlab-ci.yml | 83 +++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 76 insertions(+), 7 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 79e6500..f934803 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,12 +1,81 @@ -image: python:3.12 +# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. stages: - - test + - build + - unit_test -before_script: - - pip3 install -r requirements.txt +# Build, tag, and push the Docker image to the GitLab Docker registry +build nsc: + variables: + IMAGE_NAME: 'nsc' + IMAGE_TAG: 'test' + stage: build + before_script: + - docker image prune --force + - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY + script: + - docker buildx build -t "$IMAGE_NAME:$IMAGE_TAG" -f ./Dockerfile . + - docker tag "$IMAGE_NAME:$IMAGE_TAG" "$CI_REGISTRY_IMAGE/$IMAGE_NAME:$IMAGE_TAG" + - docker push "$CI_REGISTRY_IMAGE/$IMAGE_NAME:$IMAGE_TAG" + after_script: + - docker image prune --force + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && ($CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "develop" || $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == $CI_DEFAULT_BRANCH)' + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "develop"' + +# Apply unit test to the component +unit_test nsc: + timeout: 15m + variables: + IMAGE_NAME: 'nsc' # name of the microservice + IMAGE_TAG: 'test' # tag of the container image (production, development, etc) + stage: unit_test + needs: + - build nsc + before_script: + # Do Docker cleanup + - docker ps --all --quiet | xargs --no-run-if-empty docker stop + - docker container prune --force + - docker ps --all --quiet | xargs --no-run-if-empty docker rm --force + - docker image prune --force + - docker network prune --force + - docker volume prune --all --force + - docker buildx prune --force -test: - stage: test + - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY script: - - python3 -m pytest \ No newline at end of file + - docker pull "$CI_REGISTRY_IMAGE/$IMAGE_NAME:$IMAGE_TAG" + - docker run --name $IMAGE_NAME -d -p 8081:8081 -v "$PWD/src/tests:/opt/results" $CI_REGISTRY_IMAGE/$IMAGE_NAME:$IMAGE_TAG + - sleep 5 + - docker ps -a + - docker logs $IMAGE_NAME + - docker exec -i $IMAGE_NAME bash -c "coverage run -m pytest --log-level=INFO --verbose --junitxml=/opt/results/report.xml" + - docker exec -i $IMAGE_NAME bash -c "coverage report --include='${IMAGE_NAME}/*' --show-missing" + coverage: '/TOTAL\s+\d+\s+\d+\s+(\d+%)/' + after_script: + # Clean up + - docker ps --all --quiet | xargs --no-run-if-empty docker stop + - docker container prune --force + - docker ps --all --quiet | xargs --no-run-if-empty docker rm --force + - docker network prune --force + - docker volume prune --all --force + - docker image prune --force + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && ($CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "develop" || $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == $CI_DEFAULT_BRANCH)' + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "develop"' + artifacts: + when: always + reports: + junit: report.xml -- GitLab From 09027108086e415f71ea7f446357a8e66defa381 Mon Sep 17 00:00:00 2001 From: Lluis Gifre Renom Date: Thu, 30 Oct 2025 09:04:26 +0000 Subject: [PATCH 19/26] Edit requirements.txt --- requirements.txt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 1622628..6e8674f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,4 +20,6 @@ flask-restx netmiko requests pandas -dotenv \ No newline at end of file +dotenv +coverage +pytest -- GitLab From 90ff0b773ab268c138b54f219daca8b540297dea Mon Sep 17 00:00:00 2001 From: Lluis Gifre Renom Date: Thu, 30 Oct 2025 09:12:53 +0000 Subject: [PATCH 20/26] Edit .gitlab-ci.yml --- .gitlab-ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index f934803..a50341e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -62,7 +62,7 @@ unit_test nsc: - docker ps -a - docker logs $IMAGE_NAME - docker exec -i $IMAGE_NAME bash -c "coverage run -m pytest --log-level=INFO --verbose --junitxml=/opt/results/report.xml" - - docker exec -i $IMAGE_NAME bash -c "coverage report --include='${IMAGE_NAME}/*' --show-missing" + - docker exec -i $IMAGE_NAME bash -c "coverage report --show-missing" coverage: '/TOTAL\s+\d+\s+\d+\s+(\d+%)/' after_script: # Clean up @@ -78,4 +78,4 @@ unit_test nsc: artifacts: when: always reports: - junit: report.xml + junit: src/tests/report.xml -- GitLab From e5aaad9be07ea66bb852ce6316ebdc323fff8a23 Mon Sep 17 00:00:00 2001 From: velazquez Date: Mon, 20 Oct 2025 09:41:32 +0200 Subject: [PATCH 21/26] - Add gitlab-ci file - Add tests for api, database, nbi_processor, mapper, utils - Add e2e integration tests - Add initialization tests - Correct minor bugs - Update documentation --- .gitlab-ci.yml | 17 + src/api/main.py | 3 + src/mapper/slo_viability.py | 1 + src/planner/hrat_planner/hrat.py | 5 +- .../tfs_optical_planner/tfs_optical.py | 2 +- src/tests/requests/3ggpp_template_green.json | 176 +++++ .../3gpp_template_UC1PoC2_backhaul.json | 267 ++++++++ ...p_template_UC1PoC2_backhaul_request_1.json | 131 ++++ ...p_template_UC1PoC2_backhaul_request_2.json | 131 ++++ ...p_template_UC1PoC2_backhaul_request_3.json | 131 ++++ .../3gpp_template_UC1PoC2_midhaul.json | 267 ++++++++ src/tests/requests/P2MP.json | 108 +++ src/tests/requests/create_slice_1.json | 81 +++ src/tests/requests/ietf_green_request.json | 172 +++++ src/tests/requests/l3vpn_test.json | 164 +++++ src/tests/requests/slice_request.json | 162 +++++ src/tests/test_api.py | 301 +++++++++ src/tests/test_database.py | 585 ++++++++++++++++ src/tests/test_e2e.py | 101 +++ src/tests/test_initialization.py | 37 + src/tests/test_mapper.py | 639 ++++++++++++++++++ src/tests/test_nbi_processor.py | 222 ++++++ src/tests/test_utils.py | 182 +++++ 23 files changed, 3881 insertions(+), 4 deletions(-) create mode 100644 .gitlab-ci.yml create mode 100644 src/tests/requests/3ggpp_template_green.json create mode 100644 src/tests/requests/3gpp_template_UC1PoC2_backhaul.json create mode 100644 src/tests/requests/3gpp_template_UC1PoC2_backhaul_request_1.json create mode 100644 src/tests/requests/3gpp_template_UC1PoC2_backhaul_request_2.json create mode 100644 src/tests/requests/3gpp_template_UC1PoC2_backhaul_request_3.json create mode 100644 src/tests/requests/3gpp_template_UC1PoC2_midhaul.json create mode 100644 src/tests/requests/P2MP.json create mode 100644 src/tests/requests/create_slice_1.json create mode 100644 src/tests/requests/ietf_green_request.json create mode 100644 src/tests/requests/l3vpn_test.json create mode 100644 src/tests/requests/slice_request.json create mode 100644 src/tests/test_api.py create mode 100644 src/tests/test_database.py create mode 100644 src/tests/test_e2e.py create mode 100644 src/tests/test_initialization.py create mode 100644 src/tests/test_mapper.py create mode 100644 src/tests/test_nbi_processor.py create mode 100644 src/tests/test_utils.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..edd18a7 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,17 @@ +image: python:3.12 + +stages: + - build + - test + +before_script: + - pip3 install -r requirements.txt + +build: + stage: build + script: + - python3 app.py +test: + stage: test + script: + - python3 -m pytest \ No newline at end of file diff --git a/src/api/main.py b/src/api/main.py index 47b6344..8cf60b8 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -134,6 +134,9 @@ class Api: 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)) diff --git a/src/mapper/slo_viability.py b/src/mapper/slo_viability.py index 92b215a..a91b929 100644 --- a/src/mapper/slo_viability.py +++ b/src/mapper/slo_viability.py @@ -39,6 +39,7 @@ def slo_viability(slice_slos, nrp_slos): "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']: diff --git a/src/planner/hrat_planner/hrat.py b/src/planner/hrat_planner/hrat.py index 2686af2..0b370d7 100644 --- a/src/planner/hrat_planner/hrat.py +++ b/src/planner/hrat_planner/hrat.py @@ -63,10 +63,9 @@ def hrat_planner(data: str, ip: str, action: str = "create") -> dict: ] } } - response = requests.delete(url, headers=headers, json=payload, timeout=15) + response = requests.delete(url, headers=headers, json=payload, timeout=1) elif action == "create": - # Send creation request with full intent data - response = requests.post(url, headers=headers, json=data, timeout=15) + response = requests.post(url, headers=headers, json=data, timeout=1) else: logging.error("Invalid action. Use 'create' or 'delete'.") return data_static diff --git a/src/planner/tfs_optical_planner/tfs_optical.py b/src/planner/tfs_optical_planner/tfs_optical.py index 2489915..0d5bc79 100644 --- a/src/planner/tfs_optical_planner/tfs_optical.py +++ b/src/planner/tfs_optical_planner/tfs_optical.py @@ -176,7 +176,7 @@ def send_request(source, destination, ip): logging.debug(f"Payload for path computation: {json.dumps(payload, indent=2)}") try: - response = requests.post(url, headers=headers, data=json.dumps(payload), timeout=15) + response = requests.post(url, headers=headers, data=json.dumps(payload), timeout=1) return json.loads(response.text) except requests.exceptions.RequestException: logging.warning("Error connecting to the Optical Planner service. Skipping optical planning.") diff --git a/src/tests/requests/3ggpp_template_green.json b/src/tests/requests/3ggpp_template_green.json new file mode 100644 index 0000000..67a1367 --- /dev/null +++ b/src/tests/requests/3ggpp_template_green.json @@ -0,0 +1,176 @@ +{ + "NetworkSlice1": { + "operationalState": "", + "administrativeState": "", + "serviceProfileList": [], + "networkSliceSubnetRef": "TopSliceSubnet1" + }, + "TopSliceSubnet1": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "TOP_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "TopId", + "pLMNInfoList": null, + "TopSliceSubnetProfile": { + "EnergyEfficiency": 400, + "EnergyConsumption": 200, + "RenewableEnergyUsage": 0.5, + "CarbonEmissions": 100 + } + } + ], + "networkSliceSubnetRef": [ + "CNSliceSubnet1", + "RANSliceSubnet1" + ] + }, + "CNSliceSubnet1": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "CN_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "CNId", + "pLMNInfoList": null, + "CNSliceSubnetProfile": { + "EnergyEfficiency": 400, + "EnergyConsumption": 200, + "RenewableEnergyUsage": 0.5, + "CarbonEmissions": 100 + } + } + ] + }, + "RANSliceSubnet1": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "RAN_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "RANId", + "pLMNInfoList": null, + "RANSliceSubnetProfile": { + "dLThptPerSliceSubnet": { + "GuaThpt": 40, + "MaxThpt": 80 + }, + "uLThptPerSliceSubnet": { + "GuaThpt": 40, + "MaxThpt": 80 + }, + "dLLatency": 8, + "uLLatency": 8, + "EnergyEfficiency": 400, + "EnergyConsumption": 200, + "RenewableEnergyUsage": 0.5, + "CarbonEmissions": 100 + } + } + ], + "networkSliceSubnetRef": [ + "MidhaulSliceSubnet1" + ] + }, + "MidhaulSliceSubnet1": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "RAN_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "MidhaulId", + "pLMNInfoList": null, + "RANSliceSubnetProfile": { + "EnergyEfficiency": 5, + "EnergyConsumption": 18000, + "RenewableEnergyUsage": 0.5, + "CarbonEmissions": 650 + } + } + ], + "EpTransport": [ + "EpTransport CU-UP1", + "EpTransport DU3" + ] + }, + "BackhaulSliceSubnet1": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "RAN_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "BackhaulId", + "pLMNInfoList": null, + "RANSliceSubnetProfile": { + "dLThptPerSliceSubnet": { + "GuaThpt": 40, + "MaxThpt": 80 + }, + "uLThptPerSliceSubnet": { + "GuaThpt": 40, + "MaxThpt": 80 + }, + "dLLatency": 8, + "uLLatency": 8, + "EnergyEfficiency": 400, + "EnergyConsumption": 200, + "RenewableEnergyUsage": 0.5, + "CarbonEmissions": 100 + } + } + ], + "EpTransport": [ + "EpTransport CU-UP2", + "EpTransport UPF" + ] + }, + "EpTransport CU-UP1": { + "IpAddress": "1.1.1.100", + "logicalInterfaceInfo": { + "logicalInterfaceType": "VLAN", + "logicalInterfaceId": "300" + }, + "NextHopInfo": "1.1.1.1", + "qosProfile": "5QI100", + "EpApplicationRef": [ + "EP_F1U CU-UP1" + ] + }, + "EP_F1U CU-UP1": { + "localAddress": "100.1.1.100", + "remoteAddress": "200.1.1.100", + "epTransportRef": [ + "EpTransport CU-UP1" + ] + }, + "EpTransport DU3": { + "IpAddress": "2.2.2.100", + "logicalInterfaceInfo": { + "logicalInterfaceType": "VLAN", + "logicalInterfaceId": "300" + }, + "NextHopInfo": "2.2.2.2", + "qosProfile": "5QI100", + "EpApplicationRef": [ + "EP_F1U DU3" + ] + }, + "EP_F1U DU3": { + "localAddress": "200.1.1.100", + "remoteAddress": "100.1.1.100", + "epTransportRef": [ + "EpTransport DU3" + ] + } +} \ No newline at end of file diff --git a/src/tests/requests/3gpp_template_UC1PoC2_backhaul.json b/src/tests/requests/3gpp_template_UC1PoC2_backhaul.json new file mode 100644 index 0000000..ed80ec0 --- /dev/null +++ b/src/tests/requests/3gpp_template_UC1PoC2_backhaul.json @@ -0,0 +1,267 @@ +{ + "NetworkSlice1": { + "operationalState": "", + "administrativeState": "", + "serviceProfileList": [], + "networkSliceSubnetRef": "TopSliceSubnet1" + }, + "TopSliceSubnet1": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "TOP_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "TopId", + "pLMNInfoList": null, + "TopSliceSubnetProfile": { + "dLThptPerSliceSubnet": { + "GuaThpt": 310, + "MaxThpt": 620 + }, + "uLThptPerSliceSubnet": { + "GuaThpt": 160, + "MaxThpt": 320 + }, + "dLLatency": 20, + "uLLatency": 20 + } + } + ], + "networkSliceSubnetRef": [ + "RANSliceSubnet1" + ] + }, + "RANSliceSubnet1": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "RAN_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "RANId", + "pLMNInfoList": null, + "RANSliceSubnetProfile": { + "dLThptPerSliceSubnet": { + "GuaThpt": 310, + "MaxThpt": 620 + }, + "uLThptPerSliceSubnet": { + "GuaThpt": 160, + "MaxThpt": 320 + }, + "dLLatency": 20, + "uLLatency": 20 + } + } + ], + "networkSliceSubnetRef": [ + "BackhaulSliceSubnetN2", + "BackhaulSliceSubnetN31", + "BackhaulSliceSubnetN32" + ] + }, + "BackhaulSliceSubnetN2": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "RAN_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "BackhaulId", + "pLMNInfoList": null, + "RANSliceSubnetProfile": { + "dLThptPerSliceSubnet": { + "GuaThpt": 10, + "MaxThpt": 20 + }, + "uLThptPerSliceSubnet": { + "GuaThpt": 10, + "MaxThpt": 20 + }, + "dLLatency": 20, + "uLLatency": 20 + } + } + ], + "EpTransport": [ + "EpTransport CU-N2", + "EpTransport AMF-N2" + ] + }, + "BackhaulSliceSubnetN31": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "RAN_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "BackhaulId", + "pLMNInfoList": null, + "RANSliceSubnetProfile": { + "dLThptPerSliceSubnet": { + "GuaThpt": 100, + "MaxThpt": 200 + }, + "uLThptPerSliceSubnet": { + "GuaThpt": 50, + "MaxThpt": 100 + }, + "dLLatency": 10, + "uLLatency": 10 + } + } + ], + "EpTransport": [ + "EpTransport CU-N31", + "EpTransport UPF-N31" + ] + }, + "BackhaulSliceSubnetN32": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "RAN_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "BackhaulId", + "pLMNInfoList": null, + "RANSliceSubnetProfile": { + "dLThptPerSliceSubnet": { + "GuaThpt": 200, + "MaxThpt": 400 + }, + "uLThptPerSliceSubnet": { + "GuaThpt": 100, + "MaxThpt": 200 + }, + "dLLatency": 5, + "uLLatency": 5 + } + } + ], + "EpTransport": [ + "EpTransport CU-N32", + "EpTransport UPF-N32" + ] + }, + "EpTransport CU-N2": { + "IpAddress": "10.60.11.3", + "logicalInterfaceInfo": { + "logicalInterfaceType": "VLAN", + "logicalInterfaceId": "100" + }, + "NextHopInfo": "4.4.4.4", + "qosProfile": "A", + "EpApplicationRef": [ + "EP_N2 CU-N2" + ] + }, + "EP_N2 CU-N2": { + "localAddress": "10.60.11.3", + "remoteAddress": "10.60.60.105", + "epTransportRef": [ + "EpTransport CU-N2" + ] + }, + "EpTransport AMF-N2": { + "IpAddress": "10.60.60.105", + "logicalInterfaceInfo": { + "logicalInterfaceType": "VLAN", + "logicalInterfaceId": "100" + }, + "NextHopInfo": "5.5.5.5", + "qosProfile": "A", + "EpApplicationRef": [ + "EP_N2 AMF-N2" + ] + }, + "EP_N2 AMF-N2": { + "localAddress": "10.60.60.105", + "remoteAddress": "10.60.11.3", + "epTransportRef": [ + "EpTransport UPF-N2" + ] + }, + "EpTransport CU-N32": { + "IpAddress": "10.60.11.3", + "logicalInterfaceInfo": { + "logicalInterfaceType": "VLAN", + "logicalInterfaceId": "101" + }, + "NextHopInfo": "4.4.4.4", + "qosProfile": "B", + "EpApplicationRef": [ + "EP_N3 CU-N32" + ] + }, + "EP_N3 CU-N32": { + "localAddress": "10.60.11.3", + "remoteAddress": "10.60.10.6", + "epTransportRef": [ + "EpTransport CU-N32" + ] + }, + "EpTransport UPF-N32": { + "IpAddress": "10.60.10.6", + "logicalInterfaceInfo": { + "logicalInterfaceType": "VLAN", + "logicalInterfaceId": "101" + }, + "NextHopInfo": "5.5.5.5", + "qosProfile": "B", + "EpApplicationRef": [ + "EP_N3 UPF-N32" + ] + }, + "EP_N3 UPF-N32": { + "localAddress": "10.60.10.6", + "remoteAddress": "10.60.11.3", + "epTransportRef": [ + "EpTransport UPF-N32" + ] + }, + "EpTransport CU-N31": { + "IpAddress": "10.60.11.3", + "logicalInterfaceInfo": { + "logicalInterfaceType": "VLAN", + "logicalInterfaceId": "102" + }, + "NextHopInfo": "4.4.4.4", + "qosProfile": "C", + "EpApplicationRef": [ + "EP_N3 CU-N31" + ] + }, + "EP_N3 CU-N31": { + "localAddress": "10.60.11.3", + "remoteAddress": "10.60.60.106", + "epTransportRef": [ + "EpTransport CU-N31" + ] + }, + "EpTransport UPF-N31": { + "IpAddress": "10.60.60.106", + "logicalInterfaceInfo": { + "logicalInterfaceType": "VLAN", + "logicalInterfaceId": "102" + }, + "NextHopInfo": "5.5.5.5", + "qosProfile": "C", + "EpApplicationRef": [ + "EP_N3 UPF-N31" + ] + }, + "EP_N3 UPF-N31": { + "localAddress": "10.60.60.106", + "remoteAddress": "10.60.11.3", + "epTransportRef": [ + "EpTransport UPF-N31" + ] + } +} \ No newline at end of file diff --git a/src/tests/requests/3gpp_template_UC1PoC2_backhaul_request_1.json b/src/tests/requests/3gpp_template_UC1PoC2_backhaul_request_1.json new file mode 100644 index 0000000..9dab294 --- /dev/null +++ b/src/tests/requests/3gpp_template_UC1PoC2_backhaul_request_1.json @@ -0,0 +1,131 @@ +{ + "NetworkSlice1": { + "operationalState": "", + "administrativeState": "", + "serviceProfileList": [], + "networkSliceSubnetRef": "TopSliceSubnet1" + }, + "TopSliceSubnet1": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "TOP_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "TopId", + "pLMNInfoList": null, + "TopSliceSubnetProfile": { + "dLThptPerSliceSubnet": { + "GuaThpt": 310, + "MaxThpt": 620 + }, + "uLThptPerSliceSubnet": { + "GuaThpt": 160, + "MaxThpt": 320 + }, + "dLLatency": 20, + "uLLatency": 20 + } + } + ], + "networkSliceSubnetRef": [ + "RANSliceSubnet1" + ] + }, + "RANSliceSubnet1": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "RAN_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "RANId", + "pLMNInfoList": null, + "RANSliceSubnetProfile": { + "dLThptPerSliceSubnet": { + "GuaThpt": 310, + "MaxThpt": 620 + }, + "uLThptPerSliceSubnet": { + "GuaThpt": 160, + "MaxThpt": 320 + }, + "dLLatency": 20, + "uLLatency": 20 + } + } + ], + "networkSliceSubnetRef": [ + "BackhaulSliceSubnetN2" + ] + }, + "BackhaulSliceSubnetN2": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "RAN_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "BackhaulId", + "pLMNInfoList": null, + "RANSliceSubnetProfile": { + "dLThptPerSliceSubnet": { + "GuaThpt": 1, + "MaxThpt": 2 + }, + "uLThptPerSliceSubnet": { + "GuaThpt": 1, + "MaxThpt": 2 + }, + "dLLatency": 20, + "uLLatency": 20 + } + } + ], + "EpTransport": [ + "EpTransport CU-N2", + "EpTransport AMF-N2" + ] + }, + "EpTransport CU-N2": { + "IpAddress": "10.60.11.3", + "logicalInterfaceInfo": { + "logicalInterfaceType": "VLAN", + "logicalInterfaceId": "100" + }, + "NextHopInfo": "4.4.4.4", + "qosProfile": "A", + "EpApplicationRef": [ + "EP_N2 CU-N2" + ] + }, + "EP_N2 CU-N2": { + "localAddress": "10.60.11.3", + "remoteAddress": "10.60.60.105", + "epTransportRef": [ + "EpTransport CU-N2" + ] + }, + "EpTransport AMF-N2": { + "IpAddress": "10.60.60.105", + "logicalInterfaceInfo": { + "logicalInterfaceType": "VLAN", + "logicalInterfaceId": "100" + }, + "NextHopInfo": "5.5.5.5", + "qosProfile": "A", + "EpApplicationRef": [ + "EP_N2 AMF-N2" + ] + }, + "EP_N2 AMF-N2": { + "localAddress": "10.60.60.105", + "remoteAddress": "10.60.11.3", + "epTransportRef": [ + "EpTransport UPF-N2" + ] + } +} \ No newline at end of file diff --git a/src/tests/requests/3gpp_template_UC1PoC2_backhaul_request_2.json b/src/tests/requests/3gpp_template_UC1PoC2_backhaul_request_2.json new file mode 100644 index 0000000..d287a04 --- /dev/null +++ b/src/tests/requests/3gpp_template_UC1PoC2_backhaul_request_2.json @@ -0,0 +1,131 @@ +{ + "NetworkSlice1": { + "operationalState": "", + "administrativeState": "", + "serviceProfileList": [], + "networkSliceSubnetRef": "TopSliceSubnet1" + }, + "TopSliceSubnet1": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "TOP_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "TopId", + "pLMNInfoList": null, + "TopSliceSubnetProfile": { + "dLThptPerSliceSubnet": { + "GuaThpt": 310, + "MaxThpt": 620 + }, + "uLThptPerSliceSubnet": { + "GuaThpt": 160, + "MaxThpt": 320 + }, + "dLLatency": 20, + "uLLatency": 20 + } + } + ], + "networkSliceSubnetRef": [ + "RANSliceSubnet1" + ] + }, + "RANSliceSubnet1": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "RAN_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "RANId", + "pLMNInfoList": null, + "RANSliceSubnetProfile": { + "dLThptPerSliceSubnet": { + "GuaThpt": 310, + "MaxThpt": 620 + }, + "uLThptPerSliceSubnet": { + "GuaThpt": 160, + "MaxThpt": 320 + }, + "dLLatency": 20, + "uLLatency": 20 + } + } + ], + "networkSliceSubnetRef": [ + "BackhaulSliceSubnetN32" + ] + }, + "BackhaulSliceSubnetN32": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "RAN_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "BackhaulId", + "pLMNInfoList": null, + "RANSliceSubnetProfile": { + "dLThptPerSliceSubnet": { + "GuaThpt": 200, + "MaxThpt": 400 + }, + "uLThptPerSliceSubnet": { + "GuaThpt": 100, + "MaxThpt": 200 + }, + "dLLatency": 5, + "uLLatency": 5 + } + } + ], + "EpTransport": [ + "EpTransport CU-N32", + "EpTransport UPF-N32" + ] + }, + "EpTransport CU-N32": { + "IpAddress": "10.60.11.3", + "logicalInterfaceInfo": { + "logicalInterfaceType": "VLAN", + "logicalInterfaceId": "101" + }, + "NextHopInfo": "4.4.4.4", + "qosProfile": "B", + "EpApplicationRef": [ + "EP_N3 CU-N32" + ] + }, + "EP_N3 CU-N32": { + "localAddress": "10.60.11.3", + "remoteAddress": "10.60.10.6", + "epTransportRef": [ + "EpTransport CU-N32" + ] + }, + "EpTransport UPF-N32": { + "IpAddress": "10.60.10.6", + "logicalInterfaceInfo": { + "logicalInterfaceType": "VLAN", + "logicalInterfaceId": "101" + }, + "NextHopInfo": "5.5.5.5", + "qosProfile": "B", + "EpApplicationRef": [ + "EP_N3 UPF-N32" + ] + }, + "EP_N3 UPF-N32": { + "localAddress": "10.60.10.6", + "remoteAddress": "10.60.11.3", + "epTransportRef": [ + "EpTransport UPF-N32" + ] + } +} \ No newline at end of file diff --git a/src/tests/requests/3gpp_template_UC1PoC2_backhaul_request_3.json b/src/tests/requests/3gpp_template_UC1PoC2_backhaul_request_3.json new file mode 100644 index 0000000..55232e8 --- /dev/null +++ b/src/tests/requests/3gpp_template_UC1PoC2_backhaul_request_3.json @@ -0,0 +1,131 @@ +{ + "NetworkSlice1": { + "operationalState": "", + "administrativeState": "", + "serviceProfileList": [], + "networkSliceSubnetRef": "TopSliceSubnet1" + }, + "TopSliceSubnet1": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "TOP_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "TopId", + "pLMNInfoList": null, + "TopSliceSubnetProfile": { + "dLThptPerSliceSubnet": { + "GuaThpt": 310, + "MaxThpt": 620 + }, + "uLThptPerSliceSubnet": { + "GuaThpt": 160, + "MaxThpt": 320 + }, + "dLLatency": 20, + "uLLatency": 20 + } + } + ], + "networkSliceSubnetRef": [ + "RANSliceSubnet1" + ] + }, + "RANSliceSubnet1": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "RAN_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "RANId", + "pLMNInfoList": null, + "RANSliceSubnetProfile": { + "dLThptPerSliceSubnet": { + "GuaThpt": 310, + "MaxThpt": 620 + }, + "uLThptPerSliceSubnet": { + "GuaThpt": 160, + "MaxThpt": 320 + }, + "dLLatency": 20, + "uLLatency": 20 + } + } + ], + "networkSliceSubnetRef": [ + "BackhaulSliceSubnetN31" + ] + }, + "BackhaulSliceSubnetN31": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "RAN_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "BackhaulId", + "pLMNInfoList": null, + "RANSliceSubnetProfile": { + "dLThptPerSliceSubnet": { + "GuaThpt": 100, + "MaxThpt": 200 + }, + "uLThptPerSliceSubnet": { + "GuaThpt": 50, + "MaxThpt": 100 + }, + "dLLatency": 10, + "uLLatency": 10 + } + } + ], + "EpTransport": [ + "EpTransport CU-N31", + "EpTransport UPF-N31" + ] + }, + "EpTransport CU-N31": { + "IpAddress": "10.60.11.3", + "logicalInterfaceInfo": { + "logicalInterfaceType": "VLAN", + "logicalInterfaceId": "102" + }, + "NextHopInfo": "4.4.4.4", + "qosProfile": "C", + "EpApplicationRef": [ + "EP_N3 CU-N31" + ] + }, + "EP_N3 CU-N31": { + "localAddress": "10.60.11.3", + "remoteAddress": "10.60.60.106", + "epTransportRef": [ + "EpTransport CU-N31" + ] + }, + "EpTransport UPF-N31": { + "IpAddress": "10.60.60.106", + "logicalInterfaceInfo": { + "logicalInterfaceType": "VLAN", + "logicalInterfaceId": "102" + }, + "NextHopInfo": "5.5.5.5", + "qosProfile": "C", + "EpApplicationRef": [ + "EP_N3 UPF-N31" + ] + }, + "EP_N3 UPF-N31": { + "localAddress": "10.60.60.106", + "remoteAddress": "10.60.11.3", + "epTransportRef": [ + "EpTransport UPF-N31" + ] + } +} \ No newline at end of file diff --git a/src/tests/requests/3gpp_template_UC1PoC2_midhaul.json b/src/tests/requests/3gpp_template_UC1PoC2_midhaul.json new file mode 100644 index 0000000..300f866 --- /dev/null +++ b/src/tests/requests/3gpp_template_UC1PoC2_midhaul.json @@ -0,0 +1,267 @@ +{ + "NetworkSlice1": { + "operationalState": "", + "administrativeState": "", + "serviceProfileList": [], + "networkSliceSubnetRef": "TopSliceSubnet1" + }, + "TopSliceSubnet1": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "TOP_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "TopId", + "pLMNInfoList": null, + "TopSliceSubnetProfile": { + "dLThptPerSliceSubnet": { + "GuaThpt": 410, + "MaxThpt": 820 + }, + "uLThptPerSliceSubnet": { + "GuaThpt": 210, + "MaxThpt": 420 + }, + "dLLatency": 20, + "uLLatency": 20 + } + } + ], + "networkSliceSubnetRef": [ + "RANSliceSubnet1" + ] + }, + "RANSliceSubnet1": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "RAN_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "RANId", + "pLMNInfoList": null, + "RANSliceSubnetProfile": { + "dLThptPerSliceSubnet": { + "GuaThpt": 410, + "MaxThpt": 820 + }, + "uLThptPerSliceSubnet": { + "GuaThpt": 210, + "MaxThpt": 220 + }, + "dLLatency": 20, + "uLLatency": 20 + } + } + ], + "networkSliceSubnetRef": [ + "MidhaulSliceSubnetF1c", + "MidhaulSliceSubnetF1u1", + "MidhaulSliceSubnetF1u2" + ] + }, + "MidhaulSliceSubnetF1c": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "RAN_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "MidhaulId", + "pLMNInfoList": null, + "RANSliceSubnetProfile": { + "dLThptPerSliceSubnet": { + "GuaThpt": 10, + "MaxThpt": 20 + }, + "uLThptPerSliceSubnet": { + "GuaThpt": 10, + "MaxThpt": 20 + }, + "dLLatency": 20, + "uLLatency": 20 + } + } + ], + "EpTransport": [ + "EpTransport CU-F1c", + "EpTransport DU-F1c" + ] + }, + "MidhaulSliceSubnetF1u1": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "RAN_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "MidhaulId", + "pLMNInfoList": null, + "RANSliceSubnetProfile": { + "dLThptPerSliceSubnet": { + "GuaThpt": 200, + "MaxThpt": 400 + }, + "uLThptPerSliceSubnet": { + "GuaThpt": 100, + "MaxThpt": 200 + }, + "dLLatency": 5, + "uLLatency": 5 + } + } + ], + "EpTransport": [ + "EpTransport CU-F1u1", + "EpTransport DU-F1u1" + ] + }, + "MidhaulSliceSubnetF1u2": { + "operationalState": "", + "administrativeState": "", + "nsInfo": {}, + "managedFunctionRef": [], + "networkSliceSubnetType": "RAN_SLICESUBNET", + "SliceProfileList": [ + { + "sliceProfileId": "MidhaulId", + "pLMNInfoList": null, + "RANSliceSubnetProfile": { + "dLThptPerSliceSubnet": { + "GuaThpt": 200, + "MaxThpt": 400 + }, + "uLThptPerSliceSubnet": { + "GuaThpt": 100, + "MaxThpt": 200 + }, + "dLLatency": 10, + "uLLatency": 10 + } + } + ], + "EpTransport": [ + "EpTransport CU-F1u2", + "EpTransport DU-F1u2" + ] + }, + "EpTransport CU-F1c": { + "IpAddress": "10.60.10.2", + "logicalInterfaceInfo": { + "logicalInterfaceType": "VLAN", + "logicalInterfaceId": "100" + }, + "NextHopInfo": "5.5.5.5", + "qosProfile": "A", + "EpApplicationRef": [ + "EP_F1C CU-F1c" + ] + }, + "EP_F1C CU-F1c": { + "localAddress": "10.60.10.2", + "remoteAddress": "10.60.11.2", + "epTransportRef": [ + "EpTransport CU-F1c" + ] + }, + "EpTransport DU-F1c": { + "IpAddress": "10.60.11.2", + "logicalInterfaceInfo": { + "logicalInterfaceType": "VLAN", + "logicalInterfaceId": "100" + }, + "NextHopInfo": "4.4.4.4", + "qosProfile": "A", + "EpApplicationRef": [ + "EP_F1C DU-F1c" + ] + }, + "EP_F1C DU-F1c": { + "localAddress": "10.60.11.2", + "remoteAddress": "10.60.10.2", + "epTransportRef": [ + "EpTransport DU-F1c" + ] + }, + "EpTransport CU-F1u1": { + "IpAddress": "10.60.10.2", + "logicalInterfaceInfo": { + "logicalInterfaceType": "VLAN", + "logicalInterfaceId": "101" + }, + "NextHopInfo": "5.5.5.5", + "qosProfile": "B", + "EpApplicationRef": [ + "EP_F1U CU-F1u1" + ] + }, + "EP_F1U CU-F1u1": { + "localAddress": "10.60.10.2", + "remoteAddress": "10.60.11.2", + "epTransportRef": [ + "EpTransport CU-F1c" + ] + }, + "EpTransport DU-F1u1": { + "IpAddress": "10.60.11.2", + "logicalInterfaceInfo": { + "logicalInterfaceType": "VLAN", + "logicalInterfaceId": "101" + }, + "NextHopInfo": "4.4.4.4", + "qosProfile": "B", + "EpApplicationRef": [ + "EP_F1U DU-F1u1" + ] + }, + "EP_F1U DU-F1u1": { + "localAddress": "10.60.11.2", + "remoteAddress": "10.60.10.2", + "epTransportRef": [ + "EpTransport DU-F1u1" + ] + }, + "EpTransport CU-F1u2": { + "IpAddress": "10.60.10.2", + "logicalInterfaceInfo": { + "logicalInterfaceType": "VLAN", + "logicalInterfaceId": "102" + }, + "NextHopInfo": "5.5.5.5", + "qosProfile": "C", + "EpApplicationRef": [ + "EP_F1U CU-F1u2" + ] + }, + "EP_F1U CU-F1u2": { + "localAddress": "10.60.10.2", + "remoteAddress": "10.60.11.2", + "epTransportRef": [ + "EpTransport CU-F1u2" + ] + }, + "EpTransport DU-F1u2": { + "IpAddress": "10.60.11.2", + "logicalInterfaceInfo": { + "logicalInterfaceType": "VLAN", + "logicalInterfaceId": "102" + }, + "NextHopInfo": "4.4.4.4", + "qosProfile": "C", + "EpApplicationRef": [ + "EP_F1U DU-F1u2" + ] + }, + "EP_F1U DU-F1u2": { + "localAddress": "10.60.11.2", + "remoteAddress": "10.60.10.2", + "epTransportRef": [ + "EpTransport DU-F1u2" + ] + } +} \ No newline at end of file diff --git a/src/tests/requests/P2MP.json b/src/tests/requests/P2MP.json new file mode 100644 index 0000000..02875dc --- /dev/null +++ b/src/tests/requests/P2MP.json @@ -0,0 +1,108 @@ +{ + "ietf-network-slice-service:network-slice-services": { + "slo-sle-templates": { + "slo-sle-template": [ + { + "id": "LOW-DELAY", + "description": "Prefer direct link: delay <= 2ms", + "slo-policy": { + "metric-bound": [ + { + "metric-type": "two-way-delay-maximum", + "metric-unit": "milliseconds", + "bound": 2 + } + ] + } + } + ] + }, + "slice-service": [ + { + "id": "slice-long", + "description": "Slice tolerant to intermediate hops", + "slo-sle-policy": { + "slo-sle-template": "LOW-DELAY" + }, + "sdps": { + "sdp": [ + { + "id": "T1.2", + "node-id": "T1.2", + "attachment-circuits": { + "attachment-circuit": [ + { + "id": "ac-r1", + "ac-ipv4-address": "10.10.1.1", + "ac-ipv4-prefix-length": 24 + } + ] + } + }, + { + "id": "T1.1", + "node-id": "T1.1", + "attachment-circuits": { + "attachment-circuit": [ + { + "id": "ac-r2", + "ac-ipv4-address": "10.10.2.1", + "ac-ipv4-prefix-length": 24 + } + ] + } + }, + { + "id": "T2.1", + "node-id": "T2.1", + "attachment-circuits": { + "attachment-circuit": [ + { + "id": "ac-r3", + "ac-ipv4-address": "10.10.3.1", + "ac-ipv4-prefix-length": 24 + } + ] + } + }, + { + "id": "T1.3", + "node-id": "T1.3", + "attachment-circuits": { + "attachment-circuit": [ + { + "id": "ac-r3", + "ac-ipv4-address": "10.10.4.1", + "ac-ipv4-prefix-length": 24 + } + ] + } + } + ] + }, + "connection-groups": { + "connection-group": [ + { + "id": "cg-long", + "connectivity-type": "ietf-vpn-common:any-to-any", + "connectivity-construct": [ + { + "id": "cc-p2mp", + "p2mp-sdp": { + "root-sdp-id": "T2.1", + "leaf-sdp-id": [ + "T1.1", + "T1.2", + "T1.3" + + ] + } + } + ] + } + ] + } + } + ] + } + } \ No newline at end of file diff --git a/src/tests/requests/create_slice_1.json b/src/tests/requests/create_slice_1.json new file mode 100644 index 0000000..bcefe01 --- /dev/null +++ b/src/tests/requests/create_slice_1.json @@ -0,0 +1,81 @@ +{ + "ietf-network-slice-service:network-slice-services": { + "slo-sle-templates": { + "slo-sle-template": [ + { + "id": "LOW-DELAY", + "description": "optical-slice", + "slo-policy": { + "metric-bound": [ + { + "metric-type": "two-way-delay-maximum", + "metric-unit": "milliseconds", + "bound": 2 + } + ] + } + } + ] + }, + "slice-service": [ + { + "id": "slice-long", + "description": "Slice tolerant to intermediate hops", + "slo-sle-policy": { + "slo-sle-template": "LOW-DELAY" + }, + "sdps": { + "sdp": [ + { + "id": "Ethernet110", + "node-id": "Phoenix-1", + "attachment-circuits": { + "attachment-circuit": [ + { + "id": "ac-r1", + "ac-ipv4-address": "10.10.1.1", + "ac-ipv4-prefix-length": 24 + } + ] + } + }, + { + "id": "Ethernet220", + "node-id": "Phoenix-2", + "attachment-circuits": { + "attachment-circuit": [ + { + "id": "ac-r2", + "ac-ipv4-address": "10.10.2.1", + "ac-ipv4-prefix-length": 24 + } + ] + } + } + ] + }, + "connection-groups": { + "connection-group": [ + { + "id": "cg-long", + "connectivity-type": "ietf-vpn-common:any-to-any", + "connectivity-construct": [ + { + "id": "cc-long", + "a2a-sdp": [ + { + "sdp-id": "Ethernet110" + }, + { + "sdp-id": "Ethernet220" + } + ] + } + ] + } + ] + } + } + ] + } +} \ No newline at end of file diff --git a/src/tests/requests/ietf_green_request.json b/src/tests/requests/ietf_green_request.json new file mode 100644 index 0000000..5edae75 --- /dev/null +++ b/src/tests/requests/ietf_green_request.json @@ -0,0 +1,172 @@ +{ + "ietf-network-slice-service:network-slice-services": { + "slo-sle-templates": { + "slo-sle-template": [ + { + "id": "B", + "description": "", + "slo-policy": { + "metric-bound": [ + { + "metric-type": "energy_consumption", + "metric-unit": "kWh", + "bound": 20200 + }, + { + "metric-type": "energy_efficiency", + "metric-unit": "Wats/bps", + "bound": 6 + }, + { + "metric-type": "carbon_emission", + "metric-unit": "grams of CO2 per kWh", + "bound": 750 + }, + { + "metric-type": "renewable_energy_usage", + "metric-unit": "rate", + "bound": 0.5 + } + ] + }, + "sle-policy": { + "security": "", + "isolation": "", + "path-constraints": { + "service-functions": "", + "diversity": { + "diversity": { + "diversity-type": "" + } + } + } + } + } + ] + }, + "slice-service": [ + { + "id": "slice-service-88a585f7-a432-4312-8774-6210fb0b2342", + "description": "Transport network slice mapped with 3GPP slice NetworkSlice1", + "service-tags": { + "tag-type": [ + { + "tag-type": "service", + "tag-type-value": [ + "L2" + ] + } + ] + }, + "slo-sle-policy": { + "slo-sle-template": "B" + }, + "status": {}, + "sdps": { + "sdp": [ + { + "id": "A", + "geo-location": "", + "node-id": "CU-N32", + "sdp-ip-address": "10.60.11.3", + "tp-ref": "", + "service-match-criteria": { + "match-criterion": [ + { + "index": 1, + "match-type": "VLAN", + "value": "101", + "target-connection-group-id": "CU-N32_UPF-N32" + } + ] + }, + "incoming-qos-policy": "", + "outgoing-qos-policy": "", + "sdp-peering": { + "peer-sap-id": "", + "protocols": "" + }, + "ac-svc-ref": [], + "attachment-circuits": { + "attachment-circuit": [ + { + "id": "100", + "ac-ipv4-address": "10.60.11.3", + "ac-ipv4-prefix-length": 0, + "sdp-peering": { + "peer-sap-id": "4.4.4.4" + }, + "status": {} + } + ] + }, + "status": {}, + "sdp-monitoring": "" + }, + { + "id": "B", + "geo-location": "", + "node-id": "UPF-N32", + "sdp-ip-address": "10.60.10.6", + "tp-ref": "", + "service-match-criteria": { + "match-criterion": [ + { + "index": 1, + "match-type": "VLAN", + "value": "101", + "target-connection-group-id": "CU-N32_UPF-N32" + } + ] + }, + "incoming-qos-policy": "", + "outgoing-qos-policy": "", + "sdp-peering": { + "peer-sap-id": "", + "protocols": "" + }, + "ac-svc-ref": [], + "attachment-circuits": { + "attachment-circuit": [ + { + "id": "200", + "ac-ipv4-address": "10.60.10.6", + "ac-ipv4-prefix-length": 0, + "sdp-peering": { + "peer-sap-id": "5.5.5.5" + }, + "status": {} + } + ] + }, + "status": {}, + "sdp-monitoring": "" + } + ] + }, + "connection-groups": { + "connection-group": [ + { + "id": "CU-N32_UPF-N32", + "connectivity-type": "ietf-vpn-common:any-to-any", + "connectivity-construct": [ + { + "id": 1, + "a2a-sdp": [ + { + "sdp-id": "A" + }, + { + "sdp-id": "B" + } + ] + } + ], + "status": {} + } + ] + } + } + ] + } +} \ No newline at end of file diff --git a/src/tests/requests/l3vpn_test.json b/src/tests/requests/l3vpn_test.json new file mode 100644 index 0000000..4564739 --- /dev/null +++ b/src/tests/requests/l3vpn_test.json @@ -0,0 +1,164 @@ +{ + "ietf-network-slice-service:network-slice-services": { + "slo-sle-templates": { + "slo-sle-template": [ + { + "id": "A", + "description": "", + "slo-policy": { + "metric-bound": [ + { + "metric-type": "one-way-bandwidth", + "metric-unit": "kbps", + "bound": 20000000.67 + }, + { + "metric-type": "one-way-delay-maximum", + "metric-unit": "milliseconds", + "bound": 5.5 + } + ], + "availability": 95, + "mtu": 1450 + }, + "sle-policy": { + "security": "", + "isolation": "", + "path-constraints": { + "service-functions": "", + "diversity": { + "diversity": { + "diversity-type": "" + } + } + } + } + } + ] + }, + "slice-service": [ + { + "id": "slice-service-91327140-7361-41b3-aa45-e84a7fb40b79", + "description": "Transport network slice mapped with 3GPP slice NetworkSlice1", + "service-tags": { + "tag-type": [ + { + "tag-type": "service", + "tag-type-value": [ + "L3" + ] + } + ] + }, + "slo-sle-policy": { + "slo-sle-template": "A" + }, + "status": {}, + "sdps": { + "sdp": [ + { + "id": "", + "geo-location": "", + "node-id": "CU-N2", + "sdp-ip-address": "10.60.11.3", + "tp-ref": "", + "service-match-criteria": { + "match-criterion": [ + { + "index": 1, + "match-type": "VLAN", + "value": "100", + "target-connection-group-id": "CU-N2_AMF-N2" + } + ] + }, + "incoming-qos-policy": "", + "outgoing-qos-policy": "", + "sdp-peering": { + "peer-sap-id": "", + "protocols": "" + }, + "ac-svc-ref": [], + "attachment-circuits": { + "attachment-circuit": [ + { + "id": "100", + "ac-ipv4-address": "10.60.11.3", + "ac-ipv4-prefix-length": 0, + "sdp-peering": { + "peer-sap-id": "1.1.1.1" + }, + "status": {} + } + ] + }, + "status": {}, + "sdp-monitoring": "" + }, + { + "id": "", + "geo-location": "", + "node-id": "AMF-N2", + "sdp-ip-address": "10.60.60.105", + "tp-ref": "", + "service-match-criteria": { + "match-criterion": [ + { + "index": 1, + "match-type": "VLAN", + "value": "100", + "target-connection-group-id": "CU-N2_AMF-N2" + } + ] + }, + "incoming-qos-policy": "", + "outgoing-qos-policy": "", + "sdp-peering": { + "peer-sap-id": "", + "protocols": "" + }, + "ac-svc-ref": [], + "attachment-circuits": { + "attachment-circuit": [ + { + "id": "200", + "ac-ipv4-address": "10.60.60.105", + "ac-ipv4-prefix-length": 0, + "sdp-peering": { + "peer-sap-id": "3.3.3.3" + }, + "status": {} + } + ] + }, + "status": {}, + "sdp-monitoring": "" + } + ] + }, + "connection-groups": { + "connection-group": [ + { + "id": "CU-N2_AMF-N2", + "connectivity-type": "ietf-vpn-common:any-to-any", + "connectivity-construct": [ + { + "id": 1, + "a2a-sdp": [ + { + "sdp-id": "01" + }, + { + "sdp-id": "02" + } + ] + } + ], + "status": {} + } + ] + } + } + ] + } + } \ No newline at end of file diff --git a/src/tests/requests/slice_request.json b/src/tests/requests/slice_request.json new file mode 100644 index 0000000..f215078 --- /dev/null +++ b/src/tests/requests/slice_request.json @@ -0,0 +1,162 @@ +{ + "ietf-network-slice-service:network-slice-services": { + "slo-sle-templates": { + "slo-sle-template": [ + { + "id": "A", + "description": "", + "slo-policy": { + "metric-bound": [ + { + "metric-type": "one-way-bandwidth", + "metric-unit": "kbps", + "bound": 2000 + }, + { + "metric-type": "one-way-delay-maximum", + "metric-unit": "milliseconds", + "bound": 5 + } + ] + }, + "sle-policy": { + "security": "", + "isolation": "", + "path-constraints": { + "service-functions": "", + "diversity": { + "diversity": { + "diversity-type": "" + } + } + } + } + } + ] + }, + "slice-service": [ + { + "id": "slice-service-11327140-7361-41b3-aa45-e84a7fb40be9", + "description": "Transport network slice mapped with 3GPP slice NetworkSlice1", + "service-tags": { + "tag-type": [ + { + "tag-type": "service", + "tag-type-value": [ + "L2" + ] + } + ] + }, + "slo-sle-policy": { + "slo-sle-template": "A" + }, + "status": {}, + "sdps": { + "sdp": [ + { + "id": "", + "geo-location": "", + "node-id": "CU-N2", + "sdp-ip-address": "10.60.11.3", + "tp-ref": "", + "service-match-criteria": { + "match-criterion": [ + { + "index": 1, + "match-type": "VLAN", + "value": "100", + "target-connection-group-id": "CU-N2_AMF-N2" + } + ] + }, + "incoming-qos-policy": "", + "outgoing-qos-policy": "", + "sdp-peering": { + "peer-sap-id": "", + "protocols": "" + }, + "ac-svc-ref": [], + "attachment-circuits": { + "attachment-circuit": [ + { + "id": "100", + "ac-ipv4-address": "10.60.11.3", + "ac-ipv4-prefix-length": 0, + "sdp-peering": { + "peer-sap-id": "1.1.1.1" + }, + "status": {} + } + ] + }, + "status": {}, + "sdp-monitoring": "" + }, + { + "id": "", + "geo-location": "", + "node-id": "AMF-N2", + "sdp-ip-address": "10.60.60.105", + "tp-ref": "", + "service-match-criteria": { + "match-criterion": [ + { + "index": 1, + "match-type": "VLAN", + "value": "100", + "target-connection-group-id": "CU-N2_AMF-N2" + } + ] + }, + "incoming-qos-policy": "", + "outgoing-qos-policy": "", + "sdp-peering": { + "peer-sap-id": "", + "protocols": "" + }, + "ac-svc-ref": [], + "attachment-circuits": { + "attachment-circuit": [ + { + "id": "200", + "ac-ipv4-address": "10.60.60.105", + "ac-ipv4-prefix-length": 0, + "sdp-peering": { + "peer-sap-id": "3.3.3.3" + }, + "status": {} + } + ] + }, + "status": {}, + "sdp-monitoring": "" + } + ] + }, + "connection-groups": { + "connection-group": [ + { + "id": "CU-N2_AMF-N2", + "connectivity-type": "ietf-vpn-common:any-to-any", + "connectivity-construct": [ + { + "id": 1, + "a2a-sdp": [ + { + "sdp-id": "01" + }, + { + "sdp-id": "02" + } + ] + } + ], + "status": {} + } + ] + } + } + ] + } + } \ No newline at end of file diff --git a/src/tests/test_api.py b/src/tests/test_api.py new file mode 100644 index 0000000..7264ab9 --- /dev/null +++ b/src/tests/test_api.py @@ -0,0 +1,301 @@ +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 flask import Flask +from src.main import NSController +from src.api.main import Api + + +# Load environment variables +load_dotenv() + +@pytest.fixture(scope="session") +def flask_app(): + """Crea una app Flask mínima para los 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') + }) + return app + + +@pytest.fixture(autouse=True) +def push_flask_context(flask_app): + """Empuja automáticamente un contexto Flask para cada test.""" + with flask_app.app_context(): + yield + +@pytest.fixture +def temp_db(tmp_path): + """Fixture to create and cleanup 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() + cursor.execute(""" + CREATE TABLE IF NOT EXISTS slice ( + slice_id TEXT PRIMARY KEY, + intent TEXT NOT NULL, + controller TEXT NOT NULL + ) + """) + conn.commit() + conn.close() + + yield test_db_name + + # Cleanup - properly close connections and remove file + try: + time.sleep(0.1) + if os.path.exists(test_db_name): + os.remove(test_db_name) + except Exception: + time.sleep(0.5) + try: + if os.path.exists(test_db_name): + os.remove(test_db_name) + except: + pass + + +@pytest.fixture +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'), + } + return env_vars + + +@pytest.fixture +def controller_with_mocked_db(temp_db): + """Crea un NSController con base de datos mockeada.""" + with patch('src.database.db.DB_NAME', temp_db): + yield NSController(controller_type="TFS") + + +@pytest.fixture +def ietf_intent(): + """Intent válido en formato IETF.""" + return { + "ietf-network-slice-service:network-slice-services": { + "slo-sle-templates": { + "slo-sle-template": [ + { + "id": "qos1", + "slo-policy": { + "metric-bound": [ + { + "metric-type": "one-way-bandwidth", + "metric-unit": "kbps", + "bound": 1000 + } + ] + } + } + ] + }, + "slice-service": [ + { + "id": "slice-test-1", + "sdps": { + "sdp": [ + { + "sdp-ip-address": "10.0.0.1", + "node-id": "node1", + "service-match-criteria": { + "match-criterion": [ + { + "match-type": "vlan", + "value": "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": "vlan", + "value": "100" + } + ] + }, + "attachment-circuits": { + "attachment-circuit": [ + { + "sdp-peering": { + "peer-sap-id": "R2" + } + } + ] + }, + }, + ] + }, + "service-tags": {"tag-type": {"value": "L3VPN"}}, + } + ], + } + } + + +class TestBasicApiOperations: + """Tests for basic API operations.""" + + def test_get_flows_empty(self, controller_with_mocked_db): + """Debe devolver error cuando no hay 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): + """Debe poder añadir un flow exitosamente.""" + 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): + """Debe poder añadir un flow y luego recuperarlo.""" + 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" + } + ] + + 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): + """Debe poder modificar un flow existente.""" + 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" + + 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): + """Debe borrar un flow concreto.""" + 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): + """Debe borrar todos los flows.""" + 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): + """Debe poder recuperar un flow específico.""" + 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" + } + + result, code = Api(controller_with_mocked_db).get_flows("slice-test-1") + assert code == 200 + assert result["slice_id"] == "slice-test-1" + + +class TestErrorHandling: + """Tests for error handling.""" + + def test_add_flow_with_empty_intent(self, controller_with_mocked_db): + """Debe fallar si se pasa un intent vacío.""" + 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): + """Debe fallar si se pasa None como 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): + """Debe devolver 404 si se pide un slice inexistente.""" + 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): + """Debe fallar si se intenta modificar un flow inexistente.""" + 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): + """Debe fallar si se intenta eliminar un flow inexistente.""" + 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 + + diff --git a/src/tests/test_database.py b/src/tests/test_database.py new file mode 100644 index 0000000..06034eb --- /dev/null +++ b/src/tests/test_database.py @@ -0,0 +1,585 @@ +import pytest +import sqlite3 +import json +import os +import time +from unittest.mock import patch, MagicMock +from src.database.db import ( + 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 + + +@pytest.fixture +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): + conn = sqlite3.connect(test_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 + ) + """) + 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() + + # 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 as e: + # 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): + os.remove(test_db_name) + except: + pass # If it still fails, let pytest's tmp_path cleanup handle it + + +@pytest.fixture +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" + }] + } + }], + "slo-sle-templates": { + "slo-sle-template": [{ + "id": "profile1", + "slo-policy": { + "metric-bound": [{ + "metric-type": "one-way-bandwidth", + "metric-unit": "kbps", + "bound": 1000 + }] + } + }] + } + } + } + + +@pytest.fixture +def simple_intent(): + """Fixture providing simple intent for basic testing.""" + 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): + 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' + + 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): + 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): + 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): + 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): + 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): + 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): + 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): + 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",)) + tfs_result = cursor.fetchone() + 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): + 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 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): + 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): + 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" + + 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["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): + 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 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): + 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") + count = cursor.fetchone()[0] + 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): + 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 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): + 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): + 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 + assert len(result) == 3 + + +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): + 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): + 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): + 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): + 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): + 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 + + +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): + 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): + delete_all_data() # Should not raise error + result = get_all_data() + assert result == [] + + +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): + 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): + 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" + 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" + + 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): + 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): + 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): + # 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): + # 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(0, 3): + update_data(f"slice-{i}", updated_intent, "TFS") + + # Verify updates + for i in range(0, 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): + 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 + assert first_slice["controller"] == "TFS" \ No newline at end of file diff --git a/src/tests/test_e2e.py b/src/tests/test_e2e.py new file mode 100644 index 0000000..9fb9140 --- /dev/null +++ b/src/tests/test_e2e.py @@ -0,0 +1,101 @@ +import pytest +import json +from pathlib import Path +from itertools import product +from src.api.main import Api +from src.main import NSController +from app import create_app + +# Carpeta donde están los JSON de requests +REQUESTS_DIR = Path(__file__).parent / "requests" + +# Lista de todos los flags booleanos que quieres probar +FLAGS_TO_TEST = ["WEBUI_DEPLOY", "DUMP_TEMPLATES", "PLANNER_ENABLED", "PCE_EXTERNAL", "NRP_ENABLED"] + +# Valores posibles para PLANNER_TYPE +PLANNER_TYPE_VALUES = ["ENERGY", "HRAT", "TFS_OPTICAL"] + + +@pytest.fixture +def app(temp_sqlite_db): + """Crea la app Flask con configuración por defecto.""" + app = create_app() + return app + +@pytest.fixture +def client(app): + """Cliente de test de Flask para hacer requests.""" + return app.test_client() + +@pytest.fixture +def set_flags(app): + """Cambia directamente los flags en app.config""" + def _set(flags: dict): + for k, v in flags.items(): + app.config[k] = v + return _set + +@pytest.fixture +def temp_sqlite_db(monkeypatch, tmp_path): + """Usa una base de datos SQLite temporal durante los tests.""" + temp_db_path = tmp_path / "test_slice.db" + monkeypatch.setattr("src.database.db.DB_NAME", str(temp_db_path)) + + # Inicializa la base de datos temporal + from src.database.db import init_db + init_db() + + yield temp_db_path + + # Limpieza al finalizar + if temp_db_path.exists(): + temp_db_path.unlink() + +# Función para cargar todos los JSONs +def load_request_files(): + test_cases = [] + for f in REQUESTS_DIR.glob("*.json"): + with open(f, "r") as file: + json_data = json.load(file) + test_cases.append(json_data) + return test_cases + +# Generador de todas las combinaciones de flags +def generate_flag_combinations(): + bool_values = [True, False] + for combo in product(bool_values, repeat=len(FLAGS_TO_TEST)): + bool_flags = dict(zip(FLAGS_TO_TEST, combo)) + for planner_type in PLANNER_TYPE_VALUES: + yield {**bool_flags, "PLANNER_TYPE": planner_type} + + +# Fixture que combina cada request con cada combinación de flags +def generate_test_cases(): + requests = load_request_files() + for json_data in requests: + for flags in generate_flag_combinations(): + expected_codes = [200,201] + yield (json_data, flags, expected_codes) + +@pytest.mark.parametrize( + "json_data, flags, expected_codes", + list(generate_test_cases()) +) +def test_add_and_delete_flow(app, json_data, flags, expected_codes, set_flags, temp_sqlite_db): + with app.app_context(): + set_flags(flags) + + controller = NSController(controller_type="TFS") + api = Api(controller) + + # Añadir flujo + data, code = api.add_flow(json_data) + assert code in expected_codes, f"Flags en fallo: {flags}" + + # Eliminar flujo si fue creado + if code == 201 and isinstance(data, dict) and "slice_id" in data: + slice_id = data["slice_id"] + _, delete_code = api.delete_flows(slice_id=slice_id) + assert delete_code == 204, f"No se pudo eliminar el slice {slice_id}" + + diff --git a/src/tests/test_initialization.py b/src/tests/test_initialization.py new file mode 100644 index 0000000..c51cc06 --- /dev/null +++ b/src/tests/test_initialization.py @@ -0,0 +1,37 @@ +import pytest + +# Importa tu clase (ajusta el nombre del módulo si es distinto) +from src.main import NSController + +def test_init_default_values(): + """Test that default initialization sets expected values.""" + controller = NSController() + + # Atributo configurable + assert controller.controller_type == "TFS" + + # Atributos internos + assert controller.path == "" + assert controller.response == [] + assert controller.start_time == 0 + assert controller.end_time == 0 + assert controller.setup_time == 0 + +@pytest.mark.parametrize("controller_type", ["TFS", "IXIA", "custom"]) +def test_init_controller_type(controller_type): + """Test initialization with different controller types.""" + controller = NSController(controller_type=controller_type) + assert controller.controller_type == controller_type + +def test_init_independence_between_instances(): + """Test that each instance has independent state (mutable attrs).""" + c1 = NSController() + c2 = NSController() + + # Modifico una lista en una instancia + c1.response.append("test-response") + + # La otra instancia no debería verse afectada + assert c2.response == [] + assert c1.response == ["test-response"] + diff --git a/src/tests/test_mapper.py b/src/tests/test_mapper.py new file mode 100644 index 0000000..219923f --- /dev/null +++ b/src/tests/test_mapper.py @@ -0,0 +1,639 @@ +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 + + +@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 + } + + result = mapper(sample_ietf_intent) + + assert result is 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 + + result = mapper(sample_ietf_intent) + + assert result == {"path": "node1->node2->node3"} + mock_planner_instance.planner.assert_called_once_with(sample_ietf_intent, "ENERGY") + + @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 + + result = mapper(sample_ietf_intent) + + # Verify realizer was called to READ NRP view + assert mock_realizer.call_args_list[0] == call(None, True, "READ") + assert result is 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 + + result = mapper(sample_ietf_intent) + + assert result is 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 + + result = mapper(sample_ietf_intent) + + # 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 + + result = mapper(sample_ietf_intent) + + # Planner should be called and return the result + assert result == {"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 + + result = mapper(sample_ietf_intent) + + # 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 = [] + + mapper(sample_ietf_intent) + + # 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 + + mapper(sample_ietf_intent) + + # 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 + + result = mapper(sample_ietf_intent) + + # 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 + + result = mapper(sample_ietf_intent) + + assert result == 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: + result = mapper(sample_ietf_intent) + 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 + } + }] + } + } + } + + try: + mapper(invalid_intent) + 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 t ypes.""" + 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 \ No newline at end of file diff --git a/src/tests/test_nbi_processor.py b/src/tests/test_nbi_processor.py new file mode 100644 index 0000000..ef13494 --- /dev/null +++ b/src/tests/test_nbi_processor.py @@ -0,0 +1,222 @@ +import pytest +from unittest.mock import patch +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(): + 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(): + # Estructura mínima consistente con translator + return { + "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 + } + }], + }, + "subnetB": { + "EpTransport": ["EpTransport ep3", "EpTransport ep4"], + }, + "EpTransport ep1": { + "qosProfile": "qosA", + "EpApplicationRef": ["EP_N2 epRef1"], + "logicalInterfaceInfo": {"logicalInterfaceType": "typeA", "logicalInterfaceId": "idA"}, + "IpAddress": "1.1.1.1", + "NextHopInfo": "NH1", + }, + "EpTransport ep2": { + "qosProfile": "qosB", + "EpApplicationRef": ["EP_N2 epRef2"], + "logicalInterfaceInfo": {"logicalInterfaceType": "typeB", "logicalInterfaceId": "idB"}, + "IpAddress": "2.2.2.2", + "NextHopInfo": "NH2", + }, + "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"}, + "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"}, + } + + +@pytest.fixture +def fake_template(): + # Plantilla mínima para que el traductor funcione + return { + "ietf-network-slice-service:network-slice-services": { + "slo-sle-templates": { + "slo-sle-template": [ + {"id": "", "slo-policy": {"metric-bound": []}} + ] + }, + "slice-service": [ + { + "id": "", + "description": "", + "slo-sle-policy": {}, + "sdps": {"sdp": [ + {"service-match-criteria": {"match-criterion": [{}]}, "attachment-circuits": {"attachment-circuit": [{"sdp-peering": {}}]}}, + {"service-match-criteria": {"match-criterion": [{}]}, "attachment-circuits": {"attachment-circuit": [{"sdp-peering": {}}]}} + ]}, + "connection-groups": {"connection-group": [{}]}, + } + ], + } + } + + +# ---------- 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": {}} + result = nbi_processor(gpp_intent) + assert isinstance(result, list) + 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({}) + + +# ---------- 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 + result = translator(gpp_intent, "subnetA") + + assert isinstance(result, dict) + assert "ietf-network-slice-service:network-slice-services" in result + + slice_service = result["ietf-network-slice-service:network-slice-services"]["slice-service"][0] + assert slice_service["id"].startswith("slice-service-") + assert "description" in slice_service + assert slice_service["slo-sle-policy"]["slo-sle-template"] == "qosA" # viene del ep1 + +import re +import uuid + + +# ---------- Extra detect_format ---------- + +@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(): + # Si tiene IETF y 3GPP, debe priorizar IETF + 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): + # Quitar networkSliceSubnetRef debería provocar ValueError en translator loop + broken = gpp_intent.copy() + broken["RANSliceSubnet1"] = {} # no tiene "networkSliceSubnetRef" + with pytest.raises(KeyError): + nbi_processor(broken) + + +# ---------- 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"] + metric_types = {m["metric-type"] for m in metrics} + assert "one-way-delay-maximum" in metric_types + assert "one-way-bandwidth" in metric_types + + +@patch("src.nbi_processor.translator.load_template") +def test_translator_empty_profile(mock_load_template, gpp_intent, fake_template): + mock_load_template.return_value = fake_template + gpp_intent["subnetA"]["SliceProfileList"] = [{}] # vacío + 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"] + assert metrics == [] # no debería añadir nada + +@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 + result = translator(gpp_intent, "subnetA") + slice_service = result["ietf-network-slice-service:network-slice-services"]["slice-service"][0] + + sdp0 = slice_service["sdps"]["sdp"][0] + assert sdp0["node-id"] == "ep1" + assert re.match(r"^\d+\.\d+\.\d+\.\d+$", sdp0["attachment-circuits"]["attachment-circuit"][0]["ac-ipv4-address"]) + assert "target-connection-group-id" in sdp0["service-match-criteria"]["match-criterion"][0] + + sdp1 = slice_service["sdps"]["sdp"][1] + assert sdp1["node-id"] == "ep2" + assert sdp1["attachment-circuits"]["attachment-circuit"][0]["sdp-peering"]["peer-sap-id"].startswith("NH") + + +@patch("src.nbi_processor.translator.load_template") +def test_translator_with_single_endpoint_should_fail(mock_load_template, gpp_intent, fake_template): + mock_load_template.return_value = fake_template + gpp_intent["subnetA"]["EpTransport"] = ["EpTransport ep1"] # solo uno + with pytest.raises(IndexError): + translator(gpp_intent, "subnetA") diff --git a/src/tests/test_utils.py b/src/tests/test_utils.py new file mode 100644 index 0000000..7b887a4 --- /dev/null +++ b/src/tests/test_utils.py @@ -0,0 +1,182 @@ +import json +import pytest +import os + +from src.utils.load_template import load_template +from src.utils.dump_templates import dump_templates +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): + """Crea un archivo JSON temporal válido y devuelve su ruta y contenido.""" + data = {"name": "test"} + file_path = tmp_path / "template.json" + file_path.write_text(json.dumps(data)) + return file_path, data + + +def test_load_template_ok(tmp_json_file): + """Debe cargar correctamente un JSON válido.""" + file_path, expected = tmp_json_file + result = load_template(str(file_path)) + assert result == expected + + +def test_load_template_invalid(tmp_path): + """Debe devolver un response con error si el JSON es inválido.""" + bad_file = tmp_path / "bad.json" + bad_file.write_text("{invalid json}") + + result, code = load_template(str(bad_file)) + assert code == 500 + assert result["success"] is False + assert "Template loading error" in result["error"] + +def test_dump_templates_enabled(monkeypatch, tmp_path): + """Debe volcar múltiples JSON correctamente en src/templates si DUMP_TEMPLATES está activado.""" + templates_dir = tmp_path / "src" / "templates" + templates_dir.mkdir(parents=True) + + monkeypatch.setattr("src.utils.dump_templates.TEMPLATES_PATH", str(templates_dir)) + + app = Flask(__name__) + app.config["DUMP_TEMPLATES"] = True + + with app.app_context(): + nbi = {"nbi": 1} + ietf = {"ietf": 2} + realizer = {"realizer": 3} + + dump_templates(nbi, ietf, realizer) + + for name, data in [("nbi_template.json", nbi), ("ietf_template.json", ietf), ("realizer_template.json", realizer)]: + file_path = templates_dir / name + assert file_path.exists() + assert json.loads(file_path.read_text()) == data + +def test_dump_templates_disabled(monkeypatch, tmp_path): + """No debe escribir nada en src/templates si DUMP_TEMPLATES está desactivado.""" + templates_dir = tmp_path / "src" / "templates" + templates_dir.mkdir(parents=True) + + monkeypatch.setattr("src.utils.dump_templates.TEMPLATES_PATH", str(templates_dir)) + + app = Flask(__name__) + app.config["DUMP_TEMPLATES"] = False + + with app.app_context(): + dump_templates({"nbi": 1}, {"ietf": 2}, {"realizer": 3}) + + for name in ["nbi_template.json", "ietf_template.json", "realizer_template.json"]: + assert not (templates_dir / name).exists() + +def test_send_response_success(): + """Debe devolver success=True y code=200 si el resultado es True.""" + resp, code = send_response(True, data={"k": "v"}) + assert code == 200 + assert resp["success"] is True + assert resp["data"]["k"] == "v" + assert resp["error"] is None + + +def test_send_response_error(): + """Debe devolver success=False y code=400 si el resultado es False.""" + resp, code = send_response(False, message="fallo") + assert code == 400 + assert resp["success"] is False + assert resp["data"] is None + assert "fallo" in resp["error"] + +def ietf_intent(): + """Intento válido en formato IETF simplificado.""" + return { + "ietf-network-slice-service:network-slice-services": { + "slo-sle-templates": { + "slo-sle-template": [ + { + "id": "qos1", + "slo-policy": { + "metric-bound": [ + { + "metric-type": "one-way-bandwidth", + "metric-unit": "kbps", + "bound": 1000 + } + ], + "availability": 99.9, + "mtu": 1500 + } + } + ] + }, + "slice-service": [ + { + "id": "slice-test-1", + "sdps": { + "sdp": [ + { + "id": "CU", + "sdp-ip-address": "10.0.0.1", + "service-match-criteria": { + "match-criterion": [{"match-type": "vlan", "value": "100"}] + }, + }, + { + "id": "DU", + "sdp-ip-address": "10.0.0.2", + "service-match-criteria": { + "match-criterion": [{"match-type": "vlan", "value": "100"}] + }, + }, + ] + }, + } + ], + } + } + + +def test_build_response_ok(): + """Debe construir correctamente el response a partir de un intent IETF válido.""" + intent = ietf_intent() + response = [] + result = build_response(intent, response) + + assert isinstance(result, list) + assert len(result) == 1 + + slice_data = result[0] + assert slice_data["id"] == "slice-test-1" + assert slice_data["source"] == "CU" + assert slice_data["destination"] == "DU" + assert slice_data["vlan"] == "100" + + # Validar 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"] == "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) + + +def test_build_response_empty_policy(): + """Debe devolver lista sin constraints si slo-policy está vacío.""" + intent = ietf_intent() + intent["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["slo-policy"] = {} + response = [] + result = build_response(intent, response) + + assert isinstance(result, list) + assert len(result[0]["requirements"]) == 0 + + +def test_build_response_invalid_intent(): + """Debe fallar limpiamente si el intent no tiene la estructura esperada.""" + bad_intent = {} + response = [] + try: + result = build_response(bad_intent, response) + except Exception: + result = [] + assert result == [] -- GitLab From f9b639c788515b50cd4caa79d902b575d5d71763 Mon Sep 17 00:00:00 2001 From: velazquez Date: Mon, 20 Oct 2025 09:44:40 +0200 Subject: [PATCH 22/26] -Update gitlab-ci --- .gitlab-ci.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index edd18a7..79e6500 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,16 +1,11 @@ image: python:3.12 stages: - - build - test before_script: - pip3 install -r requirements.txt -build: - stage: build - script: - - python3 app.py test: stage: test script: -- GitLab From d3bc6b43c57f90373150df6cb92b2fd1a11b1e8c Mon Sep 17 00:00:00 2001 From: Lluis Gifre Renom Date: Thu, 30 Oct 2025 08:59:24 +0000 Subject: [PATCH 23/26] Edit .gitlab-ci.yml --- .gitlab-ci.yml | 83 +++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 76 insertions(+), 7 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 79e6500..f934803 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,12 +1,81 @@ -image: python:3.12 +# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. stages: - - test + - build + - unit_test -before_script: - - pip3 install -r requirements.txt +# Build, tag, and push the Docker image to the GitLab Docker registry +build nsc: + variables: + IMAGE_NAME: 'nsc' + IMAGE_TAG: 'test' + stage: build + before_script: + - docker image prune --force + - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY + script: + - docker buildx build -t "$IMAGE_NAME:$IMAGE_TAG" -f ./Dockerfile . + - docker tag "$IMAGE_NAME:$IMAGE_TAG" "$CI_REGISTRY_IMAGE/$IMAGE_NAME:$IMAGE_TAG" + - docker push "$CI_REGISTRY_IMAGE/$IMAGE_NAME:$IMAGE_TAG" + after_script: + - docker image prune --force + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && ($CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "develop" || $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == $CI_DEFAULT_BRANCH)' + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "develop"' + +# Apply unit test to the component +unit_test nsc: + timeout: 15m + variables: + IMAGE_NAME: 'nsc' # name of the microservice + IMAGE_TAG: 'test' # tag of the container image (production, development, etc) + stage: unit_test + needs: + - build nsc + before_script: + # Do Docker cleanup + - docker ps --all --quiet | xargs --no-run-if-empty docker stop + - docker container prune --force + - docker ps --all --quiet | xargs --no-run-if-empty docker rm --force + - docker image prune --force + - docker network prune --force + - docker volume prune --all --force + - docker buildx prune --force -test: - stage: test + - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY script: - - python3 -m pytest \ No newline at end of file + - docker pull "$CI_REGISTRY_IMAGE/$IMAGE_NAME:$IMAGE_TAG" + - docker run --name $IMAGE_NAME -d -p 8081:8081 -v "$PWD/src/tests:/opt/results" $CI_REGISTRY_IMAGE/$IMAGE_NAME:$IMAGE_TAG + - sleep 5 + - docker ps -a + - docker logs $IMAGE_NAME + - docker exec -i $IMAGE_NAME bash -c "coverage run -m pytest --log-level=INFO --verbose --junitxml=/opt/results/report.xml" + - docker exec -i $IMAGE_NAME bash -c "coverage report --include='${IMAGE_NAME}/*' --show-missing" + coverage: '/TOTAL\s+\d+\s+\d+\s+(\d+%)/' + after_script: + # Clean up + - docker ps --all --quiet | xargs --no-run-if-empty docker stop + - docker container prune --force + - docker ps --all --quiet | xargs --no-run-if-empty docker rm --force + - docker network prune --force + - docker volume prune --all --force + - docker image prune --force + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && ($CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "develop" || $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == $CI_DEFAULT_BRANCH)' + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "develop"' + artifacts: + when: always + reports: + junit: report.xml -- GitLab From 07f0655c3beb866731454e91dc0542bc5827ab4e Mon Sep 17 00:00:00 2001 From: Lluis Gifre Renom Date: Thu, 30 Oct 2025 09:04:26 +0000 Subject: [PATCH 24/26] Edit requirements.txt --- requirements.txt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 1622628..6e8674f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,4 +20,6 @@ flask-restx netmiko requests pandas -dotenv \ No newline at end of file +dotenv +coverage +pytest -- GitLab From dbb769944a5adb3a4a04181e41266348d37780a8 Mon Sep 17 00:00:00 2001 From: Lluis Gifre Renom Date: Thu, 30 Oct 2025 09:12:53 +0000 Subject: [PATCH 25/26] Edit .gitlab-ci.yml --- .gitlab-ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index f934803..a50341e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -62,7 +62,7 @@ unit_test nsc: - docker ps -a - docker logs $IMAGE_NAME - docker exec -i $IMAGE_NAME bash -c "coverage run -m pytest --log-level=INFO --verbose --junitxml=/opt/results/report.xml" - - docker exec -i $IMAGE_NAME bash -c "coverage report --include='${IMAGE_NAME}/*' --show-missing" + - docker exec -i $IMAGE_NAME bash -c "coverage report --show-missing" coverage: '/TOTAL\s+\d+\s+\d+\s+(\d+%)/' after_script: # Clean up @@ -78,4 +78,4 @@ unit_test nsc: artifacts: when: always reports: - junit: report.xml + junit: src/tests/report.xml -- GitLab From a161eb067febbbb13f868153723c784d1e2e0d5e Mon Sep 17 00:00:00 2001 From: velazquez Date: Thu, 30 Oct 2025 12:37:19 +0100 Subject: [PATCH 26/26] Update README --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index 2208b70..c05f28c 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ The Network Slice Controller (NSC) is a component defined by the IETF to orchest - [Ixia Configuration](#ixia-configuration) - [WebUI](#webui-1) 9. [Usage](#usage) +10. [Available Branches and Releases](#available-branches-and-releases) --- @@ -175,3 +176,16 @@ To use the NSC, just build the image an run it in a container following these st Send slice requests via **API** (/nsc) or **WebUI** (/webui) +## Available branches and releases + +[![Latest Release](https://labs.etsi.org/rep/tfs/nsc/-/badges/release.svg)](https://labs.etsi.org/rep/tfs/nsc/-/releases) + +- The branch `main` ([![pipeline status](https://labs.etsi.org/rep/tfs/nsc/badges/main/pipeline.svg)](https://labs.etsi.org/rep/tfs/nsc/-/commits/main) [![coverage report](https://labs.etsi.org/rep/tfs/nsc/badges/main/coverage.svg)](https://labs.etsi.org/rep/tfs/nsc/-/commits/main)), points always to the latest stable version of the TeraFlowSDN Network Slice Controller (NSC). + +- The branches `release/X.Y.Z`, point to the code for the different release versions indicated in the branch name. + - Code in these branches can be considered stable, and no new features are planned. + - In case of bugs, point releases increasing revision number (Z) might be created. + +- The `develop` ([![pipeline status](https://labs.etsi.org/rep/tfs/nsc/badges/develop/pipeline.svg)](https://labs.etsi.org/rep/tfs/nsc/-/commits/develop) [![coverage report](https://labs.etsi.org/rep/tfs/nsc/badges/develop/coverage.svg)](https://labs.etsi.org/rep/tfs/nsc/-/commits/develop)) branch is the main development branch and contains the latest contributions. + - **Use it with care! It might not be stable.** + - The latest developments and contributions are added to this branch for testing and validation before reaching a release. -- GitLab