From 08af5213ccd4469043731729225dcc35f5f694e7 Mon Sep 17 00:00:00 2001 From: armingol Date: Tue, 30 Jun 2026 08:13:45 +0000 Subject: [PATCH 1/6] feat: add support for network slice updates, alert subscriptions, and CRUD operations to the TFS connector. --- src/main.py | 59 ++++++++++++++++++- src/mapper/main.py | 6 +- .../e2e_optical_planner/e2e_optical.py | 9 ++- src/planner/planner.py | 7 ++- src/realizer/e2e/e2e_connect.py | 11 +++- src/realizer/send_controller.py | 8 ++- src/realizer/tfs/helpers/tfs_connector.py | 49 ++++++++++++--- src/utils/build_response.py | 26 +++++++- 8 files changed, 148 insertions(+), 27 deletions(-) diff --git a/src/main.py b/src/main.py index ba64187..a304a1a 100644 --- a/src/main.py +++ b/src/main.py @@ -63,7 +63,7 @@ class NSController: self.end_time = 0 self.setup_time = 0 - def nsc(self, intent_json, slice_id=None): + def nsc(self, intent_json, slice_id=None, old_service_id=None): """ Main Network Slice Controller method to process and realize network slice intents. @@ -79,6 +79,7 @@ class NSController: Args: intent_json (dict): Network slice intent in 3GPP or IETF format slice_id (str, optional): Existing slice identifier for modification + old_service_id (str, optional): Old service identifier to delete when modifying Returns: dict: Contains slice creation responses and setup time in milliseconds @@ -94,10 +95,12 @@ class NSController: # Process intent (translate if 3GPP) ietf_intents = nbi_processor(intent_json) + is_update = True if slice_id else False + for intent in ietf_intents: logging.info(intent) # Mapper - services, rules = mapper(intent, controller_type=self.controller_type) + services, rules = mapper(intent, controller_type=self.controller_type, is_update=is_update) logging.debug(f"Services: {services}") # Build response self.response = build_response(intent, self.response, controller_type= self.controller_type) @@ -117,11 +120,61 @@ class NSController: raise RuntimeError("No service to process.") # Send config to controllers - response = send_controller(self.controller_type, requests) + is_update = True if slice_id else False + response = send_controller(self.controller_type, requests, is_update=is_update, old_service_id=old_service_id) if not response: raise Exception("Controller upload failed") + # Alert subscription if flag is enabled + from flask import current_app + import os + import requests as http_requests + if current_app.config.get("SUBSCRIBE_ALERTS"): + sub_url = current_app.config.get("SUBSCRIBE_ALERTS_URL") + if sub_url: + sub_slice_id = slice_id + if not sub_slice_id: + try: + for intent in ietf_intents: + sub_slice_id = intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"] + break + except Exception: + pass + + if sub_slice_id: + logging.info(f"Subscribing to alerts at: {sub_url}") + payload = { + "tapi-notification:input": { + "subscription-filter": { + "requested-notification-types": [ + "ALARM_EVENT", + "PATH_RESTORED" + ] + }, + "subscriber-id": "e2e-orchestrator" + } + } + try: + headers = {"Content-Type": "application/json"} + sub_resp = http_requests.post(sub_url, json=payload, headers=headers, timeout=10) + logging.info(f"Subscription response status: {sub_resp.status_code}") + if sub_resp.status_code in (200, 201): + resp_json = sub_resp.json() + logging.info(f"Subscription response: {resp_json}") + output = resp_json.get("tapi-notification:output", {}) + subscription_id = output.get("subscription-id") or output.get("tapi-notification:subscription", {}).get("subscription-id") + if subscription_id: + from src.database.db import save_subscription + save_subscription(subscription_id, sub_slice_id) + logging.info(f"Successfully mapped subscription_id {subscription_id} to slice_id {sub_slice_id}") + else: + logging.warning("subscription-id not found in response output") + else: + logging.error(f"Subscription failed: {sub_resp.text}") + except Exception as e: + logging.error(f"Failed to subscribe to notification service: {e}") + # End performance tracking 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 6a3b160..d05276d 100644 --- a/src/mapper/main.py +++ b/src/mapper/main.py @@ -25,7 +25,7 @@ from flask import current_app from src.database.sysrepo_store import get_data_store, create_data_store, delete_data_store, update_data_store, normalize_libyang_data from src.database.service_db import save_data, update_data -def mapper(ietf_intent, controller_type="TFS"): +def mapper(ietf_intent, controller_type="TFS", is_update=False): """ Map an IETF network slice intent to the most suitable Network Resource Partition (NRP). @@ -39,6 +39,8 @@ def mapper(ietf_intent, controller_type="TFS"): Args: ietf_intent (dict): IETF-formatted network slice intent. + controller_type (str): Type of SDN controller. + is_update (bool): Whether it is a modification request. Returns: dict or None: Optimal path if planner is enabled; otherwise, None. @@ -79,7 +81,7 @@ def mapper(ietf_intent, controller_type="TFS"): # 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, current_app.config["PLANNER_TYPE"]) + optimal_path = Planner().planner(ietf_intent, current_app.config["PLANNER_TYPE"], is_update=is_update) logging.debug(f"Optimal path: {optimal_path}") if controller_type == "RESTCONF": diff --git a/src/planner/e2e_optical_planner/e2e_optical.py b/src/planner/e2e_optical_planner/e2e_optical.py index 2c4f35e..75854b4 100644 --- a/src/planner/e2e_optical_planner/e2e_optical.py +++ b/src/planner/e2e_optical_planner/e2e_optical.py @@ -27,11 +27,10 @@ def e2e_optical_planner(intent, ip: str, action: str = "create") -> dict: action (str, optional): Operation to perform - "create" or "delete". Defaults to "create" """ - if action == 'delete': - logging.debug("DELETE REQUEST RECEIVED FOR E2EOptical: %s", intent) - return None - - url = f"http://{ip}/tfs-api/e2e_path_computation" + if action == 'update': + url = f"http://{ip}/tfs-api/recompute_optical_path" + else: + url = f"http://{ip}/tfs-api/e2e_path_computation" headers = { "Content-Type": "application/json", "Accept": "application/json" diff --git a/src/planner/planner.py b/src/planner/planner.py index 2a35d39..b738910 100644 --- a/src/planner/planner.py +++ b/src/planner/planner.py @@ -30,13 +30,14 @@ class Planner: Planner class to compute the optimal path for a network slice based on energy consumption and topology. """ - def planner(self, intent, type): + def planner(self, intent, type, is_update=False): """ 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) + is_update (bool): Whether this is an update/modification request Returns: dict or None: Planner result or None if type is invalid @@ -48,6 +49,8 @@ class Planner: # Use HRAT planner with configured IP elif type == "HRAT" : return hrat_planner(intent, current_app.config["HRAT_IP"]) # Use E2E optical planner with configured IP - elif type == "E2E_OPTICAL": return e2e_optical_planner(intent, current_app.config["E2E_OPTICAL_IP"], action = "create") + elif type == "E2E_OPTICAL": + action = "update" if is_update else "create" + return e2e_optical_planner(intent, current_app.config["E2E_OPTICAL_IP"], action = action) # Return None if planner type is unsupported else : return None diff --git a/src/realizer/e2e/e2e_connect.py b/src/realizer/e2e/e2e_connect.py index 71ba059..f4eea6e 100644 --- a/src/realizer/e2e/e2e_connect.py +++ b/src/realizer/e2e/e2e_connect.py @@ -18,13 +18,15 @@ from ..tfs.helpers.tfs_connector import tfs_connector import logging, requests import os -def e2e_connect(requests, controller_ip): +def e2e_connect(requests, controller_ip, is_update=False, old_service_id=None): """ Function to connect end-to-end services in TeraFlowSDN (TFS) controller. Args: requests (list): List of requests to be sent to the TFS e2e controller. controller_ip (str): IP address of the TFS e2e controller. + is_update (bool): If True, updates the service instead of creating it. + old_service_id (str): Old service ID to delete (optional). """ def _extract_slice_id(payload): @@ -58,5 +60,10 @@ def e2e_connect(requests, controller_ip): slice_id = _extract_slice_id(requests) or "TEST-SLICE" logging.info("Connecting end-to-end services in TFS controller at %s with slice ID %s", controller_ip, slice_id) - response = tfs_connector().ipowdm_post(os.getenv("E2E_OPTICAL_IP"), slice_id, requests) + if is_update: + payload = dict(requests) if isinstance(requests, dict) else {"services": requests} + payload["old_service_id"] = old_service_id + response = tfs_connector().ipowdm_put(os.getenv("E2E_OPTICAL_IP"), slice_id, payload) + else: + response = tfs_connector().ipowdm_post(os.getenv("E2E_OPTICAL_IP"), slice_id, requests) return response diff --git a/src/realizer/send_controller.py b/src/realizer/send_controller.py index 96488a1..da285b7 100644 --- a/src/realizer/send_controller.py +++ b/src/realizer/send_controller.py @@ -21,7 +21,7 @@ from .ixia.ixia_connect import ixia_connect from .e2e.e2e_connect import e2e_connect from .restconf.restconf_connect import restconf_connect -def send_controller(controller_type, requests): +def send_controller(controller_type, requests, is_update=False, old_service_id=None): """ Route provisioning requests to the appropriate network controller. @@ -34,11 +34,13 @@ def send_controller(controller_type, requests): - "IXIA": Ixia network emulation controller - "E2E": TeraFlow End-to-End controller requests (dict or list): Configuration requests to be sent to the controller + is_update (bool): Whether it is a modification/update request + old_service_id (str): Old service ID to delete (optional) 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: @@ -60,7 +62,7 @@ def send_controller(controller_type, requests): response = ixia_connect(requests, current_app.config["IXIA_IP"]) logging.info("Requests sent to Ixia") elif controller_type == "E2E": - response = e2e_connect(requests, current_app.config["TFS_E2E_IP"]) + response = e2e_connect(requests, current_app.config["TFS_E2E_IP"], is_update=is_update, old_service_id=old_service_id) logging.info("Requests sent to Teraflow E2E") elif controller_type == "RESTCONF": response = restconf_connect(requests, current_app.config["RESTCONF_IP"]) diff --git a/src/realizer/tfs/helpers/tfs_connector.py b/src/realizer/tfs/helpers/tfs_connector.py index c817bf2..c927b23 100644 --- a/src/realizer/tfs/helpers/tfs_connector.py +++ b/src/realizer/tfs/helpers/tfs_connector.py @@ -95,16 +95,49 @@ class tfs_connector(): url = f'http://{tfs_ip}/restconf/ipowdm/v1/service/{slice_id}' headers = {'Content-Type': 'application/json'} data = json.dumps(payload) - # logging.debug("Posting IPoWDM to %s: %s", url, data) - # response = session.post(url, headers=headers, data=data, timeout=timeout) - # response.raise_for_status() - # logging.debug("Http response: %s", response.text) + logging.debug("Posting IPoWDM to %s: %s", url, data) + response = session.post(url, headers=headers, data=data, timeout=timeout) + response.raise_for_status() + logging.debug("Http response: %s", response.text) # STATIC RESPONSE FOR TESTING PURPOSES - response = requests.Response() - response.status_code = 200 - response._content = b'{"status": "success", "message": "IPoWDM service created successfully"}' - logging.debug("Mocked Http response: %s", response.text) + # response = requests.Response() + # response.status_code = 200 + # response._content = b'{"status": "success", "message": "IPoWDM service created successfully"}' + # logging.debug("Mocked Http response: %s", response.text) + return response + + def ipowdm_put(self, tfs_ip: str, slice_id: str, payload: object, timeout: int = 60): + """ + Put (Update) IPoWDM service payload to the controller NBI endpoint: + PUT http://{tfs_ip}/restconf/ipowdm/v1/service/{slice_id} + """ + session = requests.Session() + url = f'http://{tfs_ip}/restconf/ipowdm/v1/service/{slice_id}' + headers = {'Content-Type': 'application/json'} + data = json.dumps(payload) + logging.debug("Putting IPoWDM to %s: %s", url, data) + response = session.put(url, headers=headers, data=data, timeout=timeout) + response.raise_for_status() + logging.debug("Http response: %s", response.text) return response + + def ipowdm_delete(self, tfs_ip: str, slice_id: str, timeout: int = 60): + """ + Delete IPoWDM service payload from the controller NBI endpoint: + DELETE http://{tfs_ip}/restconf/ipowdm/v1/service/{slice_id} + """ + session = requests.Session() + url = f'http://{tfs_ip}/restconf/ipowdm/v1/service/{slice_id}' + headers = {'Content-Type': 'application/json'} + logging.debug("Deleting IPoWDM at %s", url) + try: + response = session.delete(url, headers=headers, json={}, timeout=timeout) + response.raise_for_status() + logging.debug("Delete Http response: %s", response.text) + return response + except Exception as e: + logging.error(f"Failed to delete IPoWDM service: {e}") + return None def nbi_delete(self, tfs_ip: str, service_type: str , service_id: str) -> requests.Response: """ diff --git a/src/utils/build_response.py b/src/utils/build_response.py index 30d3ffd..d3d2837 100644 --- a/src/utils/build_response.py +++ b/src/utils/build_response.py @@ -49,8 +49,30 @@ def build_response(intent, response, controller_type = None): """ 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"]) or 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,"id"]) or safe_get(intent, ["ietf-network-slice-service:network-slice-services","slice-service",0,"sdps","sdp",1,"node-id"]) + + # Try to extract source/destination from connection constructs (P2MP sender/receiver) + p2mp_sender = None + p2mp_receivers = None + slice_service = safe_get(intent, ["ietf-network-slice-service:network-slice-services","slice-service",0]) or {} + connection_groups = slice_service.get("connection-groups", {}).get("connection-group", []) + for cg in connection_groups: + connectivity_constructs = cg.get("connectivity-construct", []) + for cc in connectivity_constructs: + if cc.get("p2mp-sender-sdp"): + p2mp_sender = cc.get("p2mp-sender-sdp") + if cc.get("p2mp-receiver-sdp"): + p2mp_receivers = cc.get("p2mp-receiver-sdp") + + if p2mp_sender: + source = p2mp_sender + else: + source = safe_get(intent, ["ietf-network-slice-service:network-slice-services","slice-service",0,"sdps","sdp",0,"id"]) or safe_get(intent, ["ietf-network-slice-service:network-slice-services","slice-service",0,"sdps","sdp",0,"node-id"]) + + if p2mp_receivers and len(p2mp_receivers) > 0: + destination = p2mp_receivers[0] + else: + destination = safe_get(intent, ["ietf-network-slice-service:network-slice-services","slice-service",0,"sdps","sdp",1,"id"]) or safe_get(intent, ["ietf-network-slice-service:network-slice-services","slice-service",0,"sdps","sdp",1,"node-id"]) + vlan = safe_get(intent, ["ietf-network-slice-service:network-slice-services","slice-service",0,"sdps","sdp",0,"service-match-criteria","match-criterion",0,"match-type", 0, "vlan", 0]) if not id or not source or not destination: -- GitLab From c839d13ead5fd8790d74fe26db00db4d0de5a27f Mon Sep 17 00:00:00 2001 From: armingol Date: Tue, 30 Jun 2026 08:24:23 +0000 Subject: [PATCH 2/6] feat: add service, intent, and alert data files and update planner call signature in tests --- src/tests/test_mapper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tests/test_mapper.py b/src/tests/test_mapper.py index 3dfd1ec..b950937 100644 --- a/src/tests/test_mapper.py +++ b/src/tests/test_mapper.py @@ -351,7 +351,7 @@ class TestMapper: result = mapper(sample_ietf_intent) assert result == ([sample_ietf_intent], {"path": "node1->node2->node3"}) - mock_planner_instance.planner.assert_called_once_with(sample_ietf_intent, "ENERGY") + mock_planner_instance.planner.assert_called_once_with(sample_ietf_intent, "ENERGY", is_update=False) @patch('src.mapper.main.realizer') def test_mapper_with_nrp_enabled_finds_best_nrp(self, mock_realizer, app_context, sample_ietf_intent, sample_nrp_view): -- GitLab From 579b2090c65f7ffcb853905ac395d5aca77a258f Mon Sep 17 00:00:00 2001 From: armingol Date: Tue, 30 Jun 2026 08:13:45 +0000 Subject: [PATCH 3/6] feat: add support for network slice updates, alert subscriptions, and CRUD operations to the TFS connector. --- src/main.py | 63 ++++++++++++++++++- src/mapper/main.py | 7 ++- .../e2e_optical_planner/e2e_optical.py | 9 ++- src/planner/planner.py | 7 ++- src/realizer/e2e/e2e_connect.py | 11 +++- src/realizer/send_controller.py | 8 ++- src/realizer/tfs/helpers/tfs_connector.py | 49 ++++++++++++--- src/utils/build_response.py | 26 +++++++- 8 files changed, 153 insertions(+), 27 deletions(-) diff --git a/src/main.py b/src/main.py index e11ff27..550ef6e 100644 --- a/src/main.py +++ b/src/main.py @@ -64,7 +64,7 @@ class NSController: self.end_time = 0 self.setup_time = 0 - def nsc(self, intent_json, slice_id=None): + def nsc(self, intent_json, slice_id=None, old_service_id=None): """ Main Network Slice Controller method to process and realize network slice intents. @@ -80,6 +80,7 @@ class NSController: Args: intent_json (dict): Network slice intent in 3GPP or IETF format slice_id (str, optional): Existing slice identifier for modification + old_service_id (str, optional): Old service identifier to delete when modifying Returns: dict: Contains slice creation responses and setup time in milliseconds @@ -95,10 +96,16 @@ class NSController: # Process intent (translate if 3GPP) ietf_intents = nbi_processor(intent_json) + is_update = True if slice_id else False + for intent in ietf_intents: logging.debug(intent) + payload = { + "intent": intent, + "is_update": is_update + } # Mapper - services, rules = mapper(intent, controller_type=self.controller_type) + services, rules = mapper(payload, controller_type=self.controller_type) logging.debug(f"Services: {services}") # Build response self.response = build_response(intent, self.response, controller_type= self.controller_type) @@ -118,11 +125,61 @@ class NSController: raise RuntimeError("No service to process.") # Send config to controllers - response = send_controller(self.controller_type, requests) + is_update = True if slice_id else False + response = send_controller(self.controller_type, requests, is_update=is_update, old_service_id=old_service_id) if not response: raise Exception("Controller upload failed") + # Alert subscription if flag is enabled + from flask import current_app + import os + import requests as http_requests + if current_app.config.get("SUBSCRIBE_ALERTS"): + sub_url = current_app.config.get("SUBSCRIBE_ALERTS_URL") + if sub_url: + sub_slice_id = slice_id + if not sub_slice_id: + try: + for intent in ietf_intents: + sub_slice_id = intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"] + break + except Exception: + pass + + if sub_slice_id: + logging.info(f"Subscribing to alerts at: {sub_url}") + payload = { + "tapi-notification:input": { + "subscription-filter": { + "requested-notification-types": [ + "ALARM_EVENT", + "PATH_RESTORED" + ] + }, + "subscriber-id": "e2e-orchestrator" + } + } + try: + headers = {"Content-Type": "application/json"} + sub_resp = http_requests.post(sub_url, json=payload, headers=headers, timeout=10) + logging.info(f"Subscription response status: {sub_resp.status_code}") + if sub_resp.status_code in (200, 201): + resp_json = sub_resp.json() + logging.info(f"Subscription response: {resp_json}") + output = resp_json.get("tapi-notification:output", {}) + subscription_id = output.get("subscription-id") or output.get("tapi-notification:subscription", {}).get("subscription-id") + if subscription_id: + from src.database.db import save_subscription + save_subscription(subscription_id, sub_slice_id) + logging.info(f"Successfully mapped subscription_id {subscription_id} to slice_id {sub_slice_id}") + else: + logging.warning("subscription-id not found in response output") + else: + logging.error(f"Subscription failed: {sub_resp.text}") + except Exception as e: + logging.error(f"Failed to subscribe to notification service: {e}") + # End performance tracking 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 3c1e943..748a246 100644 --- a/src/mapper/main.py +++ b/src/mapper/main.py @@ -40,6 +40,8 @@ def mapper(payload, controller_type="TFS", action="CREATE"): Args: ietf_intent (dict): IETF-formatted network slice intent. + controller_type (str): Type of SDN controller. + is_update (bool): Whether it is a modification request. Returns: dict or None: Optimal path if planner is enabled; otherwise, None. @@ -48,7 +50,7 @@ def mapper(payload, controller_type="TFS", action="CREATE"): services = None if action == "CREATE": - ietf_intent = payload + ietf_intent = payload.get("intent", None) services = [ietf_intent] if current_app.config["NRP_ENABLED"]: # Retrieve NRP view @@ -83,7 +85,8 @@ def mapper(payload, controller_type="TFS", action="CREATE"): # 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, current_app.config["PLANNER_TYPE"]) + is_update = payload.get("is_update", False) + optimal_path = Planner().planner(ietf_intent, current_app.config["PLANNER_TYPE"], is_update=is_update) logging.debug(f"Optimal path: {optimal_path}") if controller_type == "RESTCONF": diff --git a/src/planner/e2e_optical_planner/e2e_optical.py b/src/planner/e2e_optical_planner/e2e_optical.py index 2c4f35e..75854b4 100644 --- a/src/planner/e2e_optical_planner/e2e_optical.py +++ b/src/planner/e2e_optical_planner/e2e_optical.py @@ -27,11 +27,10 @@ def e2e_optical_planner(intent, ip: str, action: str = "create") -> dict: action (str, optional): Operation to perform - "create" or "delete". Defaults to "create" """ - if action == 'delete': - logging.debug("DELETE REQUEST RECEIVED FOR E2EOptical: %s", intent) - return None - - url = f"http://{ip}/tfs-api/e2e_path_computation" + if action == 'update': + url = f"http://{ip}/tfs-api/recompute_optical_path" + else: + url = f"http://{ip}/tfs-api/e2e_path_computation" headers = { "Content-Type": "application/json", "Accept": "application/json" diff --git a/src/planner/planner.py b/src/planner/planner.py index 2a35d39..b738910 100644 --- a/src/planner/planner.py +++ b/src/planner/planner.py @@ -30,13 +30,14 @@ class Planner: Planner class to compute the optimal path for a network slice based on energy consumption and topology. """ - def planner(self, intent, type): + def planner(self, intent, type, is_update=False): """ 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) + is_update (bool): Whether this is an update/modification request Returns: dict or None: Planner result or None if type is invalid @@ -48,6 +49,8 @@ class Planner: # Use HRAT planner with configured IP elif type == "HRAT" : return hrat_planner(intent, current_app.config["HRAT_IP"]) # Use E2E optical planner with configured IP - elif type == "E2E_OPTICAL": return e2e_optical_planner(intent, current_app.config["E2E_OPTICAL_IP"], action = "create") + elif type == "E2E_OPTICAL": + action = "update" if is_update else "create" + return e2e_optical_planner(intent, current_app.config["E2E_OPTICAL_IP"], action = action) # Return None if planner type is unsupported else : return None diff --git a/src/realizer/e2e/e2e_connect.py b/src/realizer/e2e/e2e_connect.py index 71ba059..f4eea6e 100644 --- a/src/realizer/e2e/e2e_connect.py +++ b/src/realizer/e2e/e2e_connect.py @@ -18,13 +18,15 @@ from ..tfs.helpers.tfs_connector import tfs_connector import logging, requests import os -def e2e_connect(requests, controller_ip): +def e2e_connect(requests, controller_ip, is_update=False, old_service_id=None): """ Function to connect end-to-end services in TeraFlowSDN (TFS) controller. Args: requests (list): List of requests to be sent to the TFS e2e controller. controller_ip (str): IP address of the TFS e2e controller. + is_update (bool): If True, updates the service instead of creating it. + old_service_id (str): Old service ID to delete (optional). """ def _extract_slice_id(payload): @@ -58,5 +60,10 @@ def e2e_connect(requests, controller_ip): slice_id = _extract_slice_id(requests) or "TEST-SLICE" logging.info("Connecting end-to-end services in TFS controller at %s with slice ID %s", controller_ip, slice_id) - response = tfs_connector().ipowdm_post(os.getenv("E2E_OPTICAL_IP"), slice_id, requests) + if is_update: + payload = dict(requests) if isinstance(requests, dict) else {"services": requests} + payload["old_service_id"] = old_service_id + response = tfs_connector().ipowdm_put(os.getenv("E2E_OPTICAL_IP"), slice_id, payload) + else: + response = tfs_connector().ipowdm_post(os.getenv("E2E_OPTICAL_IP"), slice_id, requests) return response diff --git a/src/realizer/send_controller.py b/src/realizer/send_controller.py index 96488a1..da285b7 100644 --- a/src/realizer/send_controller.py +++ b/src/realizer/send_controller.py @@ -21,7 +21,7 @@ from .ixia.ixia_connect import ixia_connect from .e2e.e2e_connect import e2e_connect from .restconf.restconf_connect import restconf_connect -def send_controller(controller_type, requests): +def send_controller(controller_type, requests, is_update=False, old_service_id=None): """ Route provisioning requests to the appropriate network controller. @@ -34,11 +34,13 @@ def send_controller(controller_type, requests): - "IXIA": Ixia network emulation controller - "E2E": TeraFlow End-to-End controller requests (dict or list): Configuration requests to be sent to the controller + is_update (bool): Whether it is a modification/update request + old_service_id (str): Old service ID to delete (optional) 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: @@ -60,7 +62,7 @@ def send_controller(controller_type, requests): response = ixia_connect(requests, current_app.config["IXIA_IP"]) logging.info("Requests sent to Ixia") elif controller_type == "E2E": - response = e2e_connect(requests, current_app.config["TFS_E2E_IP"]) + response = e2e_connect(requests, current_app.config["TFS_E2E_IP"], is_update=is_update, old_service_id=old_service_id) logging.info("Requests sent to Teraflow E2E") elif controller_type == "RESTCONF": response = restconf_connect(requests, current_app.config["RESTCONF_IP"]) diff --git a/src/realizer/tfs/helpers/tfs_connector.py b/src/realizer/tfs/helpers/tfs_connector.py index c817bf2..c927b23 100644 --- a/src/realizer/tfs/helpers/tfs_connector.py +++ b/src/realizer/tfs/helpers/tfs_connector.py @@ -95,16 +95,49 @@ class tfs_connector(): url = f'http://{tfs_ip}/restconf/ipowdm/v1/service/{slice_id}' headers = {'Content-Type': 'application/json'} data = json.dumps(payload) - # logging.debug("Posting IPoWDM to %s: %s", url, data) - # response = session.post(url, headers=headers, data=data, timeout=timeout) - # response.raise_for_status() - # logging.debug("Http response: %s", response.text) + logging.debug("Posting IPoWDM to %s: %s", url, data) + response = session.post(url, headers=headers, data=data, timeout=timeout) + response.raise_for_status() + logging.debug("Http response: %s", response.text) # STATIC RESPONSE FOR TESTING PURPOSES - response = requests.Response() - response.status_code = 200 - response._content = b'{"status": "success", "message": "IPoWDM service created successfully"}' - logging.debug("Mocked Http response: %s", response.text) + # response = requests.Response() + # response.status_code = 200 + # response._content = b'{"status": "success", "message": "IPoWDM service created successfully"}' + # logging.debug("Mocked Http response: %s", response.text) + return response + + def ipowdm_put(self, tfs_ip: str, slice_id: str, payload: object, timeout: int = 60): + """ + Put (Update) IPoWDM service payload to the controller NBI endpoint: + PUT http://{tfs_ip}/restconf/ipowdm/v1/service/{slice_id} + """ + session = requests.Session() + url = f'http://{tfs_ip}/restconf/ipowdm/v1/service/{slice_id}' + headers = {'Content-Type': 'application/json'} + data = json.dumps(payload) + logging.debug("Putting IPoWDM to %s: %s", url, data) + response = session.put(url, headers=headers, data=data, timeout=timeout) + response.raise_for_status() + logging.debug("Http response: %s", response.text) return response + + def ipowdm_delete(self, tfs_ip: str, slice_id: str, timeout: int = 60): + """ + Delete IPoWDM service payload from the controller NBI endpoint: + DELETE http://{tfs_ip}/restconf/ipowdm/v1/service/{slice_id} + """ + session = requests.Session() + url = f'http://{tfs_ip}/restconf/ipowdm/v1/service/{slice_id}' + headers = {'Content-Type': 'application/json'} + logging.debug("Deleting IPoWDM at %s", url) + try: + response = session.delete(url, headers=headers, json={}, timeout=timeout) + response.raise_for_status() + logging.debug("Delete Http response: %s", response.text) + return response + except Exception as e: + logging.error(f"Failed to delete IPoWDM service: {e}") + return None def nbi_delete(self, tfs_ip: str, service_type: str , service_id: str) -> requests.Response: """ diff --git a/src/utils/build_response.py b/src/utils/build_response.py index 30d3ffd..d3d2837 100644 --- a/src/utils/build_response.py +++ b/src/utils/build_response.py @@ -49,8 +49,30 @@ def build_response(intent, response, controller_type = None): """ 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"]) or 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,"id"]) or safe_get(intent, ["ietf-network-slice-service:network-slice-services","slice-service",0,"sdps","sdp",1,"node-id"]) + + # Try to extract source/destination from connection constructs (P2MP sender/receiver) + p2mp_sender = None + p2mp_receivers = None + slice_service = safe_get(intent, ["ietf-network-slice-service:network-slice-services","slice-service",0]) or {} + connection_groups = slice_service.get("connection-groups", {}).get("connection-group", []) + for cg in connection_groups: + connectivity_constructs = cg.get("connectivity-construct", []) + for cc in connectivity_constructs: + if cc.get("p2mp-sender-sdp"): + p2mp_sender = cc.get("p2mp-sender-sdp") + if cc.get("p2mp-receiver-sdp"): + p2mp_receivers = cc.get("p2mp-receiver-sdp") + + if p2mp_sender: + source = p2mp_sender + else: + source = safe_get(intent, ["ietf-network-slice-service:network-slice-services","slice-service",0,"sdps","sdp",0,"id"]) or safe_get(intent, ["ietf-network-slice-service:network-slice-services","slice-service",0,"sdps","sdp",0,"node-id"]) + + if p2mp_receivers and len(p2mp_receivers) > 0: + destination = p2mp_receivers[0] + else: + destination = safe_get(intent, ["ietf-network-slice-service:network-slice-services","slice-service",0,"sdps","sdp",1,"id"]) or safe_get(intent, ["ietf-network-slice-service:network-slice-services","slice-service",0,"sdps","sdp",1,"node-id"]) + vlan = safe_get(intent, ["ietf-network-slice-service:network-slice-services","slice-service",0,"sdps","sdp",0,"service-match-criteria","match-criterion",0,"match-type", 0, "vlan", 0]) if not id or not source or not destination: -- GitLab From 341d529ae06054d421217e7541da4eee1b1b1ef4 Mon Sep 17 00:00:00 2001 From: armingol Date: Tue, 30 Jun 2026 08:24:23 +0000 Subject: [PATCH 4/6] feat: add service, intent, and alert data files and update planner call signature in tests --- src/tests/test_mapper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tests/test_mapper.py b/src/tests/test_mapper.py index 3dfd1ec..b950937 100644 --- a/src/tests/test_mapper.py +++ b/src/tests/test_mapper.py @@ -351,7 +351,7 @@ class TestMapper: result = mapper(sample_ietf_intent) assert result == ([sample_ietf_intent], {"path": "node1->node2->node3"}) - mock_planner_instance.planner.assert_called_once_with(sample_ietf_intent, "ENERGY") + mock_planner_instance.planner.assert_called_once_with(sample_ietf_intent, "ENERGY", is_update=False) @patch('src.mapper.main.realizer') def test_mapper_with_nrp_enabled_finds_best_nrp(self, mock_realizer, app_context, sample_ietf_intent, sample_nrp_view): -- GitLab From 5da3255fb849bdfd4fa18e03a9833b7c8802926c Mon Sep 17 00:00:00 2001 From: velazquez Date: Wed, 8 Jul 2026 11:38:36 +0200 Subject: [PATCH 5/6] Fix bug in rebasing --- src/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.py b/src/main.py index 93bc8ab..550ef6e 100644 --- a/src/main.py +++ b/src/main.py @@ -64,7 +64,7 @@ class NSController: self.end_time = 0 self.setup_time = 0 - def nsc(self, intent_json, slice_id=None, old_service_id=None, old_service_id=None): + def nsc(self, intent_json, slice_id=None, old_service_id=None): """ Main Network Slice Controller method to process and realize network slice intents. -- GitLab From 27abe54d48e3900159bdf1126cdf97fa8ff4bfc0 Mon Sep 17 00:00:00 2001 From: velazquez Date: Wed, 8 Jul 2026 12:03:02 +0200 Subject: [PATCH 6/6] Fix test_mapper functions --- src/tests/test_mapper.py | 71 ++++++++++++++++++++++++++++++++-------- 1 file changed, 57 insertions(+), 14 deletions(-) diff --git a/src/tests/test_mapper.py b/src/tests/test_mapper.py index b950937..79c9f83 100644 --- a/src/tests/test_mapper.py +++ b/src/tests/test_mapper.py @@ -330,8 +330,12 @@ class TestMapper: "NRP_ENABLED": False, "PLANNER_ENABLED": False } + + payload = { + "intent": sample_ietf_intent + } - result = mapper(sample_ietf_intent) + result = mapper(payload) assert result == ([sample_ietf_intent], None) @@ -348,7 +352,11 @@ class TestMapper: mock_planner_instance.planner.return_value = {"path": "node1->node2->node3"} mock_planner_class.return_value = mock_planner_instance - result = mapper(sample_ietf_intent) + payload = { + "intent": sample_ietf_intent + } + + result = mapper(payload) assert result == ([sample_ietf_intent], {"path": "node1->node2->node3"}) mock_planner_instance.planner.assert_called_once_with(sample_ietf_intent, "ENERGY", is_update=False) @@ -363,7 +371,10 @@ class TestMapper: mock_realizer.return_value = sample_nrp_view - result = mapper(sample_ietf_intent) + payload = { + "intent": sample_ietf_intent + } + result = mapper(payload) # Verify realizer was called to READ NRP view assert mock_realizer.call_args_list[0] == call(None, True, "READ") @@ -393,8 +404,12 @@ class TestMapper: ] mock_realizer.return_value = nrp_view + + payload = { + "intent": sample_ietf_intent + } - result = mapper(sample_ietf_intent) + result = mapper(payload) assert result == ([sample_ietf_intent], None) @@ -411,7 +426,11 @@ class TestMapper: mock_realizer.side_effect = [nrp_view, None] # First call returns empty, second for CREATE - result = mapper(sample_ietf_intent) + payload = { + "intent": sample_ietf_intent + } + + result = mapper(payload) # Verify CREATE was called create_call = [c for c in mock_realizer.call_args_list if len(c[0]) > 2 and c[0][2] == "CREATE"] @@ -432,8 +451,11 @@ class TestMapper: 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) + + payload = { + "intent": sample_ietf_intent + } + result = mapper(payload) # Planner should be called and return the result assert result == ([sample_ietf_intent],{"path": "optimized_path"}) @@ -448,7 +470,10 @@ class TestMapper: mock_realizer.return_value = sample_nrp_view - result = mapper(sample_ietf_intent) + payload = { + "intent": sample_ietf_intent + } + result = mapper(payload) # Verify UPDATE was called update_calls = [c for c in mock_realizer.call_args_list if len(c[0]) > 2 and c[0][2] == "UPDATE"] @@ -464,7 +489,10 @@ class TestMapper: mock_realizer.return_value = [] - mapper(sample_ietf_intent) + payload = { + "intent": sample_ietf_intent + } + mapper(payload) # Verify the function processed the intent assert mock_realizer.called @@ -480,7 +508,10 @@ class TestMapper: with patch('src.mapper.main.realizer') as mock_realizer: mock_realizer.return_value = sample_nrp_view - mapper(sample_ietf_intent) + payload = { + "intent": sample_ietf_intent + } + mapper(payload) # Verify debug logging was called assert mock_logging.debug.called @@ -499,7 +530,10 @@ class TestMapperIntegration: with patch('src.mapper.main.realizer') as mock_realizer: mock_realizer.return_value = sample_nrp_view - result = mapper(sample_ietf_intent) + payload = { + "intent": sample_ietf_intent + } + result = mapper(payload) # Verify the workflow sequence assert mock_realizer.call_count >= 1 @@ -526,7 +560,10 @@ class TestMapperIntegration: mock_planner_instance.planner.return_value = expected_path mock_planner_class.return_value = mock_planner_instance - result = mapper(sample_ietf_intent) + payload = { + "intent": sample_ietf_intent + } + result = mapper(payload) assert result == ([sample_ietf_intent],expected_path) mock_planner_instance.planner.assert_called_once() @@ -549,7 +586,10 @@ class TestMapperIntegration: # Should handle gracefully try: - result = mapper(sample_ietf_intent) + payload = { + "intent": sample_ietf_intent + } + result = mapper(payload) except (KeyError, TypeError): # Expected to fail gracefully pass @@ -577,8 +617,11 @@ class TestMapperIntegration: } } + payload = { + "intent": invalid_intent + } try: - mapper(invalid_intent) + mapper(payload) except (KeyError, TypeError): # Expected behavior pass -- GitLab