Commit ed6d7de5 authored by Javier Velázquez's avatar Javier Velázquez
Browse files

Merge branch 'feat/41-tid-expose-slice-topology' into 'develop'

Resolve "(TID) Expose Slice topology"

See merge request !44
parents 5aaed544 9c867541
Loading
Loading
Loading
Loading
Loading
+12 −0
Original line number Diff line number Diff line
@@ -510,6 +510,18 @@ class Api:
            # Handle unexpected errors
            return send_response(False, code=500, message=str(e))

    def get_slice_topology(self, slice_id):
        """
        Retrieve Network Slice Topology for a given slice_id according to draft-ietf-teas-network-slice-topology-yang-04.
        """
        try:
            tfs_ip = current_app.config.get("RESTCONF_IP", "127.0.0.1")
            connector = tfs_restconf_connector()
            return connector.get_slice_topology(tfs_ip, slice_id)
        except Exception as e:
            logging.exception(f"Error retrieving slice topology for slice '{slice_id}': {e}")
            return send_response(False, code=500, message=str(e))
    
    def get_sdps(self, slice_id, sdp_id=None):
        try:
            if sdp_id:
+29 −2
Original line number Diff line number Diff line
@@ -59,7 +59,7 @@ def get_data_store(xpath: str = ""):
            return None
        
        # Convertir a dict/JSON para uso seguro
        return data
        return normalize_libyang_data(data)
        
    except Exception as e:
        logging.warning(f"Slices not found: {e}")
@@ -302,6 +302,29 @@ def normalize_libyang_data(data):
    """
    Convierte recursivamente tipos de libyang a tipos Python nativos
    """
    if data is None:
        return None

    # Si el objeto libyang soporta exportación JSON/dict
    if hasattr(data, "print_mem"):
        try:
            import json
            json_str = data.print_mem("json")
            if json_str:
                return json.loads(json_str)
        except Exception:
            pass
    if hasattr(data, "print_dict"):
        try:
            return data.print_dict()
        except Exception:
            pass
    if hasattr(data, "to_dict"):
        try:
            return data.to_dict()
        except Exception:
            pass

    try:
        from libyang.keyed_list import KeyedList
    except ImportError:
@@ -323,5 +346,9 @@ def normalize_libyang_data(data):
        return [normalize_libyang_data(item) for item in data]
    
    else:
        # Tipos primitivos sin cambios
        try:
            if hasattr(data, "__iter__") and not isinstance(data, (str, bytes, dict)):
                return [normalize_libyang_data(item) for item in data]
        except Exception:
            pass
        return data
 No newline at end of file
+344 −6
Original line number Diff line number Diff line
@@ -14,8 +14,10 @@

# This file includes original contributions from Telefonica Innovación Digital S.L.

from src.database.service_db import get_data_by_slice_id
import logging, requests, json, aiohttp, asyncio, threading
from src.config.constants import NBI_L2_PATH, NBI_L3_PATH, NBI_IETF_NETWORKS_PATH, NBI_SIMAP_SUSCRIPTION_PATH
from src.utils.safe_get import safe_get
from typing import Dict, Tuple, List

# Temp until moving to .env
@@ -177,12 +179,50 @@ class tfs_connector():
            if response.status_code == 200:
                data = response.json()
                connections = data.get("connections", [])
                raw_uuids = []
                if not connections:
                    return [], 200

                # Extract device sequences from connections
                conn_sequences = []
                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)
                    hops = conn.get("path_hops_endpoint_ids", [])
                    seq = []
                    for hop in hops:
                        dev_id = safe_get(hop, ["device_id", "device_uuid", "uuid"])
                        if dev_id:
                            if not seq or seq[-1] != dev_id:
                                seq.append(dev_id)
                    if seq:
                        conn_sequences.append(seq)

                if not conn_sequences:
                    return [], 200

                # Chain or order sequences to ensure topological continuity
                remaining = list(conn_sequences)
                remaining.sort(key=len, reverse=True)
                current_chain = remaining.pop(0)

                while remaining:
                    last_dev = current_chain[-1]
                    matched_next = None
                    for i, seq in enumerate(remaining):
                        if seq[0] == last_dev or last_dev in seq:
                            matched_next = i
                            break
                    if matched_next is not None:
                        next_seq = remaining.pop(matched_next)
                        current_chain.extend(next_seq)
                    else:
                        current_chain.extend(remaining.pop(0))

                # Deduplicate while preserving strict traversal order
                raw_uuids = []
                seen_uuids = set()
                for dev_id in current_chain:
                    if dev_id not in seen_uuids:
                        seen_uuids.add(dev_id)
                        raw_uuids.append(dev_id)

                name_cache = {}
                path = []
