From 14a51afbfa3c50a0c814da36d694412a4812a3f6 Mon Sep 17 00:00:00 2001 From: velazquez Date: Thu, 26 Feb 2026 12:39:32 +0100 Subject: [PATCH 1/2] New Flag DATAPLANE_SUPPORT New FRR connector New tests --- src/tests/requests/frr_request.json | 296 +++++++++++++++++++ src/tests/requests/ietf_template_timing.json | 166 +++++++++++ 2 files changed, 462 insertions(+) create mode 100644 src/tests/requests/frr_request.json create mode 100644 src/tests/requests/ietf_template_timing.json diff --git a/src/tests/requests/frr_request.json b/src/tests/requests/frr_request.json new file mode 100644 index 0000000..a288d72 --- /dev/null +++ b/src/tests/requests/frr_request.json @@ -0,0 +1,296 @@ +{ + "ietf-network-slice-service:network-slice-services": { + "slo-sle-templates": { + "slo-sle-template": [ + { + "id": "gold", + "description": "", + "slo-policy": { + "metric-bound": [ + { + "metric-type": "two-way-bandwidth", + "metric-unit": "Mbps", + "bound": 100 + }, + { + "metric-type": "two-way-delay-maximum", + "metric-unit": "milliseconds", + "bound": 5 + } + ] + }, + "sle-policy": { + "security": "", + "isolation": "", + "path-constraints": { + "service-functions": {}, + "diversity": { + "diversity-type": "" + } + } + } + }, + { + "id": "silver", + "description": "", + "slo-policy": { + "metric-bound": [ + { + "metric-type": "two-way-bandwidth", + "metric-unit": "Mbps", + "bound": 10 + }, + { + "metric-type": "two-way-delay-maximum", + "metric-unit": "milliseconds", + "bound": 20 + } + ] + }, + "sle-policy": { + "security": "", + "isolation": "", + "path-constraints": { + "service-functions": {}, + "diversity": { + "diversity-type": "" + } + } + } + }, + { + "id": "bronze", + "description": "", + "slo-policy": { + "metric-bound": [ + { + "metric-type": "two-way-bandwidth", + "metric-unit": "Mbps", + "bound": 1 + }, + { + "metric-type": "two-way-delay-maximum", + "metric-unit": "milliseconds", + "bound": 20 + } + ] + }, + "sle-policy": { + "security": "", + "isolation": "", + "path-constraints": { + "service-functions": {}, + "diversity": { + "diversity-type": "" + } + } + } + } + ] + }, + "slice-service": [ + { + "id": "unity-transport-slice", + "description": "Transport network slice service comprising three paths with three different slo templates. SDPs are matched by DSCP of incoming traffic", + "service-tags": { + "tag-type": [ + { + "tag-type": "service", + "tag-type-value": [ + "L3" + ] + } + ] + }, + "slo-sle-template": "bronze", + "status": {}, + "sdps": { + "sdp": [ + { + "id": "SDP1", + "geo-location": {}, + "node-id": "CU", + "sdp-ip-address": "", + "tp-ref": "", + "service-match-criteria": { + "match-criterion": [ + { + "index": 1, + "match-type": [ + { + "type": "dscp", + "dscp": [51] + } + ], + "target-connection-group-id": "CU_DU_1" + }, + { + "index": 2, + "match-type": [ + { + "type": "dscp", + "dscp": [52] + } + ], + "target-connection-group-id": "CU_DU_2" + }, + { + "index": 3, + "match-type": [ + { + "type": "any" + } + ], + "target-connection-group-id": "CU_DU_3" + } + ] + }, + "incoming-qos-policy": {}, + "outgoing-qos-policy": {}, + "sdp-peering": { + "peer-sap-id": "", + "protocols": {} + }, + "ac-svc-ref": [], + "attachment-circuits": { + "attachment-circuit": [ + { + "id": "acSDP1", + "ac-node-id": "PE-A", + "ac-tp-id": "GigabitEthernet5/0/0/0", + "ac-ipv4-address": "4.4.4.4", + "ac-ipv4-prefix-length": 24, + "status": {} + } + ] + }, + "status": {}, + "sdp-monitoring": {} + }, + { + "id": "SDP2", + "geo-location": {}, + "node-id": "DU", + "sdp-ip-address": "", + "tp-ref": "", + "service-match-criteria": { + "match-criterion": [ + { + "index": 1, + "match-type": [ + { + "type": "dscp", + "dscp": [51] + } + ], + "target-connection-group-id": "CU_DU_1" + }, + { + "index": 2, + "match-type": [ + { + "type": "dscp", + "dscp": [52] + } + ], + "target-connection-group-id": "CU_DU_2" + }, + { + "index": 3, + "match-type": [ + { + "type": "any" + } + ], + "target-connection-group-id": "CU_DU_3" + } + ] + }, + "incoming-qos-policy": {}, + "outgoing-qos-policy": {}, + "sdp-peering": { + "peer-sap-id": "", + "protocols": {} + }, + "ac-svc-ref": [], + "attachment-circuits": { + "attachment-circuit": [ + { + "id": "acSDP2", + "ac-node-id": "PE-B", + "ac-tp-id": "GigabitEthernet5/0/0/0", + "ac-ipv4-address": "10.60.60.105", + "ac-ipv4-prefix-length": 24, + "status": {} + } + ] + }, + "status": {}, + "sdp-monitoring": {} + } + ] + }, + "connection-groups": { + "connection-group": [ + { + "id": "CU_DU_1", + "connectivity-type": "point-to-point", + "slo-sle-template": "silver", + "connectivity-construct": [ + { + "id": "1", + "p2p-sender-sdp": "SDP1", + "p2p-receiver-sdp": "SDP2" + }, + { + "id": "2", + "p2p-sender-sdp": "SDP2", + "p2p-receiver-sdp": "SDP1" + + } + ], + "status": {} + }, + { + "id": "CU_DU_2", + "connectivity-type": "point-to-point", + "slo-sle-template": "gold", + "connectivity-construct": [ + { + "id": "3", + "p2p-sender-sdp": "SDP1", + "p2p-receiver-sdp": "SDP2" + }, + { + "id": "4", + "p2p-sender-sdp": "SDP2", + "p2p-receiver-sdp": "SDP1" + + } + ], + "status": {} + }, + { + "id": "CU_DU_3", + "connectivity-type": "point-to-point", + "connectivity-construct": [ + { + "id": "5", + "p2p-sender-sdp": "SDP1", + "p2p-receiver-sdp": "SDP2" + }, + { + "id": "6", + "p2p-sender-sdp": "SDP2", + "p2p-receiver-sdp": "SDP1" + + } + ], + "status": {} + } + ] + } + } + ] + } +} diff --git a/src/tests/requests/ietf_template_timing.json b/src/tests/requests/ietf_template_timing.json new file mode 100644 index 0000000..dc25c4f --- /dev/null +++ b/src/tests/requests/ietf_template_timing.json @@ -0,0 +1,166 @@ +{ + "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": 60 + }, + { + "metric-type": "one-way-delay-maximum", + "metric-unit": "milliseconds", + "bound": 4 + } + ] + }, + "sle-policy": { + "security": "", + "isolation": "", + "path-constraints": { + "service-functions": "", + "diversity": { + "diversity": { + "diversity-type": "" + } + } + } + } + } + ] + }, + "slice-service": [ + { + "id": "slice-service-e092d086-f744-4f48-89e1-7f004fc47446", + "description": "example 5G Slice mapping", + "service-tags": { + "tag-type": { + "tag-type": "", + "value": "" + } + }, + "slo-sle-policy": { + "slo-sle-template": "A" + }, + "status": {}, + "sdps": { + "sdp": [ + { + "id": "01", + "geo-location": "", + "node-id": "DU3", + "sdp-ip-address": "100.1.2.2", + "tp-ref": "", + "service-match-criteria": { + "match-criterion": [ + { + "index": 1, + "match-type": [ + { + "type": "vlan", + "vlan": [300] + } + ], + "target-connection-group-id": "DU3_CU-UP2" + } + ] + }, + "incoming-qos-policy": "", + "outgoing-qos-policy": "", + "sdp-peering": { + "peer-sap-id": "", + "protocols": "" + }, + "ac-svc-ref": [], + "attachment-circuits": { + "attachment-circuit": [ + { + "id": "100", + "ac-ipv4-address": "100.1.2.1", + "ac-ipv4-prefix-length": 0, + "sdp-peering": { + "peer-sap-id": "100.1.2.254" + }, + "status": {} + } + ] + }, + "status": {}, + "sdp-monitoring": "" + }, + { + "id": "02", + "geo-location": "", + "node-id": "CU-UP2", + "sdp-ip-address": "1.1.3.2", + "tp-ref": "", + "service-match-criteria": { + "match-criterion": [ + { + "index": 1, + "match-type": [ + { + "type": "vlan", + "vlan": [300] + } + ], + "target-connection-group-id": "DU3_CU-UP2" + } + ] + }, + "incoming-qos-policy": "", + "outgoing-qos-policy": "", + "sdp-peering": { + "peer-sap-id": "", + "protocols": "" + }, + "ac-svc-ref": [], + "attachment-circuits": { + "attachment-circuit": [ + { + "id": "200", + "ac-ipv4-address": "1.1.3.1", + "ac-ipv4-prefix-length": 0, + "sdp-peering": { + "peer-sap-id": "1.1.3.254" + }, + "status": {} + } + ] + }, + "status": {}, + "sdp-monitoring": "" + } + ] + }, + "connection-groups": { + "connection-group": [ + { + "id": "DU3_CU-UP2", + "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 -- GitLab From be2f1061df9aa44835bf819242c98a0c826fb874 Mon Sep 17 00:00:00 2001 From: velazquez Date: Thu, 26 Feb 2026 12:39:49 +0100 Subject: [PATCH 2/2] New Flag DATAPLANE_SUPPORT New FRR connector New tests --- src/config/.env.example | 2 + src/config/config.py | 1 + .../restconf/connectors/frr_connector.py | 89 +++++++++++++++++++ src/realizer/restconf/restconf_connect.py | 48 ++++++++++ src/utils/slice_manager.py | 65 ++++++++++++++ 5 files changed, 205 insertions(+) create mode 100644 src/realizer/restconf/connectors/frr_connector.py create mode 100644 src/utils/slice_manager.py diff --git a/src/config/.env.example b/src/config/.env.example index 31b1f1d..c322fa2 100644 --- a/src/config/.env.example +++ b/src/config/.env.example @@ -69,6 +69,8 @@ TFS_E2E_IP=127.0.0.1 RESTCONF_IP=127.0.0.1 # Options: TFS or IXIA SDN_CONTROLLER_TYPE=TFS +# Options: FRR, CISCO +DATAPLANE_SUPPORT=FRR # ------------------------- # WebUI diff --git a/src/config/config.py b/src/config/config.py index 4955a51..145418d 100644 --- a/src/config/config.py +++ b/src/config/config.py @@ -67,5 +67,6 @@ def create_config(app: Flask): # Restconf Controller app.config["RESTCONF_IP"] = os.getenv("RESTCONF_IP", "127.0.0.1") app.config["SDN_CONTROLLER_TYPE"] = os.getenv("SDN_CONTROLLER_TYPE", "TFS") + app.config["DATAPLANE_SUPPORT"] = os.getenv("DATAPLANE_SUPPORT", "FRR") return app diff --git a/src/realizer/restconf/connectors/frr_connector.py b/src/realizer/restconf/connectors/frr_connector.py new file mode 100644 index 0000000..e3cb79f --- /dev/null +++ b/src/realizer/restconf/connectors/frr_connector.py @@ -0,0 +1,89 @@ +# frr_connector.py + +import logging +from netmiko import ConnectHandler + +class frr_connector(): + """Class to interact with FRR devices via SSH using Netmiko.""" + + def __init__(self, address): + self.address = address + + def execute_commands(self, commands): + try: + device = { + 'device_type': 'linux', + 'host': self.address, + 'username': 'root', + 'password': 'root', + } + connection = ConnectHandler(**device) + output = connection.send_config_set(commands) + logging.debug(output) + connection.disconnect() + + except Exception as e: + logging.error(f"Failed to execute commands on {self.address}: {str(e)}") + raise + + def setup_slice(self, config: dict, assignments: dict[int, int]) -> list[str]: + """ + Build PBR commands dynamically based on active slot assignments. + + Args: + config: Device config (interfaces, addresses, etc.) + assignments: {slot_number: dscp_value} for currently active slices. + e.g. {1: 52, 2: 51} + Returns: + List of FRR/vtysh commands. + """ + commands = ["vtysh", "conf te"] + + # Nexthop groups - one per non-default slice + DEFAULT + for i, iface in enumerate(config['output_interfaces'], start=1): + commands += [ + f"nexthop-group SLICE-{i}", + f"nexthop {iface}", + "exit", + ] + + # PBR rules - one per active slot, ordered by seq + seq = 10 + for slot, dscp in sorted(assignments.items()): + commands += [ + f"pbr-map PBR-DSCP seq {seq}", + f"match mark {dscp}", + f"set nexthop-group SLICE-{slot}", + "exit", + ] + seq += 10 + + # Default catch-all rule always last + commands += [ + "pbr-map PBR-DSCP seq 100", + "match dst-ip 0.0.0.0/0", + "set nexthop-group DEFAULT", + "exit", + ] + + # Apply PBR policy to input interface + commands += [ + f"interface {config['input_interface']}", + f"ip address {config['input_address']}/24", + "pbr-policy PBR-DSCP", + "exit", + "end", + ] + + return commands + + def delete_all_slices(self) -> list[str]: + return [ + "vtysh", + "conf te", + "no pbr-map PBR-DSCP", + "no nexthop-group SLICE-1", + "no nexthop-group SLICE-2", + "no nexthop-group DEFAULT", + "end", + ] \ No newline at end of file diff --git a/src/realizer/restconf/restconf_connect.py b/src/realizer/restconf/restconf_connect.py index 6aa431d..2246790 100644 --- a/src/realizer/restconf/restconf_connect.py +++ b/src/realizer/restconf/restconf_connect.py @@ -1,10 +1,30 @@ +from src.utils.slice_manager import SliceManager from .connectors.tfs_connector import tfs_connector +from .connectors.frr_connector import frr_connector from src.utils.send_response import send_response from src.utils.safe_get import safe_get from flask import current_app from src.config.constants import NBI_L2_PATH, NBI_L3_PATH import logging + +FRR_DEVICES = [ + { + "management_address": "10.60.125.20", + "input_address": "192.168.24.2", + "input_interface": "eth2", + "output_interfaces": ["192.168.23.3", "192.168.25.5", "192.168.12.1"], + }, + { + "management_address": "10.60.125.50", + "input_address": "192.168.56.5", + "input_interface": "eth4", + "output_interfaces": ["192.168.35.3", "192.168.25.2", "192.168.15.1"], + }, +] + +_slice_manager = SliceManager() + def restconf_connect(requests, restconf_ip): """ Connect to controller and upload services. @@ -24,6 +44,34 @@ def restconf_connect(requests, restconf_ip): elif key == "ietf-l3vpn-svc:l3vpn-svc": path = NBI_L3_PATH + dscp = safe_get(intent, [ + "sites", 0, "site-network-accesses", [0], + "service", "qos", "qos-classification-policy", + "rule", 0, "match-flow", "dscp" + ]) + + if dscp is not None and current_app.config["DATAPLANE_SUPPORT"] == "FRR": + if _slice_manager.is_full(): + return send_response( + False, code=429, + message=f"No available slices. Current assignments: {_slice_manager.get_active_assignments()}" + ) + + slot = _slice_manager.assign_slot(dscp) + if slot is None: + return send_response(False, code=429, message=f"Could not assign slot for DSCP {dscp}") + + logging.info(f"Assigned DSCP {dscp} to SLICE-{slot}") + + assignments = _slice_manager.get_active_assignments() + for device_config in FRR_DEVICES: + connector = frr_connector(device_config["management_address"]) + commands = connector.setup_slice(device_config, assignments) + try: + connector.execute_commands(commands) + except Exception as e: + return send_response(False, code=500, + message=f"FRR config failed on {device_config['management_address']}: {str(e)}") else: return send_response(False, code=400, message=f"Unsupported service type: {key}") diff --git a/src/utils/slice_manager.py b/src/utils/slice_manager.py new file mode 100644 index 0000000..17e01bb --- /dev/null +++ b/src/utils/slice_manager.py @@ -0,0 +1,65 @@ +# 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. + +class SliceManager: + """ + Manages available PBR slices and their DSCP assignments. + Slices are 1-indexed. Slice with the highest index is always the default. + """ + TOTAL_SLICES = 3 + DEFAULT_SLICE = TOTAL_SLICES # SLICE-3 is always default + + def __init__(self): + # slot -> dscp (None means free) + self._slots: dict[int, int | None] = { + i: None for i in range(1, self.TOTAL_SLICES) # {1: None, 2: None} + } + + def assign_slot(self, dscp: int) -> int | None: + """ + Try to assign a free slot to a DSCP value. + Returns the assigned slot number, or None if no slots are available. + """ + # Check if DSCP already assigned + for slot, assigned_dscp in self._slots.items(): + if assigned_dscp == dscp: + return slot # idempotent + + # Find first free slot + for slot, assigned_dscp in self._slots.items(): + if assigned_dscp is None: + self._slots[slot] = dscp + return slot + + return None # No free slots + + def release_slot(self, dscp: int) -> bool: + """ + Release the slot assigned to a DSCP value. + Returns True if released, False if not found. + """ + for slot, assigned_dscp in self._slots.items(): + if assigned_dscp == dscp: + self._slots[slot] = None + return True + return False + + def get_active_assignments(self) -> dict[int, int]: + """Returns only the occupied slots {slot: dscp}.""" + return {slot: dscp for slot, dscp in self._slots.items() if dscp is not None} + + def is_full(self) -> bool: + return all(dscp is not None for dscp in self._slots.values()) \ No newline at end of file -- GitLab