From 2415fb2b057bc9f657d5c53764a21d748f112640 Mon Sep 17 00:00:00 2001 From: velazquez Date: Mon, 10 Aug 2026 11:13:53 +0200 Subject: [PATCH] - Add get_service_path and get_device_name - Change realizer monitor action - Update tests --- src/api/main.py | 18 +-- src/main.py | 6 +- src/planner/shortest_path.py | 4 +- src/realizer/main.py | 26 ++-- .../restconf/connectors/tfs_connector.py | 66 +++++++++- src/tests/test_api.py | 18 +-- src/tests/test_initialization.py | 7 +- src/tests/test_realizer.py | 120 ++++++++++++------ 8 files changed, 172 insertions(+), 93 deletions(-) diff --git a/src/api/main.py b/src/api/main.py index f28ebed..b26bcb0 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -15,7 +15,7 @@ # This file is an original contribution from Telefonica Innovación Digital S.L. from src.utils.send_response import send_response -import logging, json, time, requests, traceback, asyncio, aiohttp +import logging, json, asyncio from flask import current_app from src.database.db import get_data, delete_data, get_all_data, delete_all_data from src.database.service_db import delete_by_slice_id, get_data_by_slice_id @@ -26,7 +26,6 @@ from src.utils.safe_get import safe_get from src.database.sysrepo_store import get_data_store, create_data_store, delete_data_store, update_data_store, normalize_libyang_data from typing import Dict, Tuple from src.realizer.restconf.connectors.tfs_connector import tfs_connector as tfs_restconf_connector -from src.planner.shortest_path import get_shortest_path from src.realizer.tfs.service_types.tfs_l2vpn import tfs_l2vpn_delete @@ -1158,12 +1157,6 @@ class Api: logging.debug(f"Getting telemetry for slice_id: {slice_id}") try: if slice_id is not None: - slice_sdps = get_data_store(f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']/sdps") - logging.debug(f"SDPs for slice_id '{slice_id}': {slice_sdps}") - if not slice_sdps: - raise ValueError("No SDPs found") - if len(slice_sdps) > 2: - raise Exception(f"Monitoring for more than 2 SDPs is not supported. Found {len(slice_sdps)} SDPs.") xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']" existing_slice = get_data_store(xpath) if not existing_slice: @@ -1174,7 +1167,7 @@ class Api: slo_sle_template = next(iter(slo_sle_template), None) if not slo_sle_template: raise ValueError(f"SLO/SLE template '{template_id}' not found for slice '{slice_id}'") - metrics = self.slice_service.monitoring(slice_id, slo_sle_template, slice_sdps) + metrics = self.slice_service.monitoring(slice_id, slo_sle_template) return metrics, 200 telemetry_data = {} @@ -1191,12 +1184,7 @@ class Api: if not slo_sle_template: raise ValueError(f"SLO/SLE template '{selected_template_id}' not found for slice '{slice['id']}'") slice_id = slice["id"] - slice_sdps = get_data_store(f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']/sdps") - if not slice_sdps: - raise ValueError("No SDPs found") - if len(slice_sdps) > 2: - raise Exception(f"Monitoring for more than 2 SDPs is not supported. Found {len(slice_sdps)} SDPs.") - telemetry_data[slice_id] = self.slice_service.monitoring(slice_id, slo_sle_template, slice_sdps) + telemetry_data[slice_id] = self.slice_service.monitoring(slice_id, slo_sle_template) return telemetry_data, 200 except ValueError as e: diff --git a/src/main.py b/src/main.py index f59dedf..3bf626a 100644 --- a/src/main.py +++ b/src/main.py @@ -23,7 +23,6 @@ from src.database.store_data import store_data from src.database.service_db import delete_data from src.mapper.main import mapper from src.realizer.main import realizer -from src.planner.shortest_path import get_shortest_path from src.realizer.send_controller import send_controller class NSController: @@ -197,7 +196,7 @@ class NSController: "setup_time": setup_time } - def monitoring(self, slice_id, slo_sle_template, sdps): + def monitoring(self, slice_id, slo_sle_template): """ Monitor the status of a specific network slice. @@ -219,8 +218,7 @@ class NSController: """ payload = { "slice_id": slice_id, - "slo_sle_template": slo_sle_template, - "sdps": sdps + "slo_sle_template": slo_sle_template } # Request the realizer to retrieve the metrics for the links of the specified slice diff --git a/src/planner/shortest_path.py b/src/planner/shortest_path.py index 1784bf1..8a68aff 100644 --- a/src/planner/shortest_path.py +++ b/src/planner/shortest_path.py @@ -35,7 +35,7 @@ def get_shortest_path(network, src_node_id, dst_node_id, directed_graph: bool = nodes = network["node"] links = network["ietf-network-topology:link"] - # Construir grafo + # Build graph graph = { normalize_node_id(node["node-id"]): set() for node in nodes @@ -77,7 +77,7 @@ def get_shortest_path(network, src_node_id, dst_node_id, directed_graph: bool = if not visited[destination_node_idx]: return {"message": "No path found"}, 404 - # Reconstruir camino + # Rebuild path path = [] at = destination_node_idx diff --git a/src/realizer/main.py b/src/realizer/main.py index 9472767..582cb81 100644 --- a/src/realizer/main.py +++ b/src/realizer/main.py @@ -19,7 +19,7 @@ from .select_way import select_way from .nrp_handler import nrp_handler from .get_metrics import get_metrics from src.utils.safe_get import safe_get -from src.planner.shortest_path import get_shortest_path +from src.database.service_db import get_data_by_slice_id from src.realizer.restconf.connectors.tfs_connector import tfs_connector from flask import current_app @@ -90,20 +90,14 @@ def realizer(payload, need_nrp=False, order=None, nrp=None, controller_type=None elif action == "MONITOR": logging.debug("Realizer action: MONITOR") slice_id = payload.get("slice_id", None) - sdps = payload.get("sdps", None) - sdps_object = safe_get(sdps, ['network-slice-services', 'slice-service']) - sdps_object = sdps_object[slice_id]['sdps']['sdp'] - sdp_ids = [sdp["id"] for sdp in sdps_object] - # Retrieve Network topology - topology, response = tfs_connector().get_network_topology(current_app.config["RESTCONF_IP"],slice_id) + service_data = get_data_by_slice_id(slice_id) + if len(service_data) > 1: + raise ValueError(f"Slices with multiple associated services are not supported: '{slice_id}'") + service_id = service_data[0]["service_id"] + # Retrieve Service Path + path, response = tfs_connector().get_service_path(current_app.config["RESTCONF_IP"], service_id) if response == 200: - logging.debug(f"Retrieved topology for slice '{slice_id}'") - # Get shortest path - path, response = get_shortest_path(topology, sdp_ids[0], sdp_ids[1]) - if response == 200: - logging.debug(f"Retrieved shortest path for slice '{slice_id}': {path}") - get_metrics(path, slice_id, controller_type) - else: - raise Exception("Error: Shortest path not retrieved ") + logging.debug(f"Retrieved service path for slice '{slice_id}' (service '{service_id}'): {path}") + get_metrics(path, slice_id, controller_type) else: - raise Exception("Error: Topology not retrieved") \ No newline at end of file + raise Exception("Error: Service path not retrieved") \ No newline at end of file diff --git a/src/realizer/restconf/connectors/tfs_connector.py b/src/realizer/restconf/connectors/tfs_connector.py index 863c02c..2a8a568 100644 --- a/src/realizer/restconf/connectors/tfs_connector.py +++ b/src/realizer/restconf/connectors/tfs_connector.py @@ -16,7 +16,7 @@ import logging, requests, json, aiohttp, asyncio, threading from src.config.constants import NBI_L2_PATH, NBI_L3_PATH, NBI_IETF_NETWORKS_PATH, NBI_SIMAP_SUSCRIPTION_PATH -from typing import Dict, Tuple +from typing import Dict, Tuple, List # Temp until moving to .env SDN_SUBSCRIPTION_PERIOD = 10 # seconds @@ -141,6 +141,70 @@ class tfs_connector(): return network, 200 + def get_device_name(self, tfs_ip: str, device_uuid: str) -> str: + """ + Get device name for a given device_uuid from TFS. + + Args: + tfs_ip (str): IP address of the TFS instance + device_uuid (str): Device UUID + + Returns: + str: Name of the device + """ + url = f"http://{tfs_ip}/tfs-api/device/{device_uuid}" + headers = {'accept': 'application/json'} + response = requests.get(url, headers=headers, timeout=60) + response.raise_for_status() + device_data = response.json() + return device_data.get("name", "") + + def get_service_path(self, tfs_ip: str, service_id: str) -> Tuple[List[str], int]: + """ + Get ordered list of node names along the service path from TFS. + + Args: + tfs_ip (str): IP address of the TFS instance + service_id (str): Service UUID + + Returns: + Tuple[List[str], int]: List of node names in order and status code + """ + url = f'http://{tfs_ip}/tfs-api/context/admin/service/{service_id}/connections' + headers = {'accept': 'application/json'} + try: + response = requests.get(url, headers=headers, timeout=60) + if response.status_code == 200: + data = response.json() + connections = data.get("connections", []) + raw_uuids = [] + for conn in connections: + for hop in conn.get("path_hops_endpoint_ids", []): + device_id = hop.get("device_id", {}).get("device_uuid", {}).get("uuid") + if device_id and (not raw_uuids or raw_uuids[-1] != device_id): + raw_uuids.append(device_id) + + name_cache = {} + path = [] + for dev_uuid in raw_uuids: + if dev_uuid not in name_cache: + try: + dev_name = self.get_device_name(tfs_ip, dev_uuid) + name_cache[dev_uuid] = dev_name if dev_name else dev_uuid + except Exception as e: + logging.warning(f"Could not retrieve device name for '{dev_uuid}': {e}") + name_cache[dev_uuid] = dev_uuid + path.append(name_cache[dev_uuid]) + + logging.debug(f"Retrieved service path for service '{service_id}': {path}") + return path, 200 + else: + logging.error(f"Failed to retrieve service path for service '{service_id}': status {response.status_code}") + return [], response.status_code + except Exception as e: + logging.exception(f"Error retrieving service path for service '{service_id}': {e}") + return [], 500 + # --- SDN STREAMS --- async def get_session(self): diff --git a/src/tests/test_api.py b/src/tests/test_api.py index e1234dc..bc203f5 100644 --- a/src/tests/test_api.py +++ b/src/tests/test_api.py @@ -1275,7 +1275,6 @@ class TestApiRequestedMethodsCoverage: api = Api(controller_with_mocked_db) # Single slice telemetry success - sdps = [{"id": "sdp-1"}, {"id": "sdp-2"}] existing_slice = { "network-slice-services": { "slice-service": { @@ -1294,31 +1293,21 @@ class TestApiRequestedMethodsCoverage: } } ] - with patch("src.api.main.get_data_store", side_effect=[sdps, existing_slice]), \ + with patch("src.api.main.get_data_store", return_value=existing_slice), \ patch.object(api, "get_slo_sle_templates", return_value=tmpl_data), \ patch.object(api.slice_service, "monitoring", return_value={"bw": 100}): res, code = api.get_telemetry("s1") assert code == 200 assert res == {"bw": 100} - # Single slice telemetry - no SDPs -> 404 - with patch("src.api.main.get_data_store", return_value=None): - res_404, code_404 = api.get_telemetry("s1") - assert code_404 == 404 - - # Single slice telemetry - >2 SDPs -> 500 - with patch("src.api.main.get_data_store", return_value=[1, 2, 3]): - res_500, code_500 = api.get_telemetry("s1") - assert code_500 == 500 - # Single slice telemetry - slice not found -> 404 - with patch("src.api.main.get_data_store", side_effect=[sdps, None]): + with patch("src.api.main.get_data_store", return_value=None): res_no_slice, code_no_slice = api.get_telemetry("s1") assert code_no_slice == 404 # Single slice telemetry - template not found -> 404 empty_tmpl_data = [{"network-slice-services": {"slo-sle-templates": {"slo-sle-template": {}}}}] - with patch("src.api.main.get_data_store", side_effect=[sdps, existing_slice]), \ + with patch("src.api.main.get_data_store", return_value=existing_slice), \ patch.object(api, "get_slo_sle_templates", return_value=empty_tmpl_data): res_no_tmpl, code_no_tmpl = api.get_telemetry("s1") assert code_no_tmpl == 404 @@ -1333,7 +1322,6 @@ class TestApiRequestedMethodsCoverage: } with patch.object(api, "get_slice_services", return_value=(slices_data_dict, 200)), \ patch.object(api, "get_slo_sle_templates", return_value=tmpl_data), \ - patch("src.api.main.get_data_store", return_value=sdps), \ patch.object(api.slice_service, "monitoring", return_value={"bw": 100}): res_all, code_all = api.get_telemetry() assert code_all == 200 diff --git a/src/tests/test_initialization.py b/src/tests/test_initialization.py index f2e45f0..b4fed33 100644 --- a/src/tests/test_initialization.py +++ b/src/tests/test_initialization.py @@ -87,21 +87,20 @@ def test_controller_monitoring(): controller = NSController(controller_type="RESTCONF") slice_id = "slice-mon-1" slo_sle_template = {"slo-policy": {"metric-bound": []}} - sdps = [{"id": "sdp-1"}] with patch("src.main.realizer") as mock_realizer, \ patch("src.main.mapper") as mock_mapper: mock_mapper.return_value = {"slice_id": slice_id, "is_compliant": True} - res = controller.monitoring(slice_id, slo_sle_template, sdps) + res = controller.monitoring(slice_id, slo_sle_template) assert res == {"slice_id": slice_id, "is_compliant": True} mock_realizer.assert_called_once_with( - {"slice_id": slice_id, "slo_sle_template": slo_sle_template, "sdps": sdps}, + {"slice_id": slice_id, "slo_sle_template": slo_sle_template}, action="MONITOR", controller_type="RESTCONF" ) mock_mapper.assert_called_once_with( - {"slice_id": slice_id, "slo_sle_template": slo_sle_template, "sdps": sdps}, + {"slice_id": slice_id, "slo_sle_template": slo_sle_template}, action="MONITOR" ) diff --git a/src/tests/test_realizer.py b/src/tests/test_realizer.py index 134899b..26d9e79 100644 --- a/src/tests/test_realizer.py +++ b/src/tests/test_realizer.py @@ -102,60 +102,57 @@ def test_realizer_create_service_tag_way(sample_ietf_intent): def test_realizer_monitor_success(flask_app): - """Test realizer MONITOR action succeeds when topology and shortest path are retrieved.""" + """Test realizer MONITOR action succeeds when service_id and service path are retrieved.""" payload = { - "slice_id": "slice-1", - "sdps": { - "network-slice-services": { - "slice-service": { - "slice-1": { - "sdps": { - "sdp": [{"id": "A"}, {"id": "B"}] - } - } - } - } - } + "slice_id": "slice-1" } - with patch("src.realizer.main.tfs_connector") as mock_tfs_cls, \ - patch("src.realizer.main.get_shortest_path") as mock_sp, \ + with patch("src.realizer.main.get_data_by_slice_id") as mock_db, \ + patch("src.realizer.main.tfs_connector") as mock_tfs_cls, \ patch("src.realizer.main.get_metrics") as mock_gm: + mock_db.return_value = [{"service_id": "svc-1", "slice_id": "slice-1"}] mock_conn = MagicMock() - mock_conn.get_network_topology.return_value = ({"node": []}, 200) + mock_conn.get_service_path.return_value = (["A", "B"], 200) mock_tfs_cls.return_value = mock_conn - mock_sp.return_value = (["A", "B"], 200) - with flask_app.app_context(): realizer(payload=payload, controller_type="RESTCONF", action="MONITOR") - mock_sp.assert_called_once_with({"node": []}, "A", "B") + mock_db.assert_called_once_with("slice-1") + mock_conn.get_service_path.assert_called_once_with(flask_app.config["RESTCONF_IP"], "svc-1") mock_gm.assert_called_once_with(["A", "B"], "slice-1", "RESTCONF") -def test_realizer_monitor_topology_failure(flask_app): - """Test realizer MONITOR action raises exception when topology retrieval fails.""" +def test_realizer_monitor_service_path_failure(flask_app): + """Test realizer MONITOR action raises exception when service path retrieval fails.""" payload = { - "slice_id": "slice-1", - "sdps": { - "network-slice-services": { - "slice-service": { - "slice-1": { - "sdps": { - "sdp": [{"id": "A"}, {"id": "B"}] - } - } - } - } - } + "slice_id": "slice-1" } - with patch("src.realizer.main.tfs_connector") as mock_tfs_cls: + with patch("src.realizer.main.get_data_by_slice_id") as mock_db, \ + patch("src.realizer.main.tfs_connector") as mock_tfs_cls: + + mock_db.return_value = [{"service_id": "svc-1", "slice_id": "slice-1"}] mock_conn = MagicMock() - mock_conn.get_network_topology.return_value = (None, 500) + mock_conn.get_service_path.return_value = ([], 500) mock_tfs_cls.return_value = mock_conn with flask_app.app_context(): - with pytest.raises(Exception, match="Error: Topology not retrieved"): + with pytest.raises(Exception, match="Error: Service path not retrieved"): + realizer(payload=payload, controller_type="RESTCONF", action="MONITOR") + + +def test_realizer_monitor_multiple_services_failure(flask_app): + """Test realizer MONITOR action raises ValueError when slice has multiple associated services.""" + payload = { + "slice_id": "slice-multi" + } + with patch("src.realizer.main.get_data_by_slice_id") as mock_db: + mock_db.return_value = [ + {"service_id": "svc-1", "slice_id": "slice-multi"}, + {"service_id": "svc-2", "slice_id": "slice-multi"} + ] + + with flask_app.app_context(): + with pytest.raises(ValueError, match="Slices with multiple associated services are not supported"): realizer(payload=payload, controller_type="RESTCONF", action="MONITOR") @@ -663,6 +660,57 @@ class TestTfsConnector: assert code == 200 assert network["network-id"] == "urn:tfs:network:admin" + def test_get_device_name(self): + from src.realizer.restconf.connectors.tfs_connector import tfs_connector + + conn = tfs_connector() + mock_resp = MagicMock() + mock_resp.json.return_value = {"name": "Router-A"} + + with patch("requests.get", return_value=mock_resp): + name = conn.get_device_name("10.0.0.1", "dev-uuid-1") + assert name == "Router-A" + + def test_get_service_path(self): + from src.realizer.restconf.connectors.tfs_connector import tfs_connector + + conn = tfs_connector() + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = { + "connections": [ + { + "path_hops_endpoint_ids": [ + {"device_id": {"device_uuid": {"uuid": "dev-1"}}}, + {"device_id": {"device_uuid": {"uuid": "dev-1"}}}, + {"device_id": {"device_uuid": {"uuid": "dev-2"}}}, + {"device_id": {"device_uuid": {"uuid": "dev-2"}}}, + {"device_id": {"device_uuid": {"uuid": "dev-3"}}} + ] + } + ] + } + + with patch("requests.get", return_value=mock_resp), \ + patch.object(conn, "get_device_name", side_effect=lambda ip, uuid: f"name-{uuid}"): + path, code = conn.get_service_path("10.0.0.1", "svc-123") + assert code == 200 + assert path == ["name-dev-1", "name-dev-2", "name-dev-3"] + + # Failure response from server + mock_fail_resp = MagicMock() + mock_fail_resp.status_code = 404 + with patch("requests.get", return_value=mock_fail_resp): + path, code = conn.get_service_path("10.0.0.1", "svc-123") + assert code == 404 + assert path == [] + + # Exception handling + with patch("requests.get", side_effect=Exception("Connection error")): + path, code = conn.get_service_path("10.0.0.1", "svc-123") + assert code == 500 + assert path == [] + def test_extract_stream_uri(self): from src.realizer.restconf.connectors.tfs_connector import tfs_connector -- GitLab