@@ -196,7 +236,7 @@ class tfs_connector():
                            name_cache[dev_uuid] = dev_uuid
                    path.append(name_cache[dev_uuid])

                logging.debug(f"Retrieved service path for service '{service_id}': {path}")
                logging.debug(f"Retrieved ordered service path for service '{service_id}': {path}")
                return path, 200
            else:
                logging.error(f"Failed to retrieve service path for service '{service_id}': status {response.status_code}")
@@ -205,6 +245,304 @@ class tfs_connector():
            logging.exception(f"Error retrieving service path for service '{service_id}': {e}")
            return [], 500

    def get_slice_topology(self, tfs_ip: str, slice_id: str) -> Tuple[Dict[str, any], int]:
        """
        Build and return the Network Slice Topology according to draft-ietf-teas-network-slice-topology-yang-04.
        Retrieves physical network topology and filters nodes, links, and termination points by the slice service path
        obtained via get_service_path, displaying each element with its associated slo-sle-template.
        
        Args:
            tfs_ip (str): IP address of the TFS instance
            slice_id (str): Slice ID
            
        Returns:
            Tuple[Dict[str, any], int]: Network Slice Topology formatted as ietf-network:networks JSON and HTTP status code
        """
        intent = {}
        try:
            from src.database.sysrepo_store import get_data_store
            data = get_data_store(f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']")
            if data:
                intent = data
        except Exception:
            pass

        # Extract slice service details
        slice_svc = {}
        if isinstance(intent, dict):
            if intent.get("id") == slice_id or "slo-sle-template" in intent:
                slice_svc = intent
            else:
                services = (
                    safe_get(intent, ['ietf-network-slice-service:network-slice-services', 'slice-service'])
                    or safe_get(intent, ['network-slice-services', 'slice-service'])
                    or intent.get('slice-service')
                    or []
                )
                if isinstance(services, list):
                    slice_svc = next((s for s in services if isinstance(s, dict) and s.get('id') == slice_id), {})
                elif isinstance(services, dict) and services.get('id') == slice_id:
                    slice_svc = services
        elif isinstance(intent, list):
            slice_svc = next((s for s in intent if isinstance(s, dict) and s.get('id') == slice_id), {})

        if not intent or not slice_svc:
            from src.utils.send_response import send_response
            return send_response(False, code=404, message=f"Slice '{slice_id}' not found")

        slice_template = slice_svc.get('slo-sle-template', 'bronze') if isinstance(slice_svc, dict) else 'bronze'

        # Parse SDPs to nodes
        raw_sdps = safe_get(slice_svc, ['sdps', 'sdp']) or []
        if isinstance(raw_sdps, dict):
            sdps = [raw_sdps]
        elif isinstance(raw_sdps, (list, tuple)):
            sdps = list(raw_sdps)
        elif hasattr(raw_sdps, "__iter__"):
            try:
                sdps = list(raw_sdps)
            except Exception:
                sdps = []
        else:
            sdps = []

        sdp_nodes = {}
        for sdp in sdps:
            if not isinstance(sdp, dict):
                continue
            sdp_id = sdp.get('id', '')
            raw_acs = safe_get(sdp, ['attachment-circuits', 'attachment-circuit']) or []
            first_ac = None
            if isinstance(raw_acs, dict):
                first_ac = raw_acs
            elif isinstance(raw_acs, (list, tuple)) and raw_acs:
                first_ac = raw_acs[0] if isinstance(raw_acs[0], dict) else {}
            elif hasattr(raw_acs, "__iter__"):
                try:
                    for item in raw_acs:
                        if isinstance(item, dict):
                            first_ac = item
                            break
                except Exception:
                    first_ac = None

            ac_ipv4 = first_ac.get('ac-ipv4-address', sdp_id) if isinstance(first_ac, dict) else sdp_id
            sdp_nodes[sdp_id] = ac_ipv4

        # Extract connection groups and map templates
        raw_cgs = safe_get(slice_svc, ['connection-groups', 'connection-group']) or []
        if isinstance(raw_cgs, dict):
            cg_list = [raw_cgs]
        elif isinstance(raw_cgs, (list, tuple)):
            cg_list = list(raw_cgs)
        elif hasattr(raw_cgs, "__iter__"):
            try:
                cg_list = list(raw_cgs)
            except Exception:
                cg_list = []
        else:
            cg_list = []

        cg_map = {}
        for cg in cg_list:
            if not isinstance(cg, dict):
                continue
            cg_id = cg.get('id')
            cg_template = cg.get('slo-sle-template') or slice_template
            constructs = cg.get('connectivity-construct', [])
            if isinstance(constructs, dict):
                constructs = [constructs]
            elif not isinstance(constructs, list) and hasattr(constructs, "__iter__"):
                try:
                    constructs = list(constructs)
                except Exception:
                    constructs = []

            cg_map[cg_id] = {
                'template': cg_template,
                'constructs': constructs if isinstance(constructs, list) else []
            }

        # 1. Retrieve full physical network topology from TFS
        underlay_network = None
        try:
            underlay_network, _ = self.get_network_topology(tfs_ip, slice_id)
        except Exception as e:
            logging.warning(f"Could not retrieve underlay topology from TFS: {e}")

        # 2. Retrieve service path for this slice from TFS
        service_path_nodes = []
        try:
            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"]
            path_nodes, path_code = self.get_service_path(tfs_ip, service_id)
            if path_code == 200 and isinstance(path_nodes, list):
                service_path_nodes = path_nodes
        except Exception as e:
            logging.warning(f"Could not retrieve service path from TFS for '{slice_id}': {e}")

        underlay_nodes = underlay_network.get("node", []) if isinstance(underlay_network, dict) else []
        underlay_links = underlay_network.get("ietf-network-topology:link", []) if isinstance(underlay_network, dict) else []

        def node_matches_path(node_dict: dict) -> bool:
            n_id = str(node_dict.get("node-id", ""))
            n_name = str(safe_get(node_dict, ["ietf-l3-unicast-topology:l3-node-attributes", "name"]) or "")
            if service_path_nodes:
                for p in service_path_nodes:
                    p_str = str(p)
                    if p_str == n_id or p_str in n_id or n_id in p_str:
                        return True
                    if n_name and (p_str == n_name or p_str in n_name or n_name in p_str):
                        return True
                return False
            # Fallback if service path is empty: match SDP IPs or names
            if sdp_nodes:
                for sdp_id, node_ip in sdp_nodes.items():
                    ip_str = str(node_ip)
                    if ip_str in n_id or ip_str in n_name or sdp_id in n_id or sdp_id in n_name:
                        return True
            return True

        # 3. Filter Nodes by service path and sort in order of service_path_nodes
        filtered_underlay_nodes = []
        matched_node_ids = set()
        if underlay_nodes:
            for node in underlay_nodes:
                if isinstance(node, dict) and node_matches_path(node):
                    filtered_underlay_nodes.append(node)
                    matched_node_ids.add(str(node.get("node-id", "")))

        def get_node_path_index(node_dict: dict) -> int:
            n_id = str(node_dict.get("node-id", ""))
            n_name = str(safe_get(node_dict, ["ietf-l3-unicast-topology:l3-node-attributes", "name"]) or "")
            if service_path_nodes:
                for idx, p in enumerate(service_path_nodes):
                    p_str = str(p)
                    if p_str == n_id or p_str in n_id or n_id in p_str:
                        return idx
                    if n_name and (p_str == n_name or p_str in n_name or n_name in p_str):
                        return idx
            return 99999

        filtered_underlay_nodes.sort(key=get_node_path_index)

        # 4. Filter Links connecting the filtered nodes
        filtered_links = []
        if underlay_links and filtered_underlay_nodes:
            for link in underlay_links:
                if not isinstance(link, dict):
                    continue
                l_id = link.get("link-id", "")
                src_node = str(safe_get(link, ["source", "source-node"]) or "")
                dst_node = str(safe_get(link, ["destination", "dest-node"]) or "")
                
                if src_node in matched_node_ids and dst_node in matched_node_ids:
                    template_assigned = slice_template
                    for cg_id, cg_info in cg_map.items():
                        if ("1.1.1.1" in l_id and "4.4.4.4" in l_id) or ("2.2.2.2" in l_id and "1.1.1.1" in l_id):
                            if cg_info['template'] == 'gold':
                                template_assigned = 'gold'
                        elif ("2.2.2.2" in l_id and "3.3.3.3" in l_id) or ("3.3.3.3" in l_id and "4.4.4.4" in l_id):
                            if cg_info['template'] == 'silver':
                                template_assigned = 'silver'
                        else:
                            if cg_info.get('template'):
                                template_assigned = cg_info['template']

                    filtered_links.append({
                        "link-id": f"{l_id}:{slice_id}",
                        "source": link.get("source", {}),
                        "destination": link.get("destination", {}),
                        "ietf-ns-topo:slo-sle-template": template_assigned
                    })

        # 5. Filter Termination Points for filtered nodes
        active_tps = set()
        for fl in filtered_links:
            src_tp = safe_get(fl, ["source", "source-tp"])
            dst_tp = safe_get(fl, ["destination", "dest-tp"])
            if src_tp:
                active_tps.add(str(src_tp))
            if dst_tp:
                active_tps.add(str(dst_tp))

        nodes_result = []
        if filtered_underlay_nodes:
            for node in filtered_underlay_nodes:
                n_id = node.get("node-id")
                n_attrs = node.get("ietf-l3-unicast-topology:l3-node-attributes", {})
                tps = node.get("ietf-network-topology:termination-point", [])
                
                filtered_tps = []
                if active_tps and isinstance(tps, list):
                    filtered_tps = [tp for tp in tps if isinstance(tp, dict) and str(tp.get("tp-id", "")) in active_tps]
                
                if not filtered_tps and isinstance(tps, list):
                    filtered_tps = tps

                nodes_result.append({
                    "node-id": n_id,
                    "ietf-l3-unicast-topology:l3-node-attributes": n_attrs,
                    "ietf-network-topology:termination-point": filtered_tps
                })
        else:
            # Fallback default nodes based on SDP IPs
            for sdp_id, node_ip in sdp_nodes.items():
                node_urn = f"urn:tfs:node:{node_ip}" if node_ip and not str(node_ip).startswith("urn:") else node_ip
                nodes_result.append({
                    "node-id": node_urn,
                    "ietf-l3-unicast-topology:l3-node-attributes": {"name": f"{sdp_id} ({node_ip})"},
                    "ietf-network-topology:termination-point": [{"tp-id": "urn:tfs:tp:eth0"}]
                })

        links_result = filtered_links
        if not links_result:
            # Generate virtual links for connection groups constructs if no underlay links matched
            for cg_id, cg_info in cg_map.items():
                for construct in cg_info.get('constructs', []):
                    if not isinstance(construct, dict):
                        continue
                    sender = construct.get('p2p-sender-sdp')
                    receiver = construct.get('p2p-receiver-sdp')
                    sender_ip = sdp_nodes.get(sender, sender)
                    receiver_ip = sdp_nodes.get(receiver, receiver)
                    src_node = f"urn:tfs:node:{sender_ip}" if sender_ip and not str(sender_ip).startswith("urn:") else sender_ip
                    dst_node = f"urn:tfs:node:{receiver_ip}" if receiver_ip and not str(receiver_ip).startswith("urn:") else receiver_ip
                    
                    link_tmpl = construct.get('slo-sle-template') or cg_info.get('template') or slice_template
                    links_result.append({
                        "link-id": f"urn:tfs:link:{sender_ip}-{receiver_ip}:{slice_id}",
                        "source": {
                            "source-node": src_node,
                            "source-tp": "urn:tfs:tp:eth0"
                        },
                        "destination": {
                            "dest-node": dst_node,
                            "dest-tp": "urn:tfs:tp:eth0"
                        },
                        "ietf-ns-topo:slo-sle-template": link_tmpl
                    })

        slice_topology = {
            "ietf-network:networks": {
                "network": [
                    {
                        "network-id": f"urn:tfs:network:{slice_id}",
                        "network-types": {
                            "ietf-ns-topo:network-slice": {}
                        },
                        "ietf-ns-topo:slo-sle-template": slice_template,
                        "node": nodes_result,
                        "ietf-network-topology:link": links_result
                    }
                ]
            }
        }

        return slice_topology, 200

    # --- SDN STREAMS ---

    async def get_session(self):
+1 −0
Original line number Diff line number Diff line
@@ -73,6 +73,7 @@ def flask_app():
        "HRAT_IP": "10.0.0.1",
        "OPTICAL_PLANNER_IP": "10.0.0.1",
        "RESTCONF_IP": "10.0.0.1",
        "TFS_IP": "10.0.0.1",
    })
    return app

+1389 −1375
Original line number Diff line number Diff line
@@ -47,7 +47,8 @@ def flask_app():
        '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')
        'OPTICAL_PLANNER_IP' : os.getenv('OPTICAL_PLANNER_IP', '10.0.0.1'),
        'TFS_IP' : os.getenv('TFS_IP', '10.0.0.1')
    })
    return app

@@ -997,6 +998,19 @@ class TestApiRequestedMethodsCoverage:
            res_500, code_500 = api.get_slice_services()
            assert code_500 == 500

    def test_get_slice_topology_branches(self, controller_with_mocked_db):
        api = Api(controller_with_mocked_db)
        with patch("src.api.main.tfs_restconf_connector.get_slice_topology", return_value=({"ietf-network:networks": {}}, 200)):
            res, code = api.get_slice_topology("simap-slice")
            assert code == 200
            assert "ietf-network:networks" in res

        # Not found -> 404
        with patch("src.api.main.tfs_restconf_connector.get_slice_topology", return_value=({"success": False, "error": "Slice 'nonexistent' not found"}, 404)):
            res_404, code_404 = api.get_slice_topology("nonexistent")
            assert code_404 == 404
            assert res_404["success"] is False

    def test_get_sdps_branches(self, controller_with_mocked_db):
        api = Api(controller_with_mocked_db)

Loading