diff --git a/.gitignore b/.gitignore
index 7f029220c43d52816cbbb2050036a237b1f18dca..dc5f1b84081030f2a0c9cb3bba4fc732452ac813 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,3 +22,7 @@ service.db
telemetry_client.db
alert.db
.python-version
+.agents/
+.coverage
+.ruff_cache/
+.pytest_cache/
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index f8dcd65776aaade4fce6e6ce84b841441cfdee5a..3f646a5712a4cf5c96461513539ffd2f82a2fe93 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -37,7 +37,7 @@ build nsc:
# Apply unit test to the component
unit_test nsc:
- timeout: 15m
+ timeout: 30m
variables:
IMAGE_NAME: 'nsc' # name of the microservice
IMAGE_TAG: 'test' # tag of the container image (production, development, etc)
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000000000000000000000000000000000000..a046f0f455dc80db3e7c16d1784ff36e80857788
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,23 @@
+[tool.ruff]
+line-length = 120
+target-version = "py312"
+
+[tool.ruff.lint]
+select = ["E", "F", "W", "I", "UP", "B", "C4", "SIM"]
+ignore = [
+ "E501", # Line too long (managed by formatter and docstrings)
+ "BLE001", # Catching Exception is intentional for HTTP 500 API boundary handlers
+ "SIM108", # Use ternary operator
+ "SIM102", # Nested if statements
+ "SIM105", # Use contextlib.suppress
+ "SIM117", # Multiple with statements
+]
+
+[tool.pytest.ini_options]
+testpaths = ["src/tests"]
+pythonpath = ["."]
+addopts = "-v --tb=short"
+
+[tool.pyright]
+extraPaths = ["."]
+
diff --git a/src/api/__init__.py b/src/api/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..f6a28b43f5407d176ade2bddef38f003849afa8f
--- /dev/null
+++ b/src/api/__init__.py
@@ -0,0 +1,35 @@
+# Copyright 2022-2026 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.
+
+"""API package for Network Slice Controller."""
+
+from __future__ import annotations
+
+from src.api.handlers.base_handler import BaseSliceHandler
+from src.api.handlers.e2e_handler import E2EHandler
+from src.api.handlers.ixia_handler import IxiaHandler
+from src.api.handlers.restconf_handler import RestconfHandler
+from src.api.handlers.tfs_handler import TfsHandler
+from src.api.main import Api
+
+__all__ = [
+ "Api",
+ "BaseSliceHandler",
+ "E2EHandler",
+ "IxiaHandler",
+ "RestconfHandler",
+ "TfsHandler",
+]
diff --git a/src/api/handlers/__init__.py b/src/api/handlers/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..9a556bd1ce148528cb700da601630ddc3caaf024
--- /dev/null
+++ b/src/api/handlers/__init__.py
@@ -0,0 +1,33 @@
+# Copyright 2022-2026 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.
+
+"""Handlers subpackage initialization."""
+
+from __future__ import annotations
+
+from src.api.handlers.base_handler import BaseSliceHandler
+from src.api.handlers.e2e_handler import E2EHandler
+from src.api.handlers.ixia_handler import IxiaHandler
+from src.api.handlers.restconf_handler import RestconfHandler
+from src.api.handlers.tfs_handler import TfsHandler
+
+__all__ = [
+ "BaseSliceHandler",
+ "E2EHandler",
+ "IxiaHandler",
+ "RestconfHandler",
+ "TfsHandler",
+]
diff --git a/src/api/handlers/base_handler.py b/src/api/handlers/base_handler.py
new file mode 100644
index 0000000000000000000000000000000000000000..2038756e519427486cc6ce3d1b1ecd6728caa6df
--- /dev/null
+++ b/src/api/handlers/base_handler.py
@@ -0,0 +1,207 @@
+# Copyright 2022-2026 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.
+
+"""Base handler for transport network slice flow operations."""
+
+from __future__ import annotations
+
+import logging
+import sys
+from typing import Any
+
+from flask import current_app
+
+from src.database.db import (
+ delete_all_data as _db_delete_all_data,
+)
+from src.database.db import (
+ delete_data as _db_delete_data,
+)
+from src.database.db import (
+ get_all_data as _db_get_all_data,
+)
+from src.database.db import (
+ get_data as _db_get_data,
+)
+from src.realizer.tfs.helpers.tfs_connector import tfs_connector as _real_tfs_connector
+from src.realizer.tfs.service_types.tfs_l2vpn import tfs_l2vpn_delete as _real_tfs_l2vpn_delete
+from src.utils.safe_get import safe_get
+from src.utils.send_response import send_response
+
+logger = logging.getLogger(__name__)
+
+
+def _dep(name: str, fallback: Any) -> Any:
+ """Resolve dependency from src.api.main or src.database.db if patched, otherwise fallback."""
+ main_mod = sys.modules.get("src.api.main")
+ if main_mod is not None and hasattr(main_mod, name):
+ val = getattr(main_mod, name)
+ if hasattr(val, "return_value") or hasattr(val, "_mock_return_value") or hasattr(val, "assert_called"):
+ return val
+ db_mod = sys.modules.get("src.database.db")
+ if db_mod is not None and hasattr(db_mod, name):
+ val = getattr(db_mod, name)
+ if hasattr(val, "return_value") or hasattr(val, "_mock_return_value") or hasattr(val, "assert_called"):
+ return val
+ if main_mod is not None and hasattr(main_mod, name):
+ return getattr(main_mod, name)
+ return fallback
+
+
+def _extract_slice_type(slice_dict: dict[str, Any]) -> str:
+ """Extract slice type tag from slice intent with fallback to L2."""
+ slice_type = safe_get(
+ slice_dict,
+ [
+ "intent",
+ "ietf-network-slice-service:network-slice-services",
+ "slice-service",
+ 0,
+ "service-tags",
+ "tag-type",
+ 0,
+ "tag-type-value",
+ 0,
+ ],
+ )
+ if not slice_type:
+ logger.warning("Slice type not found in slice intent. Defaulting to L2")
+ return "L2"
+ return str(slice_type)
+
+
+def _delete_slice_from_tfs(slice_dict: dict[str, Any], slice_id: str) -> None:
+ """Delete a slice in TeraFlowSDN via NBI connector."""
+ slice_type = _extract_slice_type(slice_dict)
+ connector = _dep("tfs_connector", _real_tfs_connector)()
+ connector.nbi_delete(current_app.config["TFS_IP"], slice_type, slice_id)
+
+
+class BaseSliceHandler:
+ """Base handler providing CRUD flow operations for transport network slices."""
+
+ def __init__(self, slice_service: Any) -> None:
+ """Initialize handler with underlying slice service (e.g. NSController).
+
+ Args:
+ slice_service: Service instance managing controller-specific business logic.
+ """
+ self.slice_service = slice_service
+
+ def add_flow(self, intent: dict[str, Any]) -> tuple[dict[str, Any], int]:
+ """Create a new transport network slice."""
+ try:
+ result = self.slice_service.nsc(intent)
+ if not result:
+ return send_response(False, code=404, message="No intents found")
+ if isinstance(result, tuple):
+ return result
+ logger.info("Slice created successfully")
+ return send_response(True, code=201, data=result)
+ except RuntimeError as exc:
+ return send_response(False, code=422, message=str(exc))
+ except Exception as exc:
+ return send_response(False, code=500, message=str(exc))
+
+ def get_flows(self, slice_id: str | None = None) -> tuple[dict[str, Any], int]:
+ """Retrieve transport network slice information."""
+ try:
+ get_all_data_fn = _dep("get_all_data", _db_get_all_data)
+ content = get_all_data_fn()
+ if slice_id:
+ for slice_item in content:
+ if slice_item.get("slice_id") == slice_id:
+ return send_response(True, code=200, data=slice_item)
+ raise ValueError("Transport network slice not found")
+
+ if not content:
+ return send_response(True, code=200, data=[])
+
+ filtered = [s for s in content if s.get("controller") == self.slice_service.controller_type]
+ return send_response(True, code=200, data=filtered)
+
+ except ValueError as exc:
+ return send_response(False, code=404, message=str(exc))
+ except Exception as exc:
+ return send_response(False, code=500, message=str(exc))
+
+
+ def modify_flow(self, slice_id: str, intent: dict[str, Any]) -> tuple[dict[str, Any], int]:
+ """Modify an existing transport network slice."""
+ try:
+ result = self.slice_service.nsc(intent, slice_id)
+ if isinstance(result, tuple):
+ return result
+ if not result:
+ return send_response(False, code=404, message="Slice not found")
+
+ logger.info("Slice %s modified successfully", slice_id)
+ return send_response(
+ True,
+ code=200,
+ message="Slice modified successfully",
+ data=result,
+ )
+ except ValueError as exc:
+ return send_response(False, code=404, message=str(exc))
+ except Exception as exc:
+ return send_response(False, code=500, message=str(exc))
+
+ def delete_flows(self, slice_id: str | None = None) -> tuple[dict[str, Any], int]:
+ """Delete transport network slice(s)."""
+ try:
+ if slice_id:
+ return self._delete_single_flow(slice_id)
+ return self._delete_all_flows()
+ except ValueError as exc:
+ return send_response(False, code=404, message=str(exc))
+ except Exception as exc:
+ return send_response(False, code=500, message=str(exc))
+
+ def _delete_single_flow(self, slice_id: str) -> tuple[dict[str, Any], int]:
+ """Delete a single slice by slice_id."""
+ get_data_fn = _dep("get_data", _db_get_data)
+ slice_item = get_data_fn(slice_id)
+ if not slice_item or slice_item.get("controller") != self.slice_service.controller_type:
+ raise ValueError("Transport network slice not found")
+
+ if not current_app.config["DUMMY_MODE"] and self.slice_service.controller_type == "TFS":
+ _delete_slice_from_tfs(slice_item, slice_id)
+
+ delete_data_fn = _dep("delete_data", _db_delete_data)
+ delete_data_fn(slice_id)
+ logger.info("Slice %s removed successfully", slice_id)
+ return {}, 204
+
+ def _delete_all_flows(self) -> tuple[dict[str, Any], int]:
+ """Delete all slices belonging to the current controller."""
+ get_all_data_fn = _dep("get_all_data", _db_get_all_data)
+ if not current_app.config["DUMMY_MODE"] and self.slice_service.controller_type == "TFS":
+ content = get_all_data_fn()
+ for slice_item in content:
+ if slice_item.get("controller") == self.slice_service.controller_type:
+ _delete_slice_from_tfs(slice_item, slice_item.get("slice_id", ""))
+ if current_app.config["TFS_L2VPN_SUPPORT"]:
+ if hasattr(self.slice_service, "tfs_l2vpn_delete"):
+ self.slice_service.tfs_l2vpn_delete()
+ else:
+ l2vpn_del = _dep("tfs_l2vpn_delete", _real_tfs_l2vpn_delete)
+ l2vpn_del()
+
+ delete_all_data_fn = _dep("delete_all_data", _db_delete_all_data)
+ delete_all_data_fn()
+ logger.info("All slices removed successfully")
+ return {}, 204
diff --git a/src/api/handlers/e2e_handler.py b/src/api/handlers/e2e_handler.py
new file mode 100644
index 0000000000000000000000000000000000000000..421e5ff0a2b16a13d64300f7940a22db8cfd0f68
--- /dev/null
+++ b/src/api/handlers/e2e_handler.py
@@ -0,0 +1,287 @@
+# Copyright 2022-2026 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.
+
+"""E2E Orchestrator slice and TAPI alert API service handler."""
+
+from __future__ import annotations
+
+import json
+import logging
+import sys
+from pathlib import Path
+from typing import Any
+
+from src.api.handlers.base_handler import BaseSliceHandler
+from src.database import alert_db as _real_alert_db
+from src.database.db import (
+ get_all_data as _db_get_all_data,
+)
+from src.database.db import (
+ get_data as _db_get_data,
+)
+from src.database.db import (
+ get_slice_id_by_subscription as _db_get_slice_id_by_sub,
+)
+from src.database.service_db import get_data as _db_get_service_db_data
+from src.utils.send_response import send_response
+
+logger = logging.getLogger(__name__)
+
+FALLBACK_INTENT_PATH = Path("/home/llmserver/tfs-nsc/intent.json")
+
+
+def _dep(name: str, fallback: Any) -> Any:
+ """Resolve dependency from src.api.main if patched, otherwise fallback."""
+ main_mod = sys.modules.get("src.api.main")
+ if main_mod is not None and hasattr(main_mod, name):
+ return getattr(main_mod, name)
+ return fallback
+
+
+def _parse_alert_notification(alert_data: dict[str, Any]) -> tuple[str | None, str | None, str | None]:
+ """Extract alert_id, subscription_id, and service_id from TAPI alert payload."""
+ context = alert_data.get("tapi-notification:notification-context", [])
+ if not context or not isinstance(context, list):
+ return None, None, None
+
+ notification = context[0].get("tapi-notification:notification", {})
+ alert_id = notification.get("uuid")
+ subscription_id = alert_id
+ additional_info = notification.get("additional-info", {})
+ service_id = additional_info.get("service-id")
+ return alert_id, subscription_id, service_id
+
+
+def _find_slice_for_alert(subscription_id: str | None, service_id: str | None) -> dict[str, Any] | None:
+ """Resolve slice information for an alert by subscription, service_id, database, or fallback file."""
+ slice_id = _resolve_slice_id(subscription_id, service_id)
+ slice_info = _lookup_slice_info(slice_id)
+
+ if slice_info is not None:
+ return slice_info
+
+ return _load_fallback_intent(slice_id)
+
+
+def _resolve_slice_id(subscription_id: str | None, service_id: str | None) -> str | None:
+ """Resolve slice ID from subscription mapping or service database."""
+ if subscription_id:
+ try:
+ get_slice_fn = _dep("get_slice_id_by_subscription", _db_get_slice_id_by_sub)
+ mapped_slice_id = get_slice_fn(subscription_id)
+ if mapped_slice_id:
+ logger.info("Found slice_id %s mapped to subscription_id %s", mapped_slice_id, subscription_id)
+ return mapped_slice_id
+ except Exception as exc:
+ logger.info("Subscription mapping lookup failed: %s", exc)
+
+ if service_id:
+ try:
+ get_svc_fn = _dep("get_service_db_data", _db_get_service_db_data)
+ service_info = get_svc_fn(service_id)
+ resolved_slice_id = service_info.get("slice_id")
+ logger.info("Found slice_id %s in service_db for service_id %s", resolved_slice_id, service_id)
+ return resolved_slice_id
+ except Exception as exc:
+ logger.info("service_db lookup failed: %s", exc)
+ return service_id
+
+ return None
+
+
+def _lookup_slice_info(slice_id: str | None) -> dict[str, Any] | None:
+ """Lookup slice info in the database by ID or fall back to the first available slice."""
+ if slice_id:
+ try:
+ get_data_fn = _dep("get_data", _db_get_data)
+ slice_info = get_data_fn(slice_id)
+ if slice_info:
+ logger.info("Found slice_info in db by slice_id %s", slice_id)
+ return slice_info
+ except Exception as exc:
+ logger.info("db lookup by slice_id %s failed: %s", slice_id, exc)
+
+ try:
+ get_all_data_fn = _dep("get_all_data", _db_get_all_data)
+ slices = get_all_data_fn()
+ logger.info("Slices in db: %s", [s.get("slice_id") for s in slices])
+ for s in slices:
+ if s.get("slice_id") == slice_id:
+ return s
+ if slices:
+ first_slice = slices[0]
+ logger.info("Defaulted to first slice from db: %s", first_slice.get("slice_id"))
+ return first_slice
+ except Exception as exc:
+ logger.info("db get_all_data lookup failed: %s", exc)
+
+ return None
+
+
+def _load_fallback_intent(slice_id: str | None) -> dict[str, Any] | None:
+ """Load fallback intent configuration from disk if available."""
+ if not FALLBACK_INTENT_PATH.exists():
+ return None
+
+ try:
+ with FALLBACK_INTENT_PATH.open("r", encoding="utf-8") as file:
+ intent_data = json.load(file)
+ logger.info("Loaded fallback intent from intent.json")
+ return {"slice_id": slice_id or "slice", "intent": intent_data}
+ except Exception as exc:
+ logger.error("Failed to read fallback intent.json: %s", exc)
+ return None
+
+
+def _swap_p2mp_endpoints(intent: dict[str, Any]) -> tuple[bool, str | None]:
+ """Modify P2MP receiver endpoints to alternate paths following an alert."""
+ nss = intent.get("ietf-network-slice-service:network-slice-services", {})
+ slice_services = nss.get("slice-service", [])
+ modified = False
+ old_service_id = None
+
+ for service in slice_services:
+ sdp_list = service.get("sdps", {}).get("sdp", [])
+ sdp_ids = [sdp.get("id") for sdp in sdp_list if sdp.get("id")]
+
+ connection_groups = service.get("connection-groups", {}).get("connection-group", [])
+ for cg in connection_groups:
+ connectivity_constructs = cg.get("connectivity-construct", [])
+ for cc in connectivity_constructs:
+ p2mp_sender = cc.get("p2mp-sender-sdp")
+ p2mp_receivers = cc.get("p2mp-receiver-sdp", [])
+
+ logger.info(
+ "sdp_ids: %s, p2mp_sender: %s, p2mp_receivers: %s",
+ sdp_ids,
+ p2mp_sender,
+ p2mp_receivers,
+ )
+
+ other_endpoints = [
+ sdp_id for sdp_id in sdp_ids if sdp_id != p2mp_sender and sdp_id not in p2mp_receivers
+ ]
+
+ if other_endpoints and p2mp_receivers:
+ new_receiver = other_endpoints[0]
+ cc["p2mp-receiver-sdp"] = (
+ [p2mp_receivers[0], new_receiver] if len(p2mp_receivers) >= 2 else [new_receiver]
+ )
+ modified = True
+ logger.info("ORIGEN: %s DESTINO: %s", p2mp_sender, new_receiver)
+ else:
+ logger.warning("No alternative receiver endpoints found to swap.")
+
+ if p2mp_receivers:
+ old_service_id = f"{p2mp_sender}_to_{','.join(p2mp_receivers)}"
+
+ return modified, old_service_id
+
+
+class E2EHandler(BaseSliceHandler):
+ """API handler dedicated to E2E Orchestrator slice operations and TAPI alert processing."""
+
+ def receive_alert(self, alert_data: dict[str, Any]) -> tuple[dict[str, Any], int]:
+ """Receive and process an incoming TAPI network alert."""
+ try:
+ logger.info("Alert received: %s", alert_data)
+ alert_id, subscription_id, service_id = _parse_alert_notification(alert_data)
+
+ if not alert_id:
+ return send_response(False, code=400, message="UUID not found in alert data")
+
+ adb = _dep("alert_db", _real_alert_db)
+ adb.save_alert(alert_id, alert_data)
+ logger.info(
+ "Looking up intent for subscription_id: %s, service_id: %s",
+ subscription_id,
+ service_id,
+ )
+
+ slice_info = _find_slice_for_alert(subscription_id, service_id)
+ if slice_info:
+ intent = slice_info.get("intent")
+ curr_slice_id = slice_info.get("slice_id")
+ logger.info("Processing intent for slice %s", curr_slice_id)
+
+ if intent:
+ modified, old_service_id = _swap_p2mp_endpoints(intent)
+ if modified:
+ try:
+ self.slice_service.nsc(intent, curr_slice_id, old_service_id=old_service_id)
+ logger.info("Slice %s updated successfully following alert.", curr_slice_id)
+ except Exception as exc:
+ logger.error("Failed to update slice configuration: %s", exc)
+ else:
+ logger.warning("No slice intent found to process alert.")
+
+ return send_response(
+ True,
+ code=201,
+ message="Alert processed and saved successfully",
+ data=alert_data,
+ )
+ except Exception as exc:
+ return send_response(False, code=500, message=str(exc))
+
+ def get_alerts(self, alert_id: str | None = None) -> tuple[dict[str, Any] | list[dict[str, Any]], int]:
+ """Retrieve alert(s)."""
+ try:
+ adb = _dep("alert_db", _real_alert_db)
+ if alert_id:
+ try:
+ data = adb.get_alert(alert_id)
+ return data, 200
+ except ValueError as exc:
+ return send_response(False, code=404, message=str(exc))
+
+ data = adb.get_all_alerts()
+ return data, 200
+ except Exception as exc:
+ return send_response(False, code=500, message=str(exc))
+
+ def modify_alert(self, alert_id: str, alert_data: dict[str, Any]) -> tuple[dict[str, Any], int]:
+ """Modify/update an alert."""
+ try:
+ adb = _dep("alert_db", _real_alert_db)
+ try:
+ adb.update_alert(alert_id, alert_data)
+ return send_response(
+ True,
+ code=200,
+ message="Alert updated successfully",
+ data=alert_data,
+ )
+ except ValueError as exc:
+ return send_response(False, code=404, message=str(exc))
+ except Exception as exc:
+ return send_response(False, code=500, message=str(exc))
+
+ def delete_alerts(self, alert_id: str | None = None) -> tuple[dict[str, Any], int]:
+ """Delete alert(s)."""
+ try:
+ adb = _dep("alert_db", _real_alert_db)
+ if alert_id:
+ try:
+ adb.delete_alert(alert_id)
+ return {}, 204
+ except ValueError as exc:
+ return send_response(False, code=404, message=str(exc))
+
+ adb.delete_all_alerts()
+ return {}, 204
+ except Exception as exc:
+ return send_response(False, code=500, message=str(exc))
diff --git a/src/api/handlers/ixia_handler.py b/src/api/handlers/ixia_handler.py
new file mode 100644
index 0000000000000000000000000000000000000000..7d25372da839ca3f2542a380e7529a6f09e40673
--- /dev/null
+++ b/src/api/handlers/ixia_handler.py
@@ -0,0 +1,29 @@
+# Copyright 2022-2026 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.
+
+"""IXIA NEII slice API service handler."""
+
+from __future__ import annotations
+
+import logging
+
+from src.api.handlers.base_handler import BaseSliceHandler
+
+logger = logging.getLogger(__name__)
+
+
+class IxiaHandler(BaseSliceHandler):
+ """API handler dedicated to IXIA NEII transport network slice operations."""
diff --git a/src/api/handlers/restconf_handler.py b/src/api/handlers/restconf_handler.py
new file mode 100644
index 0000000000000000000000000000000000000000..290629a6ef282b1d3b1d7c9397290f198579c5f6
--- /dev/null
+++ b/src/api/handlers/restconf_handler.py
@@ -0,0 +1,1058 @@
+# Copyright 2022-2026 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.
+
+"""RESTCONF API service handler for IETF Network Slice Service datastore, telemetry, and reconfiguration."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import sys
+from collections.abc import AsyncGenerator, Callable, Generator
+from typing import Any
+
+from flask import current_app
+
+from src.database.service_db import (
+ delete_by_slice_id as _db_delete_by_slice_id,
+)
+from src.database.service_db import (
+ get_data_by_slice_id as _db_get_data_by_slice_id,
+)
+from src.database.sysrepo_store import (
+ create_data_store as _real_create_data_store,
+)
+from src.database.sysrepo_store import (
+ delete_data_store as _real_delete_data_store,
+)
+from src.database.sysrepo_store import (
+ get_data_store as _real_get_data_store,
+)
+from src.database.sysrepo_store import (
+ normalize_libyang_data as _real_normalize_libyang_data,
+)
+from src.database.sysrepo_store import (
+ update_data_store as _real_update_data_store,
+)
+from src.database.telemetry_client_db import (
+ create_client as _real_create_client,
+)
+from src.database.telemetry_client_db import (
+ delete_all_clients as _real_delete_all_clients,
+)
+from src.database.telemetry_client_db import (
+ delete_all_subscriptions as _real_delete_all_subscriptions,
+)
+from src.database.telemetry_client_db import (
+ delete_client as _real_delete_client,
+)
+from src.database.telemetry_client_db import (
+ delete_subscription as _real_delete_subscription,
+)
+from src.database.telemetry_client_db import (
+ get_all_clients as _real_get_all_clients,
+)
+from src.database.telemetry_client_db import (
+ get_client as _real_get_client,
+)
+from src.database.telemetry_client_db import (
+ get_client_subscriptions as _real_get_client_subscriptions,
+)
+from src.database.telemetry_client_db import (
+ get_subscription as _real_get_subscription,
+)
+from src.database.telemetry_client_db import (
+ upsert_subscription as _real_upsert_subscription,
+)
+from src.realizer.restconf.connectors.tfs_connector import (
+ tfs_connector as _real_tfs_restconf_connector,
+)
+from src.realizer.tfs.helpers.tfs_connector import tfs_connector as _real_tfs_connector
+from src.realizer.tfs.service_types.tfs_l2vpn import (
+ tfs_l2vpn_delete as _real_tfs_l2vpn_delete,
+)
+from src.utils.safe_get import safe_get
+from src.utils.send_response import send_response
+
+logger = logging.getLogger(__name__)
+
+
+def _dep(name: str, fallback: Any) -> Any:
+ """Resolve dependency from src.api.main if patched, otherwise fallback."""
+ main_mod = sys.modules.get("src.api.main")
+ if main_mod is not None and hasattr(main_mod, name):
+ return getattr(main_mod, name)
+ return fallback
+
+
+def _build_full_slice_intent(
+ intent: dict[str, Any],
+) -> tuple[dict[str, Any] | None, tuple[dict[str, Any], int] | None]:
+ """Construct full IETF network slice intent from template reference or inline policy."""
+ get_data_store_fn = _dep("get_data_store", _real_get_data_store)
+ normalize_fn = _dep("normalize_libyang_data", _real_normalize_libyang_data)
+
+ if "slo-sle-template" in intent:
+ template_ref = intent.get("slo-sle-template")
+ xpath_template = f"/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template[id='{template_ref}']"
+ existing_template = get_data_store_fn(xpath_template)
+ if not existing_template:
+ return None, send_response(False, code=404, message="Referenced SLO/SLE template not found")
+
+ template_body = existing_template["network-slice-services"]["slo-sle-templates"]["slo-sle-template"][
+ template_ref
+ ]
+ full_intent = {
+ "ietf-network-slice-service:network-slice-services": {
+ "slo-sle-templates": {"slo-sle-template": [template_body]},
+ "slice-service": [intent],
+ }
+ }
+ elif "service-slo-sle-policy" in intent:
+ full_intent = {
+ "ietf-network-slice-service:network-slice-services": {
+ "slo-sle-templates": {"slo-sle-template": intent.get("service-slo-sle-policy")},
+ "slice-service": [intent],
+ }
+ }
+ else:
+ return None, send_response(False, code=400, message="No SLO/SLE template or policy provided in intent")
+
+ return normalize_fn(full_intent), None
+
+
+class RestconfHandler:
+ """API handler dedicated to RESTCONF IETF Network Slice Service operations."""
+
+ def __init__(self, slice_service: Any) -> None:
+ """Initialize RESTCONF handler with underlying slice service (e.g. NSController).
+
+ Args:
+ slice_service: Service instance managing controller-specific business logic.
+ """
+ self.slice_service = slice_service
+
+ # -------------------------------------------------------------------------
+ # RESTCONF Data: Network Slice Services Root
+ # -------------------------------------------------------------------------
+
+ def get_network_slice_services(self) -> tuple[dict[str, Any], int]:
+ """Retrieve root network slice services container from datastore."""
+ try:
+ get_ds_fn = _dep("get_data_store", _real_get_data_store)
+ data = get_ds_fn("/ietf-network-slice-service:network-slice-services")
+ if not data:
+ raise ValueError("Nothing found")
+ return data, 200
+ except ValueError as exc:
+ return send_response(False, code=404, message=str(exc))
+ except Exception as exc:
+ return send_response(False, code=500, message=str(exc))
+
+ def add_network_slice_service(self, intent: dict[str, Any]) -> tuple[dict[str, Any], int]:
+ """Create network slice services in datastore and realize via controller."""
+ try:
+ result = self.slice_service.nsc(intent)
+ if isinstance(result, tuple):
+ return result
+ if result:
+ try:
+ create_ds_fn = _dep("create_data_store", _real_create_data_store)
+ create_ds_fn(intent)
+ except Exception as ds_err:
+ logger.warning("Could not store intent in sysrepo datastore: %s", ds_err)
+ logger.info("Network Slice created successfully")
+ return send_response(
+ True,
+ code=201,
+ message="Network Slice created successfully",
+ data=result,
+ )
+ except RuntimeError as exc:
+ return send_response(False, code=422, message=str(exc))
+ except Exception as exc:
+ return send_response(False, code=500, message=str(exc))
+
+ def update_network_slice_service(self, intent: dict[str, Any]) -> tuple[dict[str, Any], int]:
+ """Modify (replace) all network-slice-services configuration."""
+ try:
+ xpath = "/ietf-network-slice-service:network-slice-services"
+ get_ds_fn = _dep("get_data_store", _real_get_data_store)
+ existing_data = get_ds_fn(xpath)
+ if not existing_data:
+ return send_response(False, code=404, message="Network slice services not found")
+
+ # 1. Normalize existing data and extract existing slice IDs
+ normalize_fn = _dep("normalize_libyang_data", _real_normalize_libyang_data)
+ normalized_existing = normalize_fn(existing_data)
+ existing_slices_list = safe_get(
+ normalized_existing,
+ ["network-slice-services", "slice-service"]
+ ) or []
+ existing_slice_ids = {
+ item["id"] for item in existing_slices_list if isinstance(item, dict) and "id" in item
+ }
+
+ # 2. Normalize incoming intent and extract new slice IDs
+ normalized_incoming = normalize_fn(intent)
+ incoming_slices_list = safe_get(
+ normalized_incoming,
+ ["ietf-network-slice-service:network-slice-services", "slice-service"]
+ ) or []
+ incoming_slices_map = {
+ item["id"]: item for item in incoming_slices_list if isinstance(item, dict) and "id" in item
+ }
+ new_slice_ids = set(incoming_slices_map.keys())
+
+ # 3. Calculate diffs: to_delete, to_update, to_create
+ to_delete = existing_slice_ids - new_slice_ids
+ to_update = existing_slice_ids & new_slice_ids
+ to_create = new_slice_ids - existing_slice_ids
+
+ logger.info("Slice Update diff - Delete: %s, Update: %s, Create: %s", to_delete, to_update, to_create)
+
+ # A) Delete slices no longer present
+ for old_id in to_delete:
+ logger.info("Deleting slice '%s' during container update...", old_id)
+ self._delete_single_slice_service(old_id)
+
+ results = []
+ # B) Update existing slices
+ for slice_id in to_update:
+ logger.info("Updating slice '%s' during container update...", slice_id)
+ slice_item = incoming_slices_map[slice_id]
+ full_intent = {
+ "ietf-network-slice-service:network-slice-services": {
+ "slo-sle-templates": safe_get(normalized_incoming, ["ietf-network-slice-service:network-slice-services", "slo-sle-templates"]) or {},
+ "slice-service": [slice_item],
+ }
+ }
+ res = self.slice_service.nsc(full_intent, slice_id=slice_id)
+ if not res:
+ return send_response(False, code=500, message=f"Failed to process slice '{slice_id}' in TFS")
+ results.append(res)
+
+ # C) Create new slices
+ for slice_id in to_create:
+ logger.info("Creating new slice '%s' during container update...", slice_id)
+ slice_item = incoming_slices_map[slice_id]
+ full_intent = {
+ "ietf-network-slice-service:network-slice-services": {
+ "slo-sle-templates": safe_get(normalized_incoming, ["ietf-network-slice-service:network-slice-services", "slo-sle-templates"]) or {},
+ "slice-service": [slice_item],
+ }
+ }
+ res = self.slice_service.nsc(full_intent, slice_id=None)
+ if not res:
+ return send_response(False, code=500, message=f"Failed to process new slice '{slice_id}' in TFS")
+ results.append(res)
+
+ if len(to_update) == 0 and len(to_create) == 0:
+ slice_id = safe_get(intent, ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "id"])
+ res = self.slice_service.nsc(intent, slice_id=slice_id)
+ if not res:
+ return send_response(False, code=500, message="Failed to process slice in TFS")
+ results.append(res)
+
+ update_ds_fn = _dep("update_data_store", _real_update_data_store)
+ update_ds_fn(intent)
+ logger.info("Network slice services modified successfully")
+ return send_response(
+ True,
+ code=200,
+ message="Network slice services updated successfully",
+ data=results if len(results) > 1 else (results[0] if results else {}),
+ )
+ except ValueError as exc:
+ return send_response(False, code=404, message=str(exc))
+ except RuntimeError as exc:
+ return send_response(False, code=422, message=str(exc))
+ except Exception as exc:
+ return send_response(False, code=500, message=str(exc))
+
+ def delete_network_slice_services(self) -> tuple[dict[str, Any], int]:
+ """Delete all network-slice-services from datastore and controller."""
+ try:
+ xpath = "/ietf-network-slice-service:network-slice-services"
+ get_ds_fn = _dep("get_data_store", _real_get_data_store)
+ if not current_app.config["DUMMY_MODE"]:
+ content = get_ds_fn(xpath)
+ slice_services = safe_get(content, ["network-slice-services", "slice-service"])
+ if not slice_services:
+ raise ValueError("Network slice services not found")
+
+ for slice_item in slice_services:
+ slice_type_val = safe_get(
+ slice_item,
+ ["service-tags", "tag-type", "ietf-network-slice-service:service", "tag-type-value"],
+ )
+ slice_type = list(slice_type_val)[0] if slice_type_val else "L2"
+ logger.debug("Send slice to delete in TFS with slice_type %s", slice_type)
+
+ try:
+ get_svc_by_slice = _dep("get_data_by_slice_id", _db_get_data_by_slice_id)
+ del_svc_by_slice = _dep("delete_by_slice_id", _db_delete_by_slice_id)
+ services = get_svc_by_slice(slice_item.get("id"))
+ if services:
+ for service in services:
+ svc_id = service.get("service_id")
+ tfs_conn = _dep("tfs_connector", _real_tfs_connector)()
+ tfs_conn.nbi_delete(current_app.config["RESTCONF_IP"], slice_type, svc_id)
+ del_svc_by_slice(slice_item.get("id"))
+ except Exception as exc:
+ logger.debug("No service_db entries to delete for slice %s: %s", slice_item.get("id"), exc)
+
+ if current_app.config["TFS_L2VPN_SUPPORT"]:
+ if hasattr(self.slice_service, "tfs_l2vpn_delete"):
+ self.slice_service.tfs_l2vpn_delete()
+ else:
+ l2vpn_del = _dep("tfs_l2vpn_delete", _real_tfs_l2vpn_delete)
+ l2vpn_del()
+
+ delete_ds_fn = _dep("delete_data_store", _real_delete_data_store)
+ delete_ds_fn(xpath)
+ logger.info("All slices removed successfully")
+ return {}, 204
+ except ValueError as exc:
+ return send_response(False, code=404, message=str(exc))
+ except Exception as exc:
+ return send_response(False, code=500, message=str(exc))
+
+ # -------------------------------------------------------------------------
+ # RESTCONF Data: SLO/SLE Templates
+ # -------------------------------------------------------------------------
+
+ def get_slo_sle_templates(self, template_id: str | None = None) -> tuple[dict[str, Any], int]:
+ """Retrieve one or all SLO/SLE templates from datastore."""
+ try:
+ get_ds_fn = _dep("get_data_store", _real_get_data_store)
+ if template_id:
+ xpath = f"/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template[id='{template_id}']"
+ data = get_ds_fn(xpath)
+ if not data:
+ raise ValueError("Template not found")
+ return data, 200
+
+ data = get_ds_fn("/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template")
+ if not data:
+ raise ValueError("No templates found")
+ return data, 200
+ except ValueError as exc:
+ return send_response(False, code=404, message=str(exc))
+ except Exception as exc:
+ return send_response(False, code=500, message=str(exc))
+
+ def add_slo_sle_template(self, template: dict[str, Any]) -> tuple[dict[str, Any], int]:
+ """Create a new SLO/SLE template in the datastore."""
+ try:
+ template_id = template.pop("id", None)
+ xpath = f"/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template[id='{template_id}']"
+ get_ds_fn = _dep("get_data_store", _real_get_data_store)
+ existing_template = get_ds_fn(xpath)
+ if existing_template:
+ return send_response(False, code=409, message="Template already exists")
+
+ create_ds_fn = _dep("create_data_store", _real_create_data_store)
+ create_ds_fn(template, xpath)
+ logger.info("Template created successfully")
+ return send_response(True, code=201, message="Template created successfully")
+ except Exception as exc:
+ return send_response(False, code=500, message=str(exc))
+
+ def update_slo_sle_template(self, template_id: str, template: dict[str, Any]) -> tuple[dict[str, Any], int]:
+ """Modify (replace) a specific SLO/SLE template."""
+ try:
+ xpath = f"/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template[id='{template_id}']"
+ get_ds_fn = _dep("get_data_store", _real_get_data_store)
+ existing_template = get_ds_fn(xpath)
+ if not existing_template:
+ return send_response(False, code=404, message="Template not found")
+
+ if "id" in template and template["id"] != template_id:
+ return send_response(False, code=400, message="Template ID in body does not match URL")
+
+ slices = get_ds_fn("/ietf-network-slice-service:network-slice-services/slice-service")
+ result = None
+ if slices and "network-slice-services" in slices:
+ slice_list = slices["network-slice-services"].get("slice-service", [])
+ for slice_item in slice_list:
+ if slice_item.get("slo-sle-template") == template_id:
+ full_intent = {
+ "ietf-network-slice-service:network-slice-services": {
+ "slo-sle-templates": {
+ "slo-sle-template": [
+ existing_template["network-slice-services"]["slo-sle-templates"][
+ "slo-sle-template"
+ ][template_id]
+ ]
+ },
+ "slice-service": [slice_item],
+ }
+ }
+ normalize_fn = _dep("normalize_libyang_data", _real_normalize_libyang_data)
+ normalized_intent = normalize_fn(full_intent)
+ result = self.slice_service.nsc(normalized_intent, slice_item.get("id"))
+ if not result:
+ return send_response(False, code=500, message="Slice not updated")
+
+ template_data = template.copy()
+ template_data.pop("id", None)
+ update_ds_fn = _dep("update_data_store", _real_update_data_store)
+ update_ds_fn(template_data, xpath)
+ logger.info("Template %s modified successfully", template_id)
+
+ return send_response(
+ True,
+ code=200,
+ message="Template updated successfully",
+ data=result,
+ )
+ except ValueError as exc:
+ return send_response(False, code=404, message=str(exc))
+ except Exception as exc:
+ return send_response(False, code=500, message=str(exc))
+
+ def delete_slo_sle_templates(self, template_id: str | None = None) -> tuple[dict[str, Any], int]:
+ """Delete specific or all SLO/SLE templates."""
+ try:
+ get_ds_fn = _dep("get_data_store", _real_get_data_store)
+ delete_ds_fn = _dep("delete_data_store", _real_delete_data_store)
+ if template_id:
+ xpath = f"/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template[id='{template_id}']"
+ existing_template = get_ds_fn(xpath)
+ if not existing_template:
+ raise ValueError("Template not found")
+ delete_ds_fn(xpath)
+ logger.info("Template %s removed successfully", template_id)
+ return {}, 204
+
+ xpath = "/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template"
+ delete_ds_fn(xpath)
+ logger.info("All templates removed successfully")
+ return {}, 204
+ except ValueError as exc:
+ return send_response(False, code=404, message=str(exc))
+ except Exception as exc:
+ return send_response(False, code=500, message=str(exc))
+
+ # -------------------------------------------------------------------------
+ # RESTCONF Data: Slice Services
+ # -------------------------------------------------------------------------
+
+ def get_slice_services(self, slice_id: str | None = None) -> tuple[dict[str, Any], int]:
+ """Retrieve specific or all slice services from datastore."""
+ try:
+ get_ds_fn = _dep("get_data_store", _real_get_data_store)
+ if slice_id:
+ xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']"
+ data = get_ds_fn(xpath)
+ if not data:
+ raise ValueError("Slice not found")
+ return data, 200
+
+ data = get_ds_fn("/ietf-network-slice-service:network-slice-services/slice-service")
+ if not data:
+ raise ValueError("No slices found")
+ return data, 200
+ except ValueError as exc:
+ return send_response(False, code=404, message=str(exc))
+ except Exception as exc:
+ return send_response(False, code=500, message=str(exc))
+
+ def add_slice_service(self, intent: dict[str, Any]) -> tuple[dict[str, Any], int]:
+ """Add a new slice service to the datastore."""
+ try:
+ slice_id = intent.get("id")
+ xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']"
+ get_ds_fn = _dep("get_data_store", _real_get_data_store)
+ existing_slice = get_ds_fn(xpath)
+ if existing_slice:
+ return send_response(False, code=409, message="Slice already exists")
+
+ full_intent, err_resp = _build_full_slice_intent(intent)
+ if err_resp is not None:
+ return err_resp
+
+ result = self.slice_service.nsc(full_intent)
+ if result:
+ intent_copy = intent.copy()
+ intent_copy.pop("id", None)
+ create_ds_fn = _dep("create_data_store", _real_create_data_store)
+ create_ds_fn(intent_copy, xpath)
+ logger.info("Slice created successfully")
+
+ return send_response(
+ True,
+ code=201,
+ message="Slice created successfully",
+ data=result,
+ )
+ except RuntimeError as exc:
+ return send_response(False, code=422, message=str(exc))
+ except Exception as exc:
+ return send_response(False, code=500, message=str(exc))
+
+ def update_slice_service(self, slice_id: str, intent: dict[str, Any]) -> tuple[dict[str, Any], int]:
+ """Modify (replace) a specific slice service."""
+ try:
+ xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']"
+ get_ds_fn = _dep("get_data_store", _real_get_data_store)
+ existing_slice = get_ds_fn(xpath)
+ if not existing_slice:
+ return send_response(False, code=404, message="Slice not found")
+
+ if "id" in intent and intent["id"] != slice_id:
+ return send_response(False, code=400, message="Slice ID in body does not match URL")
+
+ full_intent, err_resp = _build_full_slice_intent(intent)
+ if err_resp is not None:
+ return err_resp
+
+ result = self.slice_service.nsc(full_intent, slice_id)
+ if not result:
+ return send_response(False, code=500, message="Slice not updated")
+
+ intent_data = intent.copy()
+ intent_data.pop("id", None)
+ update_ds_fn = _dep("update_data_store", _real_update_data_store)
+ update_ds_fn(intent_data, xpath)
+ logger.info("Slice %s modified successfully", slice_id)
+
+ return send_response(
+ True,
+ code=200,
+ message="Slice updated successfully",
+ data=result,
+ )
+ except ValueError as exc:
+ return send_response(False, code=404, message=str(exc))
+ except RuntimeError as exc:
+ return send_response(False, code=422, message=str(exc))
+ except Exception as exc:
+ return send_response(False, code=500, message=str(exc))
+
+ def delete_slice_services(self, slice_id: str | None = None) -> tuple[dict[str, Any], int]:
+ """Delete specific or all slice services."""
+ try:
+ if slice_id:
+ return self._delete_single_slice_service(slice_id)
+ return self._delete_all_slice_services()
+ except ValueError as exc:
+ return send_response(False, code=404, message=str(exc))
+ except Exception as exc:
+ return send_response(False, code=500, message=str(exc))
+
+ def _delete_single_slice_service(self, slice_id: str) -> tuple[dict[str, Any], int]:
+ """Delete a single slice service by slice_id."""
+ xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']"
+ get_ds_fn = _dep("get_data_store", _real_get_data_store)
+ existing_slice = get_ds_fn(xpath)
+ if not existing_slice:
+ raise ValueError("Slice not found")
+
+ if not current_app.config["DUMMY_MODE"]:
+ slice_type = (
+ safe_get(
+ existing_slice,
+ [
+ "network-slice-services",
+ "slice-service",
+ slice_id,
+ "service-tags",
+ "tag-type",
+ "ietf-network-slice-service:service",
+ "tag-type-value",
+ 0,
+ ],
+ )
+ or "L2"
+ )
+ logger.debug("Send slice to delete in TFS with slice_type %s", slice_type)
+ try:
+ get_svc_by_slice = _dep("get_data_by_slice_id", _db_get_data_by_slice_id)
+ del_svc_by_slice = _dep("delete_by_slice_id", _db_delete_by_slice_id)
+ services = get_svc_by_slice(slice_id)
+ if services:
+ for service in services:
+ svc_id = service.get("service_id")
+ tfs_conn = _dep("tfs_connector", _real_tfs_connector)()
+ tfs_conn.nbi_delete(current_app.config["RESTCONF_IP"], slice_type, svc_id)
+ del_svc_by_slice(slice_id)
+ except Exception as exc:
+ logger.debug("No service_db entries for slice %s: %s", slice_id, exc)
+
+ if current_app.config["TFS_L2VPN_SUPPORT"]:
+ if hasattr(self.slice_service, "tfs_l2vpn_delete"):
+ self.slice_service.tfs_l2vpn_delete()
+ else:
+ l2vpn_del = _dep("tfs_l2vpn_delete", _real_tfs_l2vpn_delete)
+ l2vpn_del()
+
+ delete_ds_fn = _dep("delete_data_store", _real_delete_data_store)
+ delete_ds_fn(xpath)
+ logger.info("Slice %s removed successfully", slice_id)
+ return {}, 204
+
+ def _delete_all_slice_services(self) -> tuple[dict[str, Any], int]:
+ """Delete all slice services from datastore and controller."""
+ xpath = "/ietf-network-slice-service:network-slice-services/slice-service"
+ get_ds_fn = _dep("get_data_store", _real_get_data_store)
+ if not current_app.config["DUMMY_MODE"]:
+ content = get_ds_fn(xpath)
+ slice_services = safe_get(content, ["network-slice-services", "slice-service"])
+ if not slice_services:
+ raise ValueError("Slice services not found")
+
+ for slice_item in slice_services:
+ slice_type = (
+ safe_get(
+ slice_item,
+ ["service-tags", "tag-type", "ietf-network-slice-service:service", "tag-type-value", 0],
+ )
+ or "L2"
+ )
+ logger.debug("Send slice to delete in TFS with slice_type %s", slice_type)
+ try:
+ get_svc_by_slice = _dep("get_data_by_slice_id", _db_get_data_by_slice_id)
+ del_svc_by_slice = _dep("delete_by_slice_id", _db_delete_by_slice_id)
+ services = get_svc_by_slice(slice_item.get("id"))
+ if services:
+ for service in services:
+ svc_id = service.get("service_id")
+ tfs_conn = _dep("tfs_connector", _real_tfs_connector)()
+ tfs_conn.nbi_delete(current_app.config["RESTCONF_IP"], slice_type, svc_id)
+ del_svc_by_slice(slice_item.get("id"))
+ except Exception as exc:
+ logger.debug("No service_db entries for slice %s: %s", slice_item.get("id"), exc)
+
+ if current_app.config["TFS_L2VPN_SUPPORT"]:
+ if hasattr(self.slice_service, "tfs_l2vpn_delete"):
+ self.slice_service.tfs_l2vpn_delete()
+ else:
+ l2vpn_del = _dep("tfs_l2vpn_delete", _real_tfs_l2vpn_delete)
+ l2vpn_del()
+
+ delete_ds_fn = _dep("delete_data_store", _real_delete_data_store)
+ delete_ds_fn(xpath)
+ logger.info("All slices removed successfully")
+ return {}, 204
+
+ # -------------------------------------------------------------------------
+ # RESTCONF Data: SDPs
+ # -------------------------------------------------------------------------
+
+ def get_sdps(self, slice_id: str, sdp_id: str | None = None) -> tuple[dict[str, Any], int]:
+ """Retrieve SDP(s) for a given slice."""
+ try:
+ get_ds_fn = _dep("get_data_store", _real_get_data_store)
+ if sdp_id:
+ xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']/sdps/sdp[id='{sdp_id}']"
+ data = get_ds_fn(xpath)
+ if not data:
+ raise ValueError("SDP not found")
+ return data, 200
+
+ xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']/sdps"
+ data = get_ds_fn(xpath)
+ if not data:
+ raise ValueError("No SDPs found")
+ return data, 200
+ except ValueError as exc:
+ return send_response(False, code=404, message=str(exc))
+ except Exception as exc:
+ return send_response(False, code=500, message=str(exc))
+
+ def add_sdp(self, slice_id: str, sdp: dict[str, Any]) -> tuple[dict[str, Any], int]:
+ """Add an SDP to a slice service."""
+ try:
+ sdp_id = sdp.pop("id", None)
+ xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']/sdps/sdp[id='{sdp_id}']"
+ get_ds_fn = _dep("get_data_store", _real_get_data_store)
+ existing_sdp = get_ds_fn(xpath)
+ if existing_sdp:
+ return send_response(False, code=409, message="SDP already exists")
+
+ create_ds_fn = _dep("create_data_store", _real_create_data_store)
+ create_ds_fn(sdp, xpath)
+ logger.info("SDP created successfully")
+ return send_response(True, code=201, message="SDP created successfully")
+ except Exception as exc:
+ return send_response(False, code=500, message=str(exc))
+
+ def update_sdp(self, slice_id: str, sdp_id: str, sdp: dict[str, Any]) -> tuple[dict[str, Any], int]:
+ """Modify (replace) a specific SDP in the slice."""
+ try:
+ get_ds_fn = _dep("get_data_store", _real_get_data_store)
+ slice_xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']"
+ if not get_ds_fn(slice_xpath):
+ return send_response(False, code=404, message="Slice not found")
+
+ sdp_xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']/sdps/sdp[id='{sdp_id}']"
+ if not get_ds_fn(sdp_xpath):
+ return send_response(False, code=404, message="SDP not found")
+
+ if "id" in sdp and sdp["id"] != sdp_id:
+ return send_response(False, code=400, message="SDP ID in body does not match URL")
+
+ sdp_data = sdp.copy()
+ sdp_data.pop("id", None)
+ update_ds_fn = _dep("update_data_store", _real_update_data_store)
+ update_ds_fn(sdp_data, sdp_xpath)
+ logger.info("SDP %s in slice %s modified successfully", sdp_id, slice_id)
+
+ return send_response(True, code=200, message="SDP updated successfully")
+ except ValueError as exc:
+ return send_response(False, code=404, message=str(exc))
+ except Exception as exc:
+ return send_response(False, code=500, message=str(exc))
+
+ def delete_sdps(self, slice_id: str, sdp_id: str | None = None) -> tuple[dict[str, Any], int]:
+ """Delete specific or all SDPs for a slice."""
+ try:
+ get_ds_fn = _dep("get_data_store", _real_get_data_store)
+ delete_ds_fn = _dep("delete_data_store", _real_delete_data_store)
+
+ slice_xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']"
+ if not get_ds_fn(slice_xpath):
+ raise ValueError("Slice not found")
+
+ if sdp_id:
+ sdp_xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']/sdps/sdp[id='{sdp_id}']"
+ if not get_ds_fn(sdp_xpath):
+ raise ValueError("SDP not found")
+ delete_ds_fn(sdp_xpath)
+ logger.info("SDP %s removed successfully", sdp_id)
+ return {}, 204
+
+ all_sdps_xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']/sdps"
+ delete_ds_fn(all_sdps_xpath)
+ logger.info("All SDPs removed successfully")
+ return {}, 204
+ except ValueError as exc:
+ return send_response(False, code=404, message=str(exc))
+ except Exception as exc:
+ return send_response(False, code=500, message=str(exc))
+
+ # -------------------------------------------------------------------------
+ # RESTCONF Data: Topology
+ # -------------------------------------------------------------------------
+
+ def get_slice_topology(self, slice_id: str) -> tuple[dict[str, Any], int]:
+ """Retrieve Network Slice Topology for a given slice_id."""
+ try:
+ tfs_ip = current_app.config.get("RESTCONF_IP", "127.0.0.1")
+ conn_fn = _dep("tfs_restconf_connector", _real_tfs_restconf_connector)
+ connector = conn_fn()
+ return connector.get_slice_topology(tfs_ip, slice_id)
+ except Exception as exc:
+ logger.exception("Error retrieving slice topology for slice '%s'", slice_id)
+ return send_response(False, code=500, message=str(exc))
+
+ # -------------------------------------------------------------------------
+ # RESTCONF Telemetry Operations: Clients
+ # -------------------------------------------------------------------------
+
+ def get_clients(self, client_id: str | None = None) -> tuple[dict[str, Any], int]:
+ """Retrieve one or all registered telemetry clients."""
+ try:
+ if client_id:
+ get_cli_fn = _dep("get_client", _real_get_client)
+ cli = get_cli_fn(client_id)
+ if not cli:
+ raise ValueError(f"Client '{client_id}' not found")
+ return send_response(True, code=200, data=cli)
+ get_all_cli_fn = _dep("get_all_clients", _real_get_all_clients)
+ clients = get_all_cli_fn()
+ return send_response(True, code=200, data=clients or [])
+ except ValueError as exc:
+ return send_response(False, code=404, message=str(exc))
+ except Exception as exc:
+ return send_response(False, code=500, message=str(exc))
+
+ def add_client(self, client_id: str) -> tuple[dict[str, Any], int]:
+ """Register a new telemetry client."""
+ try:
+ create_cli_fn = _dep("create_client", _real_create_client)
+ create_cli_fn(client_id)
+ logger.info("Client '%s' created successfully", client_id)
+ return send_response(
+ True,
+ code=201,
+ message=f"Client '{client_id}' created successfully",
+ data={"client_id": client_id},
+ )
+ except ValueError as exc:
+ return send_response(False, code=409, message=str(exc))
+ except Exception as exc:
+ return send_response(False, code=500, message=str(exc))
+
+ def delete_clients(self, client_id: str | None = None) -> tuple[dict[str, Any], int]:
+ """Delete one or all telemetry clients."""
+ try:
+ if client_id:
+ del_cli_fn = _dep("delete_client", _real_delete_client)
+ del_cli_fn(client_id)
+ logger.info("Client '%s' removed successfully", client_id)
+ else:
+ del_all_cli_fn = _dep("delete_all_clients", _real_delete_all_clients)
+ del_all_cli_fn()
+ logger.info("All clients removed successfully")
+ return {}, 204
+ except ValueError as exc:
+ return send_response(False, code=404, message=str(exc))
+ except Exception as exc:
+ return send_response(False, code=500, message=str(exc))
+
+ # -------------------------------------------------------------------------
+ # RESTCONF Telemetry Operations: Subscriptions
+ # -------------------------------------------------------------------------
+
+ def get_subscriptions(self, client_id: str, slice_id: str | None = None) -> tuple[dict[str, Any], int]:
+ """Retrieve telemetry subscriptions for a client."""
+ try:
+ if slice_id is not None:
+ return self._get_single_client_subscription(client_id, slice_id)
+ return self._get_all_client_subscriptions(client_id)
+ except ValueError as exc:
+ return send_response(False, code=404, message=str(exc))
+ except Exception as exc:
+ return send_response(False, code=500, message=str(exc))
+
+ def _get_single_client_subscription(self, client_id: str, slice_id: str) -> tuple[dict[str, Any], int]:
+ """Get a single subscription with telemetry data."""
+ try:
+ get_sub_fn = _dep("get_subscription", _real_get_subscription)
+ subscription = get_sub_fn(client_id, slice_id)
+ except ValueError:
+ subscription = None
+
+ if not subscription:
+ raise ValueError(f"Client '{client_id}' has no subscription for slice '{slice_id}'")
+
+ telemetry, _ = self.get_telemetry(slice_id)
+ return {**subscription, "telemetry": telemetry}, 200
+
+ def _get_all_client_subscriptions(self, client_id: str) -> tuple[dict[str, Any], int]:
+ """Get all subscriptions for a client with telemetry data."""
+ get_cli_subs_fn = _dep("get_client_subscriptions", _real_get_client_subscriptions)
+ subscriptions = get_cli_subs_fn(client_id)
+ result = []
+ for sub in subscriptions:
+ sub_slice_id = sub["slice_id"]
+ telemetry, _ = self.get_telemetry(sub_slice_id)
+ result.append({**sub, "telemetry": telemetry})
+
+ return {"client_id": client_id, "subscriptions": result}, 200
+
+ def add_subscription(self, client_id: str, slice_id: str, frequency: int) -> tuple[dict[str, Any], int]:
+ """Create a telemetry subscription."""
+ try:
+ try:
+ get_sub_fn = _dep("get_subscription", _real_get_subscription)
+ subscription = get_sub_fn(client_id, slice_id)
+ except ValueError:
+ subscription = None
+
+ if subscription:
+ raise ValueError(f"Client '{client_id}' already has a subscription for slice '{slice_id}'")
+ if not frequency:
+ raise KeyError("Field 'frequency' is required")
+
+ upsert_sub_fn = _dep("upsert_subscription", _real_upsert_subscription)
+ upsert_sub_fn(client_id, slice_id, frequency)
+ logger.info("Subscription for slice '%s' and client '%s' created successfully", slice_id, client_id)
+ return send_response(
+ True,
+ code=201,
+ message="Subscription successfully created",
+ data={"slice_id": slice_id, "frequency": frequency},
+ )
+ except KeyError as exc:
+ return send_response(False, code=400, message=str(exc))
+ except ValueError as exc:
+ return send_response(False, code=404, message=str(exc))
+ except Exception as exc:
+ return send_response(False, code=500, message=str(exc))
+
+ def update_subscription(self, client_id: str, slice_id: str, frequency: int) -> tuple[dict[str, Any], int]:
+ """Modify an existing telemetry subscription."""
+ try:
+ try:
+ get_sub_fn = _dep("get_subscription", _real_get_subscription)
+ subscription = get_sub_fn(client_id, slice_id)
+ except ValueError:
+ subscription = None
+
+ if not subscription:
+ raise ValueError(f"Client '{client_id}' has no subscription for slice '{slice_id}'")
+ if not frequency:
+ raise KeyError("Field 'frequency' is required")
+
+ upsert_sub_fn = _dep("upsert_subscription", _real_upsert_subscription)
+ upsert_sub_fn(client_id, slice_id, frequency)
+ logger.info("Subscription for slice '%s' and client '%s' modified successfully", slice_id, client_id)
+ return send_response(
+ True,
+ code=200,
+ message="Subscription successfully modified",
+ data={"slice_id": slice_id, "frequency": frequency},
+ )
+ except KeyError as exc:
+ return send_response(False, code=400, message=str(exc))
+ except ValueError as exc:
+ return send_response(False, code=404, message=str(exc))
+ except Exception as exc:
+ return send_response(False, code=500, message=str(exc))
+
+ def delete_subscriptions(self, client_id: str, slice_id: str | None = None) -> tuple[dict[str, Any], int]:
+ """Delete one or all subscriptions for a client."""
+ try:
+ get_cli_subs_fn = _dep("get_client_subscriptions", _real_get_client_subscriptions)
+ subscriptions = get_cli_subs_fn(client_id)
+ if slice_id:
+ if slice_id not in subscriptions:
+ raise ValueError(f"Client '{client_id}' has no subscription for slice '{slice_id}'")
+ del_sub_fn = _dep("delete_subscription", _real_delete_subscription)
+ del_sub_fn(client_id, slice_id)
+ logger.info("Subscription for slice '%s' and client '%s' removed successfully", slice_id, client_id)
+ return {}, 204
+
+ del_all_subs_fn = _dep("delete_all_subscriptions", _real_delete_all_subscriptions)
+ del_all_subs_fn(client_id)
+ logger.info("All subscriptions for client '%s' removed successfully", client_id)
+ return {}, 204
+ except ValueError as exc:
+ return send_response(False, code=404, message=str(exc))
+ except Exception as exc:
+ return send_response(False, code=500, message=str(exc))
+
+ # -------------------------------------------------------------------------
+ # RESTCONF Telemetry Operations: Metrics & SSE Streaming
+ # -------------------------------------------------------------------------
+
+ def get_telemetry(self, slice_id: str | None = None) -> tuple[dict[str, Any], int]:
+ """Retrieve monitoring telemetry metrics for one or all slices."""
+ logger.debug("Getting telemetry for slice_id: %s", slice_id)
+ try:
+ if slice_id is not None:
+ return self._get_single_slice_telemetry(slice_id)
+ return self._get_all_slices_telemetry()
+ except ValueError as exc:
+ return send_response(False, code=404, message=str(exc))
+ except Exception as exc:
+ return send_response(False, code=500, message=str(exc))
+
+ def _get_single_slice_telemetry(self, slice_id: str) -> tuple[dict[str, Any], int]:
+ """Fetch telemetry for a single slice."""
+ xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']"
+ get_ds_fn = _dep("get_data_store", _real_get_data_store)
+ existing_slice = get_ds_fn(xpath)
+ if not existing_slice:
+ raise ValueError(f"There is no slice with id '{slice_id}' registered")
+
+ template_id = safe_get(
+ existing_slice, ["network-slice-services", "slice-service", slice_id, "slo-sle-template"]
+ )
+ slo_sle_template = self.get_slo_sle_templates(template_id)[0]
+ slo_sle_template = safe_get(
+ slo_sle_template, ["network-slice-services", "slo-sle-templates", "slo-sle-template"]
+ )
+ 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)
+ return metrics, 200
+
+ def _get_all_slices_telemetry(self) -> tuple[dict[str, Any], int]:
+ """Fetch telemetry for all registered slices."""
+ telemetry_data: dict[str, Any] = {}
+ slices_data = self.get_slice_services()[0]
+ slice_service_list = slices_data["network-slice-services"]["slice-service"]
+ if isinstance(slice_service_list, dict):
+ slice_service_list = list(slice_service_list.values())
+
+ for slice_item in slice_service_list:
+ selected_template_id = slice_item.get("slo-sle-template")
+ slo_sle_template = self.get_slo_sle_templates(selected_template_id)[0]
+ slo_sle_template = safe_get(
+ slo_sle_template, ["network-slice-services", "slo-sle-templates", "slo-sle-template"]
+ )
+ slo_sle_template = next(iter(slo_sle_template), None)
+
+ if not slo_sle_template:
+ raise ValueError(f"SLO/SLE template '{selected_template_id}' not found for slice '{slice_item['id']}'")
+
+ curr_id = slice_item["id"]
+ telemetry_data[curr_id] = self.slice_service.monitoring(curr_id, slo_sle_template)
+
+ return telemetry_data, 200
+
+ def sync_stream(
+ self,
+ async_gen_func: Callable[..., AsyncGenerator[str, None]],
+ *args: Any,
+ **kwargs: Any,
+ ) -> Generator[str, None, None]:
+ """Synchronous wrapper for consuming an async generator in Flask streaming responses."""
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+ agen = async_gen_func(*args, **kwargs)
+
+ try:
+ while True:
+ yield loop.run_until_complete(agen.__anext__())
+ except StopAsyncIteration:
+ pass
+ finally:
+ loop.close()
+
+ async def stream_client_subscriptions(self, client_id: str) -> AsyncGenerator[str, None]:
+ """Async SSE event stream for all client subscriptions."""
+ while True:
+ try:
+ data, code = self.get_subscriptions(client_id)
+ if code == 200:
+ yield f"data: {json.dumps(data)}\n\n"
+ subs = data.get("subscriptions", [])
+ freq = max([s["frequency"] for s in subs]) if subs else 5
+ await asyncio.sleep(freq)
+ else:
+ yield f"event: error\ndata: {json.dumps(data)}\n\n"
+ break
+ except Exception as exc:
+ yield f"event: error\ndata: {json.dumps({'error': str(exc)})}\n\n"
+ break
+
+ async def stream_slice_subscription(self, client_id: str, slice_id: str) -> AsyncGenerator[str, None]:
+ """Async SSE event stream for a specific slice subscription."""
+ while True:
+ try:
+ data, code = self.get_subscriptions(client_id, slice_id)
+ if code == 200:
+ yield f"data: {json.dumps(data)}\n\n"
+ freq = data.get("frequency", 5)
+ await asyncio.sleep(freq)
+ else:
+ yield f"event: error\ndata: {json.dumps(data)}\n\n"
+ break
+ except Exception as exc:
+ yield f"event: error\ndata: {json.dumps({'error': str(exc)})}\n\n"
+ break
+
+
diff --git a/src/api/handlers/tfs_handler.py b/src/api/handlers/tfs_handler.py
new file mode 100644
index 0000000000000000000000000000000000000000..520a206d246adf5d8f5eb857921bfe0d9b4d184f
--- /dev/null
+++ b/src/api/handlers/tfs_handler.py
@@ -0,0 +1,29 @@
+# Copyright 2022-2026 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.
+
+"""TFS (TeraFlowSDN) slice API service handler."""
+
+from __future__ import annotations
+
+import logging
+
+from src.api.handlers.base_handler import BaseSliceHandler
+
+logger = logging.getLogger(__name__)
+
+
+class TfsHandler(BaseSliceHandler):
+ """API handler dedicated to TFS (TeraFlowSDN) transport network slice operations."""
diff --git a/src/api/main.py b/src/api/main.py
index d922369ae8fcd900dc1b97349f18d453a99f5d82..caf112882947ea0ed1ab6776f79d20ba802f127e 100644
--- a/src/api/main.py
+++ b/src/api/main.py
@@ -1,11 +1,11 @@
# Copyright 2022-2026 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
-
+#
+# 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.
@@ -14,1233 +14,138 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
-from src.utils.send_response import send_response
-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
-from src.database.telemetry_client_db import create_client, get_client, get_all_clients, delete_client, delete_all_clients, upsert_subscription, get_subscription, get_client_subscriptions, delete_subscription, delete_all_subscriptions
-import src.database.alert_db as alert_db
+"""Network Slice Controller REST and RESTCONF API facade and module exports."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from src.api.handlers.base_handler import (
+ BaseSliceHandler,
+ _delete_slice_from_tfs,
+ _extract_slice_type,
+)
+from src.api.handlers.e2e_handler import (
+ E2EHandler,
+ _find_slice_for_alert,
+ _load_fallback_intent,
+ _lookup_slice_info,
+ _parse_alert_notification,
+ _resolve_slice_id,
+ _swap_p2mp_endpoints,
+)
+from src.api.handlers.ixia_handler import IxiaHandler
+from src.api.handlers.restconf_handler import (
+ RestconfHandler,
+ _build_full_slice_intent,
+)
+from src.api.handlers.tfs_handler import TfsHandler
+from src.database import alert_db
+from src.database.db import (
+ delete_all_data,
+ delete_data,
+ get_all_data,
+ get_data,
+ get_slice_id_by_subscription,
+)
+from src.database.service_db import (
+ delete_by_slice_id,
+ get_data_by_slice_id,
+)
+from src.database.service_db import (
+ get_data as get_service_db_data,
+)
+from src.database.sysrepo_store import (
+ create_data_store,
+ delete_data_store,
+ get_data_store,
+ normalize_libyang_data,
+ update_data_store,
+)
+from src.database.telemetry_client_db import (
+ create_client,
+ delete_all_clients,
+ delete_all_subscriptions,
+ delete_client,
+ delete_subscription,
+ get_all_clients,
+ get_client,
+ get_client_subscriptions,
+ get_subscription,
+ upsert_subscription,
+)
+from src.realizer.restconf.connectors.tfs_connector import (
+ tfs_connector as tfs_restconf_connector,
+)
from src.realizer.tfs.helpers.tfs_connector import tfs_connector
-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.realizer.tfs.service_types.tfs_l2vpn import tfs_l2vpn_delete
+from src.utils.safe_get import safe_get
+from src.utils.send_response import send_response
+# Public alias exports for backwards compatibility
+parse_alert_notification = _parse_alert_notification
+find_slice_for_alert = _find_slice_for_alert
+swap_p2mp_endpoints = _swap_p2mp_endpoints
-class Api:
-
- def __init__(self, slice_service):
- self.slice_service = slice_service
-
- def add_flow(self, intent):
- """
- Create a new transport network slice.
-
- Args:
- intent (dict): Network slice intent in 3GPP or IETF format
-
- Returns:
- Result of the Network Slice Controller (NSC) operation
-
- API Endpoint:
- POST /slice
-
- Raises:
- RuntimeError: If there is no content to process
- Exception: For unexpected errors
- """
- try:
- result = self.slice_service.nsc(intent)
- if not result:
- return send_response(False, code=404, message="No intents found")
- if isinstance(result, tuple):
- return result
- logging.info(f"Slice created successfully")
- return send_response(
- True,
- code=201,
- data=result
- )
- except RuntimeError as e:
- # Handle case where there is no content to process
- return send_response(False, code=200, message=str(e))
- except Exception as e:
- # Handle unexpected errors
- return send_response(False, code=500, message=str(e))
-
- def receive_alert(self, alert_data):
- """
- Receive and process an alert.
-
- Args:
- alert_data (dict): The alert payload
-
- Returns:
- Result of the operation
- """
- try:
- logging.info(f"Alert received: {alert_data}")
- # Extract uuid if exists
- context = alert_data.get("tapi-notification:notification-context", [])
- alert_id = None
- service_id = None
- subscription_id = None
- if context and isinstance(context, list):
- notification = context[0].get("tapi-notification:notification", {})
- alert_id = notification.get("uuid")
- subscription_id = alert_id
- additional_info = notification.get("additional-info", {})
- service_id = additional_info.get("service-id")
-
- if not alert_id:
- return send_response(False, code=400, message="UUID not found in alert data")
-
- # Save alert to DB
- alert_db.save_alert(alert_id, alert_data)
-
- # Process intent modification based on alert
- slice_id = None
- slice_info = None
-
- logging.info(f"Looking up intent for subscription_id: {subscription_id}, service_id: {service_id}")
-
- if subscription_id:
- try:
- import src.database.db as db
- mapped_slice_id = db.get_slice_id_by_subscription(subscription_id)
- if mapped_slice_id:
- slice_id = mapped_slice_id
- logging.info(f"Found slice_id {slice_id} mapped to subscription_id {subscription_id}")
- except Exception as e:
- logging.info(f"Subscription mapping lookup failed: {e}")
-
- if not slice_id and service_id:
- # 1. Try to find slice_id from service_db
- try:
- import src.database.service_db as service_db
- service_info = service_db.get_data(service_id)
- slice_id = service_info.get("slice_id")
- logging.info(f"Found slice_id {slice_id} in service_db for service_id {service_id}")
- except Exception as e:
- logging.info(f"service_db lookup failed: {e}")
- slice_id = service_id
-
- if slice_id:
- # 2. Try to get slice data from db
- try:
- import src.database.db as db
- slice_info = db.get_data(slice_id)
- logging.info(f"Found slice_info in db by slice_id {slice_id}")
- except Exception as e:
- logging.info(f"db lookup by slice_id {slice_id} failed: {e}")
-
- # Fallback: if not found by ID, look up any existing slice in the DB
- if not slice_info:
- try:
- import src.database.db as db
- slices = db.get_all_data()
- logging.info(f"Slices in db: {[s.get('slice_id') for s in slices]}")
- for s in slices:
- if s.get("slice_id") == slice_id:
- slice_info = s
- break
- if not slice_info and slices:
- # Default to the first/only slice if there's any
- slice_info = slices[0]
- logging.info(f"Defaulted to first slice from db: {slice_info.get('slice_id')}")
- except Exception as e:
- logging.info(f"db get_all_data lookup failed: {e}")
-
- # Fallback: read from /home/llmserver/tfs-nsc/intent.json if DB is empty
- if not slice_info:
- import os
- import json
- fallback_path = "/home/llmserver/tfs-nsc/intent.json"
- if os.path.exists(fallback_path):
- try:
- with open(fallback_path, "r") as f:
- intent_data = json.load(f)
- slice_info = {"slice_id": slice_id or "slice", "intent": intent_data}
- logging.info("Loaded fallback intent from intent.json")
- except Exception as e:
- logging.error(f"Failed to read fallback intent.json: {e}")
-
- if slice_info:
- intent = slice_info.get("intent")
- curr_slice_id = slice_info.get("slice_id")
- logging.info(f"Processing intent for slice {curr_slice_id}")
-
- if intent:
- nss = intent.get("ietf-network-slice-service:network-slice-services", {})
- slice_services = nss.get("slice-service", [])
- modified = False
-
- for service in slice_services:
- # Get all SDP IDs in this slice service
- sdp_list = service.get("sdps", {}).get("sdp", [])
- sdp_ids = [sdp.get("id") for sdp in sdp_list if sdp.get("id")]
-
- connection_groups = service.get("connection-groups", {}).get("connection-group", [])
- for cg in connection_groups:
- connectivity_constructs = cg.get("connectivity-construct", [])
- for cc in connectivity_constructs:
- p2mp_sender = cc.get("p2mp-sender-sdp")
- p2mp_receivers = cc.get("p2mp-receiver-sdp", [])
-
- logging.info(f"sdp_ids: {sdp_ids}, p2mp_sender: {p2mp_sender}, p2mp_receivers: {p2mp_receivers}")
-
- # Find alternative receiver endpoints
- other_endpoints = [
- sdp_id for sdp_id in sdp_ids
- if sdp_id != p2mp_sender and sdp_id not in p2mp_receivers
- ]
-
- old_receiver = None
- new_receiver = None
-
- if other_endpoints and p2mp_receivers:
- if len(p2mp_receivers) >= 2:
- old_receiver = p2mp_receivers[1]
- new_receiver = other_endpoints[0]
- cc["p2mp-receiver-sdp"] = [p2mp_receivers[0], new_receiver]
- else:
- old_receiver = p2mp_receivers[0]
- new_receiver = other_endpoints[0]
- cc["p2mp-receiver-sdp"] = [new_receiver]
- modified = True
-
- # Log ORIGEN and DESTINO
- logging.info(f"ORIGEN: {p2mp_sender} DESTINO: {new_receiver}")
- print(f"ORIGEN: {p2mp_sender} DESTINO: {new_receiver}", flush=True)
- else:
- logging.warning("No alternative receiver endpoints found to swap.")
-
- if p2mp_receivers:
- old_service_id = f"{p2mp_sender}_to_{','.join(p2mp_receivers)}"
- else:
- old_service_id = None
-
- if modified:
- # Re-apply the modified intent via slice_service.nsc
- try:
- self.slice_service.nsc(intent, curr_slice_id, old_service_id=old_service_id)
- logging.info(f"Slice {curr_slice_id} updated successfully following alert.")
- except Exception as e:
- logging.error(f"Failed to update slice configuration: {e}")
- else:
- logging.warning("No slice intent found to process alert.")
-
- return send_response(
- True,
- code=201,
- message="Alert processed and saved successfully",
- data=alert_data
- )
- except Exception as e:
- return send_response(False, code=500, message=str(e))
-
- def get_alerts(self, alert_id=None):
- """
- Retrieve alert(s).
- """
- try:
- if alert_id:
- try:
- data = alert_db.get_alert(alert_id)
- return data, 200
- except ValueError as e:
- return send_response(False, code=404, message=str(e))
- else:
- data = alert_db.get_all_alerts()
- return data, 200
- except Exception as e:
- return send_response(False, code=500, message=str(e))
-
- def modify_alert(self, alert_id, alert_data):
- """
- Modify/update an alert.
- """
- try:
- try:
- alert_db.update_alert(alert_id, alert_data)
- return send_response(
- True,
- code=200,
- message="Alert updated successfully",
- data=alert_data
- )
- except ValueError as e:
- return send_response(False, code=404, message=str(e))
- except Exception as e:
- return send_response(False, code=500, message=str(e))
-
- def delete_alerts(self, alert_id=None):
- """
- Delete alert(s).
- """
- try:
- if alert_id:
- try:
- alert_db.delete_alert(alert_id)
- return {}, 204
- except ValueError as e:
- return send_response(False, code=404, message=str(e))
- else:
- alert_db.delete_all_alerts()
- return {}, 204
- except Exception as e:
- return send_response(False, code=500, message=str(e))
-
- def get_flows(self,slice_id=None):
- """
- Retrieve transport network slice information.
-
- This method allows retrieving:
- - All transport network slices
- - A specific slice by its ID
-
- Args:
- slice_id (str, optional): Unique identifier of a specific slice.
- Defaults to None.
-
- Returns:
- dict or list:
- - If slice_id is provided: Returns the specific slice details
- - If slice_id is None: Returns a list of all slices
- - Returns an error response if no slices are found
-
- API Endpoint:
- GET /slice/{id}
-
- Raises:
- ValueError: If no transport network slices are found
- Exception: For unexpected errors
- """
- try:
- # Read slice database from JSON file
- content = get_all_data()
- # If specific slice ID is provided, find and return matching slice
- if slice_id:
- for slice in content:
- if slice["slice_id"] == slice_id:
- return slice, 200
- raise ValueError("Transport network slices not found")
- # If no slices exist, raise an error
- if len(content) == 0:
- raise ValueError("Transport network slices not found")
-
- # Return all slices if no specific ID is given
- return [slice for slice in content if slice.get("controller") == self.slice_service.controller_type], 200
-
- except ValueError as e:
- # Handle case where no slices are found
- return send_response(False, code=404, message=str(e))
- except Exception as e:
- # Handle unexpected errors
- return send_response(False, code=500, message=str(e))
-
- def modify_flow(self,slice_id, intent):
- """
- Modify an existing transport network slice.
-
- Args:
- slice_id (str): Unique identifier of the slice to modify
- intent (dict): New intent configuration for the slice
+class Api(E2EHandler, TfsHandler, IxiaHandler, RestconfHandler):
+ """Unified Facade API handler combining E2E, TFS, IXIA, and RESTCONF operations."""
- Returns:
- Result of the Network Slice Controller (NSC) operation
-
- API Endpoint:
- PUT /slice/{id}
- Raises:
- Exception: For unexpected errors
- """
- try:
- result = self.slice_service.nsc(intent, slice_id)
- if isinstance(result, tuple):
- return result
- if not result:
- return send_response(False, code=404, message="Slice not found")
- logging.info(f"Slice {slice_id} modified successfully")
- return send_response(
- True,
- code=200,
- message="Slice modified successfully",
- data=result
- )
- except ValueError as e:
- # Handle case where no slices are found
- return send_response(False, code=404, message=str(e))
- except Exception as e:
- # Handle unexpected errors
- return send_response(False, code=500, message=str(e))
-
- def delete_flows(self, slice_id=None):
- """
- Delete transport network slice(s).
-
- This method supports:
- - Deleting a specific slice by ID
- - Deleting all slices
- - Optional cleanup of L2VPN configurations
+ def __init__(self, slice_service: Any) -> None:
+ """Initialize API facade with underlying slice service.
Args:
- slice_id (str, optional): Unique identifier of slice to delete.
- Defaults to None.
-
- Returns:
- dict: {} indicating successful deletion or error details
-
- API Endpoint:
- DELETE /slice/{id}
-
- Raises:
- ValueError: If no slices are found to delete
- Exception: For unexpected errors
-
- Notes:
- - If controller_type is TFS, attempts to delete from Teraflow
- - If need_l2vpn_support is True, performs additional L2VPN cleanup
- """
- try:
- # Delete specific slice if slice_id is provided
- if slice_id:
- slice = get_data(slice_id)
- # Raise error if slice not found
- if not slice or slice.get("controller") != self.slice_service.controller_type:
- raise ValueError("Transport network slice not found")
- # Delete in Teraflow
- if not current_app.config["DUMMY_MODE"]:
- if self.slice_service.controller_type == "TFS":
- slice_type = safe_get(slice, ['intent', 'ietf-network-slice-service:network-slice-services', 'slice-service', 0, 'service-tags', 'tag-type', 0, 'tag-type-value', 0])
- if not slice_type:
- slice_type = "L2"
- logging.warning(f"Slice type not found in slice intent. Defaulting to L2")
- tfs_connector().nbi_delete(current_app.config["TFS_IP"],slice_type, slice_id)
- # Update slice database
- delete_data(slice_id)
- logging.info(f"Slice {slice_id} removed successfully")
- return {}, 204
-
- # Delete all slices
- else:
- # Optional: Delete in Teraflow if configured
- if not current_app.config["DUMMY_MODE"]:
- if self.slice_service.controller_type == "TFS":
- content = get_all_data()
- for slice in content:
- if slice.get("controller") == self.slice_service.controller_type:
- slice_type = safe_get(slice, ['intent', 'ietf-network-slice-service:network-slice-services', 'slice-service', 0, 'service-tags', 'tag-type', 0, 'tag-type-value', 0])
- if not slice_type:
- slice_type = "L2"
- logging.warning(f"Slice type not found in slice intent. Defaulting to L2")
- tfs_connector().nbi_delete(current_app.config["TFS_IP"],slice_type, slice.get("slice_id"))
- if current_app.config["TFS_L2VPN_SUPPORT"]:
- tfs_l2vpn_delete()
-
- # Clear slice database
- delete_all_data()
-
- logging.info("All slices removed successfully")
- return {}, 204
-
- except ValueError as e:
- return send_response(False, code=404, message=str(e))
- except Exception as e:
- return send_response(False, code=500, message=str(e))
-
- # RESCONF calls
-
- ### GET
-
- def get_network_slice_services(self):
- try:
- data = get_data_store("/ietf-network-slice-service:network-slice-services")
- if not data:
- raise ValueError("Nothing found")
- return data, 200
- except ValueError as e:
- return send_response(False, code=404, message=str(e))
- except Exception as e:
- return send_response(False, code=500, message=str(e))
-
- def get_slo_sle_templates(self, template_id=None):
- try:
- if template_id:
- xpath = f"/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template[id='{template_id}']"
- data = get_data_store(xpath)
- if not data:
- raise ValueError("Template not found")
- return data, 200
-
- data = get_data_store("/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template")
- if not data:
- raise ValueError("No templates found")
-
- return data, 200
-
- except ValueError as e:
- # Handle case where no slices are found
- return send_response(False, code=404, message=str(e))
- except Exception as e:
- # Handle unexpected errors
- return send_response(False, code=500, message=str(e))
-
- def get_slice_services(self, slice_id=None):
- try:
- if slice_id:
- data = get_data_store(f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']")
- if not data:
- raise ValueError("Slice not found")
- return data, 200
-
- data = get_data_store("/ietf-network-slice-service:network-slice-services/slice-service")
- if not data:
- raise ValueError("No slices found")
-
- return data, 200
-
- except ValueError as e:
- # Handle case where no slices are found
- return send_response(False, code=404, message=str(e))
- except Exception as e:
- # Handle unexpected errors
- return send_response(False, code=500, message=str(e))
-
- def 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:
- data = get_data_store(f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']/sdps/sdp[id='{sdp_id}']")
- if not data:
- raise ValueError("SDP not found")
- return data, 200
-
- data = get_data_store(f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']/sdps")
- if not data:
- raise ValueError("No SDPs found")
-
- return data, 200
-
- except ValueError as e:
- # Handle case where no slices are found
- return send_response(False, code=404, message=str(e))
- except Exception as e:
- # Handle unexpected errors
- return send_response(False, code=500, message=str(e))
-
- ### POST
-
- def add_network_slice_service(self, intent):
- try:
- result = self.slice_service.nsc(intent)
- if isinstance(result, tuple):
- return result
- if result:
- try:
- create_data_store(intent)
- except Exception as ds_err:
- logging.warning(f"Could not store intent in sysrepo datastore: {ds_err}")
- logging.info(f"Network Slice created successfully")
- return send_response(
- True,
- code=201,
- message="Network Slice created successfully",
- data=result
- )
- except RuntimeError as e:
- # Handle case where there is no content to process
- return send_response(False, code=200, message=str(e))
- except Exception as e:
- # Handle unexpected errors
- return send_response(False, code=500, message=str(e))
-
- def add_slo_sle_template(self, template):
- try:
- template_id = template.pop("id", None)
- xpath = f"/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template[id='{template_id}']"
- existing_template = get_data_store(xpath)
- if existing_template:
- return send_response(False, code=409, message="Template already exists")
- create_data_store(template, xpath)
- logging.info(f"Template created successfully")
- return send_response(
- True,
- code=201,
- message="Template created successfully"
- )
- except Exception as e:
- # Handle unexpected errors
- return send_response(False, code=500, message=str(e))
-
- def add_slice_service(self, intent):
- try:
- xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{intent.get('id')}']"
- existing_slice = get_data_store(xpath)
- if existing_slice:
- return send_response(False, code=409, message="Slice already exists")
-
- if "slo-sle-template" in intent:
- template_ref = intent.get("slo-sle-template")
- xpath_template = f"/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template[id='{template_ref}']"
- existing_template = get_data_store(xpath_template)
- if not existing_template:
- return send_response(False, code=404, message="Referenced SLO/SLE template not found")
- full_intent = {
- "ietf-network-slice-service:network-slice-services": {
- "slo-sle-templates": {
- "slo-sle-template": [existing_template["network-slice-services"]["slo-sle-templates"]["slo-sle-template"][template_ref]]
- },
- "slice-service": [intent]
- }
- }
- elif "service-slo-sle-policy" in intent:
- full_intent = {
- "ietf-network-slice-service:network-slice-services": {
- "slo-sle-templates": {
- "slo-sle-template": intent.get("service-slo-sle-policy")
- },
- "slice-service": [intent]
- }
- }
- else:
- return send_response(False, code=400, message="No SLO/SLE template or policy provided in intent")
-
- full_intent = normalize_libyang_data(full_intent)
- result = self.slice_service.nsc(full_intent)
- if result:
- intent.pop("id", None)
- create_data_store(intent, xpath)
- logging.info(f"Slice created successfully")
- return send_response(
- True,
- code=201,
- message="Slice created successfully",
- data=result
- )
- except RuntimeError as e:
- # Handle case where there is no content to process
- return send_response(False, code=200, message=str(e))
- except Exception as e:
- # Handle unexpected errors
- return send_response(False, code=500, message=str(e))
-
- def add_sdp(self, slice_id, sdp):
- try:
- sdp_id = sdp.pop("id", None)
- xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']/sdps/sdp[id='{sdp_id}']"
- existing_sdp = get_data_store(xpath)
- if existing_sdp:
- return send_response(False, code=409, message="SDP already exists")
- create_data_store(sdp, xpath)
- logging.info(f"SDP created successfully")
- return send_response(
- True,
- code=201,
- message="SDP created successfully"
- )
- except Exception as e:
- # Handle unexpected errors
- return send_response(False, code=500, message=str(e))
-
- ### PUT
-
- def update_network_slice_service(self, intent):
- """
- Modify (replace) all network-slice-services configuration
- """
- try:
- xpath = "/ietf-network-slice-service:network-slice-services"
-
- # Verify if there is something to modify
- existing_data = get_data_store(xpath)
- if not existing_data:
- return send_response(False, code=404, message="Network slice services not found")
-
- # If not in DUMMY mode, process with TFS
- result = self.slice_service.nsc(intent)
- if not result:
- return send_response(False, code=500, message="Failed to process slice in TFS")
-
- # Replace completely the resource
- update_data_store(intent)
- logging.info("Network slice services modified successfully")
-
- return send_response(
- True,
- code=200,
- message="Network slice services updated successfully",
- data=result
- )
-
- except ValueError as e:
- return send_response(False, code=404, message=str(e))
- except Exception as e:
- return send_response(False, code=500, message=str(e))
-
- def update_slo_sle_template(self, template_id, template):
- """
- Modify (replace) an specific SLO/SLE template
- """
- try:
- xpath = f"/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template[id='{template_id}']"
-
- # Verify the template exists
- existing_template = get_data_store(xpath)
- if not existing_template:
- return send_response(False, code=404, message="Template not found")
-
- # Assure that the body ID matches the URL
- if "id" in template and template["id"] != template_id:
- return send_response(False, code=400, message="Template ID in body does not match URL")
-
- slices = get_data_store("/ietf-network-slice-service:network-slice-services/slice-service")
-
- for slice in slices["network-slice-services"]["slice-service"]:
- if "slo-sle-template" in slice:
- if slice.get("slo-sle-template") == template_id:
- full_intent = {
- "ietf-network-slice-service:network-slice-services": {
- "slo-sle-templates": {
- "slo-sle-template": [existing_template["network-slice-services"]["slo-sle-templates"]["slo-sle-template"][template_id]]
- },
- "slice-service": [slice]
- }
- }
- full_intent = normalize_libyang_data(full_intent)
- result = self.slice_service.nsc(full_intent, slice.get("id"))
- if not result:
- return send_response(False, code=500, message="Slice not updated")
-
- # Remove the ID from the body if it exists (it's already in the predicate)
- template_data = template.copy()
- template_data.pop("id", None)
-
- # Replace the template
- update_data_store(template_data, xpath)
- logging.info(f"Template {template_id} modified successfully")
-
- return send_response(
- True,
- code=200,
- message="Template updated successfully",
- data=result
- )
-
- except ValueError as e:
- return send_response(False, code=404, message=str(e))
- except Exception as e:
- return send_response(False, code=500, message=str(e))
-
- def update_slice_service(self, slice_id, intent):
- """
- Modifica (reemplaza) un slice service específico
- """
- try:
- xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']"
-
- # Verify that the slice exists
- existing_slice = get_data_store(xpath)
- if not existing_slice:
- return send_response(False, code=404, message="Slice not found")
-
- # Assure that the body ID matches the URL
- if "id" in intent and intent["id"] != slice_id:
- return send_response(False, code=400, message="Slice ID in body does not match URL")
-
- # Validate that the referenced SLO/SLE template exists
- if "slo-sle-template" in intent:
- template_ref = intent.get("slo-sle-template")
- xpath_template = f"/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template[id='{template_ref}']"
- existing_template = get_data_store(xpath_template)
- if not existing_template:
- return send_response(False, code=404, message="Referenced SLO/SLE template not found")
-
- # Build the full intent
- full_intent = {
- "ietf-network-slice-service:network-slice-services": {
- "slo-sle-templates": {
- "slo-sle-template": [existing_template["network-slice-services"]["slo-sle-templates"]["slo-sle-template"][template_ref]]
- },
- "slice-service": [intent]
- }
- }
- elif "service-slo-sle-policy" in intent:
- full_intent = {
- "ietf-network-slice-service:network-slice-services": {
- "slo-sle-templates": {
- "slo-sle-template": intent.get("service-slo-sle-policy")
- },
- "slice-service": [intent]
- }
- }
- else:
- return send_response(False, code=400, message="No SLO/SLE template or policy provided in intent")
-
- full_intent = normalize_libyang_data(full_intent)
- result = self.slice_service.nsc(full_intent)
- if not result:
- return send_response(False, code=500, message="Slice not updated")
-
- # Remove the ID from the body (it's already in the predicate)
- intent_data = intent.copy()
- intent_data.pop("id", None)
-
- # Replace the slice
- update_data_store(intent_data, xpath)
- logging.info(f"Slice {slice_id} modified successfully")
-
- return send_response(
- True,
- code=200,
- message="Slice updated successfully",
- data=result
- )
-
- except ValueError as e:
- return send_response(False, code=404, message=str(e))
- except RuntimeError as e:
- return send_response(False, code=200, message=str(e))
- except Exception as e:
- return send_response(False, code=500, message=str(e))
-
- def update_sdp(self, slice_id, sdp_id, sdp):
- """
- Modify (replace) an specific SDP in the slice
- """
- try:
- # Verify the template exists
- slice_xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']"
- existing_slice = get_data_store(slice_xpath)
- if not existing_slice:
- return send_response(False, code=404, message="Slice not found")
-
- # Verify the SDP exists
- sdp_xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']/sdps/sdp[id='{sdp_id}']"
- existing_sdp = get_data_store(sdp_xpath)
- if not existing_sdp:
- return send_response(False, code=404, message="SDP not found")
-
- # Assure that the body ID matches the URL
- if "id" in sdp and sdp["id"] != sdp_id:
- return send_response(False, code=400, message="SDP ID in body does not match URL")
-
- # Remove the ID from the body (it's already in the predicate)
- sdp_data = sdp.copy()
- sdp_data.pop("id", None)
-
- # Replace the SDP
- update_data_store(sdp_data, sdp_xpath)
- logging.info(f"SDP {sdp_id} in slice {slice_id} modified successfully")
-
- return send_response(
- True,
- code=200,
- message="SDP updated successfully"
- )
-
- except ValueError as e:
- return send_response(False, code=404, message=str(e))
- except Exception as e:
- return send_response(False, code=500, message=str(e))
-
- ### DELETE
-
- def delete_network_slice_services(self):
- try:
- xpath = f"/ietf-network-slice-service:network-slice-services"
- if not current_app.config["DUMMY_MODE"]:
- content = get_data_store(xpath)
- slice_services = safe_get(content, ["network-slice-services", "slice-service"])
- if not slice_services:
- raise ValueError("Network slice services not found")
- for slice in slice_services:
- slice_type = list(slice["service-tags"]["tag-type"]["ietf-network-slice-service:service"]["tag-type-value"])[0]
- if not slice_type:
- slice_type = "L2"
- logging.warning(f"Slice type not found in slice intent. Defaulting to L2")
- logging.debug(f"Send slice to delete in TFS with slice_type {slice_type}")
- services = get_data_by_slice_id(slice.get("id"))
- for service in services:
- id = service.get("service_id")
- tfs_connector().nbi_delete(current_app.config["RESTCONF_IP"], slice_type, id)
- delete_by_slice_id(slice.get("id"))
- if current_app.config["TFS_L2VPN_SUPPORT"]:
- self.slice_service.tfs_l2vpn_delete()
-
- delete_data_store(xpath)
- logging.info("All slices removed successfully")
-
- return {}, 204
-
- except ValueError as e:
- return send_response(False, code=404, message=str(e))
- except Exception as e:
- return send_response(False, code=500, message=str(e))
-
- def delete_slo_sle_templates(self, template_id=None):
- try:
- # Delete specific template if template_id is provided
- if template_id:
- xpath = f"/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template[id='{template_id}']"
- existing_template = get_data_store(xpath)
- if not existing_template:
- raise ValueError("Template not found")
- delete_data_store(xpath)
- logging.info(f"Template {template_id} removed successfully")
- return {}, 204
-
- # Delete all templates
- else:
- xpath = f"/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template"
- delete_data_store(xpath)
- logging.info("All templates removed successfully")
- return {}, 204
-
- except ValueError as e:
- return send_response(False, code=404, message=str(e))
- except Exception as e:
- return send_response(False, code=500, message=str(e))
-
- def delete_slice_services(self, slice_id=None):
- try:
- # Delete specific slice if slice_id is provided
- if slice_id:
- xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']"
- existing_slice = get_data_store(xpath)
- if not existing_slice:
- raise ValueError("Slice not found")
- if not current_app.config["DUMMY_MODE"]:
- slice_type = safe_get(existing_slice, ["network-slice-services", "slice-service", slice_id, "service-tags", "tag-type", "ietf-network-slice-service:service", "tag-type-value", 0]) or "L2"
- logging.debug(f"Send slice to delete in TFS with slice_type {slice_type}")
- services = get_data_by_slice_id(slice_id)
- for service in services:
- id = service.get("service_id")
- tfs_connector().nbi_delete(current_app.config["RESTCONF_IP"], slice_type, id)
- delete_by_slice_id(slice_id)
- if current_app.config["TFS_L2VPN_SUPPORT"]:
- tfs_l2vpn_delete()
-
- delete_data_store(xpath)
- logging.info(f"Slice {slice_id} removed successfully")
- return {}, 204
-
- # Delete all slices
- else:
- xpath = f"/ietf-network-slice-service:network-slice-services/slice-service"
- if not current_app.config["DUMMY_MODE"]:
- content = get_data_store(xpath)
- slice_services = safe_get(content, ["network-slice-services", "slice-service"])
- if not slice_services:
- raise ValueError("Slice services not found")
- for slice in slice_services:
- slice_type = safe_get(slice, ["service-tags", "tag-type", "ietf-network-slice-service:service", "tag-type-value", 0]) or "L2"
- logging.debug(f"Send slice to delete in TFS with slice_type {slice_type}")
- services = get_data_by_slice_id(slice.get("id"))
- for service in services:
- id = service.get("service_id")
- tfs_connector().nbi_delete(current_app.config["RESTCONF_IP"], slice_type, id)
- delete_by_slice_id(slice.get("id"))
- if current_app.config["TFS_L2VPN_SUPPORT"]:
- tfs_l2vpn_delete()
- delete_data_store(xpath)
- logging.info("All slices removed successfully")
- return {}, 204
-
- except ValueError as e:
- return send_response(False, code=404, message=str(e))
- except Exception as e:
- return send_response(False, code=500, message=str(e))
-
- def delete_sdps(self, slice_id, sdp_id=None):
- try:
- # Delete specific SDP if sdp_id is provided
- if sdp_id:
- xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']"
- existing_slice = get_data_store(xpath)
- if not existing_slice:
- raise ValueError("Slice not found")
- xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']/sdps/sdp[id='{sdp_id}']"
- existing_sdp = get_data_store(xpath)
- if not existing_sdp:
- raise ValueError("SDP not found")
- delete_data_store(xpath)
- logging.info(f"SDP {sdp_id} removed successfully")
- return {}, 204
-
- # Delete all SDPs
- else:
- xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']"
- existing_slice = get_data_store(xpath)
- if not existing_slice:
- raise ValueError("Slice not found")
- xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']/sdps"
- delete_data_store(xpath)
- logging.info("All SDPs removed successfully")
- return {}, 204
-
- except ValueError as e:
- return send_response(False, code=404, message=str(e))
- except Exception as e:
- return send_response(False, code=500, message=str(e))
-
- # --- CLIENTS ---
-
- def get_clients(self, client_id=None):
- try:
- if client_id:
- return get_client(client_id), 200
- clients = get_all_clients()
- if not clients:
- raise ValueError("No clients found")
- return clients, 200
-
- except ValueError as e:
- return send_response(False, code=404, message=str(e))
- except Exception as e:
- return send_response(False, code=500, message=str(e))
-
- def add_client(self, client_id):
- try:
- create_client(client_id)
- logging.info(f"Client '{client_id}' created successfully")
- return send_response(
- True,
- code=201,
- message=f"Client '{client_id}' created successfully",
- data={"client_id": client_id}
- )
-
- except ValueError as e:
- return send_response(False, code=409, message=str(e))
- except Exception as e:
- return send_response(False, code=500, message=str(e))
-
- def delete_clients(self, client_id=None):
- try:
- if client_id:
- delete_client(client_id)
- logging.info(f"Client '{client_id}' removed successfully")
- else:
- delete_all_clients()
- logging.info("All clients removed successfully")
- return {}, 204
- except ValueError as e:
- return send_response(False, code=404, message=str(e))
- except Exception as e:
- return send_response(False, code=500, message=str(e))
-
- # --- SUBSCRIPTIONS ---
-
- def get_subscriptions(self, client_id, slice_id = None):
- try:
- if slice_id is not None:
- try:
- subscription = get_subscription(client_id, slice_id)
- except ValueError:
- subscription = None
- if not subscription:
- raise ValueError(f"Client '{client_id}' has no subscription for slice '{slice_id}'")
-
- telemetry = self.get_telemetry(slice_id)
-
- return {
- **subscription,
- "telemetry": telemetry
- }, 200
-
- subscriptions = get_client_subscriptions(client_id)
-
- result = []
-
- for sub in subscriptions:
- slice_id = sub["slice_id"]
-
- telemetry = self.get_telemetry(slice_id)
-
- result.append({
- **sub,
- "telemetry": telemetry
- })
-
- return {
- "client_id": client_id,
- "subscriptions": result
- }, 200
-
- except ValueError as e:
- return send_response(False, code=404, message=str(e))
- except Exception as e:
- return send_response(False, code=500, message=str(e))
-
- def add_subscription(self, client_id, slice_id, frequency):
- try:
- try:
- subscription = get_subscription(client_id, slice_id)
- except ValueError:
- subscription = None
- if subscription:
- raise ValueError(f"Client '{client_id}' already has a subscription for slice '{slice_id}'")
- if not frequency:
- raise KeyError("Field 'frequency' is required")
-
- upsert_subscription(client_id, slice_id, frequency)
- logging.info(f"Subscription for slice '{slice_id}' and client '{client_id}' created successfully")
- return send_response(
- True,
- code=201,
- message="Subscription successfully created",
- data={
- "sliceId": slice_id,
- "frequency": frequency
- }
- )
- except KeyError as e:
- return send_response(False, code=400, message=str(e))
- except ValueError as e:
- return send_response(False, code=404, message=str(e))
- except Exception as e:
- return send_response(False, code=500, message=str(e))
-
- def update_subscription(self, client_id, slice_id, frequency):
- try:
- try:
- subscription = get_subscription(client_id, slice_id)
- except ValueError:
- subscription = None
- if not subscription:
- raise ValueError(f"Client '{client_id}' has no subscription for slice '{slice_id}'")
- if not frequency:
- raise KeyError("Field 'frequency' is required")
-
- upsert_subscription(client_id, slice_id, frequency)
- logging.info(f"Subscription for slice '{slice_id}' and client '{client_id}' modified successfully")
- return send_response(
- True,
- code=201,
- message="Subscription successfully modified",
- data={
- "sliceId": slice_id,
- "frequency": frequency
- }
- )
- except KeyError as e:
- return send_response(False, code=400, message=str(e))
- except ValueError as e:
- return send_response(False, code=404, message=str(e))
- except Exception as e:
- return send_response(False, code=500, message=str(e))
-
- def delete_subscriptions(self, client_id, slice_id = None):
- try:
- subscriptions = get_client_subscriptions(client_id)
- if slice_id:
- if slice_id not in subscriptions:
- raise ValueError(f"Client '{client_id}' has no subscription for slice '{slice_id}'")
- delete_subscription(client_id, slice_id)
- logging.info(f"Subscription for slice '{slice_id}' and client '{client_id}' removed successfully")
- return {}, 204
- delete_all_subscriptions(client_id)
- logging.info(f"All subscriptions for client '{client_id}' removed successfully")
- return {}, 204
-
- except ValueError as e:
- return send_response(False, code=404, message=str(e))
- except Exception as e:
- return send_response(False, code=500, message=str(e))
-
- # --- TELEMETRY ---
-
- def get_telemetry(self, slice_id = None):
- logging.debug(f"Getting telemetry for slice_id: {slice_id}")
- try:
- if slice_id is not None:
- xpath = f"/ietf-network-slice-service:network-slice-services/slice-service[id='{slice_id}']"
- existing_slice = get_data_store(xpath)
- if not existing_slice:
- raise ValueError(f"There is no slice with id '{slice_id}' registered")
- template_id = safe_get(existing_slice, ["network-slice-services", "slice-service", slice_id, "slo-sle-template"])
- slo_sle_template = self.get_slo_sle_templates(template_id)[0]
- slo_sle_template = safe_get(slo_sle_template, ["network-slice-services", "slo-sle-templates", "slo-sle-template"])
- 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)
- return metrics, 200
-
- telemetry_data = {}
- slices_data = self.get_slice_services()[0]
- slice_service_list = slices_data["network-slice-services"]["slice-service"]
- if isinstance(slice_service_list, dict):
- slice_service_list = list(slice_service_list.values())
-
- for slice in slice_service_list:
- selected_template_id = slice.get("slo-sle-template")
- slo_sle_template = self.get_slo_sle_templates(selected_template_id)[0]
- slo_sle_template = safe_get(slo_sle_template, ["network-slice-services", "slo-sle-templates", "slo-sle-template"])
- slo_sle_template = next(iter(slo_sle_template), None)
- if not slo_sle_template:
- raise ValueError(f"SLO/SLE template '{selected_template_id}' not found for slice '{slice['id']}'")
- slice_id = slice["id"]
- telemetry_data[slice_id] = self.slice_service.monitoring(slice_id, slo_sle_template)
- return telemetry_data, 200
-
- except ValueError as e:
- return send_response(False, code=404, message=str(e))
- except Exception as e:
- return send_response(False, code=500, message=str(e))
-
- def sync_stream(self, async_gen_func, *args, **kwargs):
- loop = asyncio.new_event_loop()
- asyncio.set_event_loop(loop)
- agen = async_gen_func(*args, **kwargs)
-
- try:
- while True: yield loop.run_until_complete(agen.__anext__())
- except StopAsyncIteration: pass
- finally: loop.close()
-
- async def stream_client_subscriptions(self, client_id):
- while True:
- try:
- data, code = self.get_subscriptions(client_id)
- if code == 200:
- yield f"data: {json.dumps(data)}\n\n"
- subs = data.get("subscriptions", [])
- freq = max([s["frequency"] for s in subs]) if subs else 5
- await asyncio.sleep(freq)
- else:
- yield f"event: error\ndata: {json.dumps(data)}\n\n"
- break
- except Exception as e:
- yield f"event: error\ndata: {json.dumps({'error': str(e)})}\n\n"
- break
-
- async def stream_slice_subscription(self, client_id, slice_id):
- while True:
- try:
- data, code = self.get_subscriptions(client_id, slice_id)
- if code == 200:
- yield f"data: {json.dumps(data)}\n\n"
- freq = data.get("frequency", 5)
- await asyncio.sleep(freq)
- else:
- yield f"event: error\ndata: {json.dumps(data)}\n\n"
- break
- except Exception as e:
- yield f"event: error\ndata: {json.dumps({'error': str(e)})}\n\n"
- break
\ No newline at end of file
+ slice_service: Service instance managing controller-specific business logic.
+ """
+ super().__init__(slice_service)
+
+
+__all__ = [
+ "Api",
+ "BaseSliceHandler",
+ "E2EHandler",
+ "IxiaHandler",
+ "RestconfHandler",
+ "TfsHandler",
+ "_build_full_slice_intent",
+ "_delete_slice_from_tfs",
+ "_extract_slice_type",
+ "_find_slice_for_alert",
+ "_load_fallback_intent",
+ "_lookup_slice_info",
+ "_parse_alert_notification",
+ "_resolve_slice_id",
+ "_swap_p2mp_endpoints",
+ "alert_db",
+ "create_client",
+ "create_data_store",
+ "delete_all_clients",
+ "delete_all_data",
+ "delete_all_subscriptions",
+ "delete_by_slice_id",
+ "delete_client",
+ "delete_data",
+ "delete_data_store",
+ "delete_subscription",
+ "find_slice_for_alert",
+ "get_all_clients",
+ "get_all_data",
+ "get_client",
+ "get_client_subscriptions",
+ "get_data",
+ "get_data_by_slice_id",
+ "get_data_store",
+ "get_service_db_data",
+ "get_slice_id_by_subscription",
+ "get_subscription",
+ "normalize_libyang_data",
+ "parse_alert_notification",
+ "safe_get",
+ "send_response",
+ "swap_p2mp_endpoints",
+ "tfs_connector",
+ "tfs_l2vpn_delete",
+ "tfs_restconf_connector",
+ "update_data_store",
+ "upsert_subscription",
+]
diff --git a/src/config/config.py b/src/config/config.py
index 338f7b743f05c8db241449b4baf6ad582d706e20..37c66176340032c5cbd0ead47e56e889f6a8943b 100644
--- a/src/config/config.py
+++ b/src/config/config.py
@@ -14,15 +14,17 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
+import logging
import os
+from typing import Final
+
from dotenv import load_dotenv
from flask import Flask
-import logging
# Load .env file if present
load_dotenv()
-LOG_LEVELS = {
+LOG_LEVELS: Final[dict[str, int]] = {
"CRITICAL": logging.CRITICAL,
"ERROR": logging.ERROR,
"WARNING": logging.WARNING,
@@ -31,31 +33,43 @@ LOG_LEVELS = {
"NOTSET": logging.NOTSET,
}
-def create_config(app: Flask):
- """Load flags into Flask app.config"""
+
+def _get_bool_env(key: str, default: bool = False) -> bool:
+ """Retrieve an environment variable parsed as boolean."""
+ value = os.getenv(key)
+ if value is None:
+ return default
+ return value.strip().lower() in ("true", "1", "yes")
+
+
+def create_config(app: Flask) -> Flask:
+ """Load configuration flags and environment variables into Flask app.config."""
# Default logging level
- app.config["LOGGING_LEVEL"] = LOG_LEVELS.get(os.getenv("LOGGING_LEVEL", "INFO").upper(),logging.INFO)
+ log_level_name = os.getenv("LOGGING_LEVEL", "INFO").upper()
+ app.config["LOGGING_LEVEL"] = LOG_LEVELS.get(log_level_name, logging.INFO)
# Dump templates
- app.config["DUMP_TEMPLATES"] = os.getenv("DUMP_TEMPLATES", "false").lower() == "true"
+ app.config["DUMP_TEMPLATES"] = _get_bool_env("DUMP_TEMPLATES", default=False)
# Mapper
- app.config["NRP_ENABLED"] = os.getenv("NRP_ENABLED", "false").lower() == "true"
- app.config["PLANNER_ENABLED"] = os.getenv("PLANNER_ENABLED", "false").lower() == "true"
+ app.config["NRP_ENABLED"] = _get_bool_env("NRP_ENABLED", default=False)
+ app.config["PLANNER_ENABLED"] = _get_bool_env("PLANNER_ENABLED", default=False)
app.config["PLANNER_TYPE"] = os.getenv("PLANNER_TYPE", "ENERGY")
- app.config["PCE_EXTERNAL"] = os.getenv("PCE_EXTERNAL", "false").lower() == "true"
+ app.config["PCE_EXTERNAL"] = _get_bool_env("PCE_EXTERNAL", default=False)
app.config["HRAT_IP"] = os.getenv("HRAT_IP", "192.168.1.143")
app.config["E2E_OPTICAL_IP"] = os.getenv("E2E_OPTICAL_IP", "127.0.0.1")
- app.config["SUBSCRIBE_ALERTS"] = os.getenv("SUBSCRIBE_ALERTS", "false").lower() == "true"
+ app.config["SUBSCRIBE_ALERTS"] = _get_bool_env("SUBSCRIBE_ALERTS", default=False)
app.config["SUBSCRIBE_ALERTS_URL"] = os.getenv("SUBSCRIBE_ALERTS_URL", "")
+ app.config["CHANGE_SCHEDULER_IP"] = os.getenv("CHANGE_SCHEDULER_IP", "127.0.0.1")
+ app.config["CHANGE_SCHEDULER_PORT"] = int(os.getenv("CHANGE_SCHEDULER_PORT", "8090"))
# Realizer
- app.config["DUMMY_MODE"] = os.getenv("DUMMY_MODE", "true").lower() == "true"
+ app.config["DUMMY_MODE"] = _get_bool_env("DUMMY_MODE", default=True)
# Teraflow
app.config["TFS_IP"] = os.getenv("TFS_IP", "127.0.0.1")
app.config["UPLOAD_TYPE"] = os.getenv("UPLOAD_TYPE", "WEBUI")
- app.config["TFS_L2VPN_SUPPORT"] = os.getenv("TFS_L2VPN_SUPPORT", "false").lower() == "true"
+ app.config["TFS_L2VPN_SUPPORT"] = _get_bool_env("TFS_L2VPN_SUPPORT", default=False)
# IXIA
app.config["IXIA_IP"] = os.getenv("IXIA_IP", "127.0.0.1")
@@ -64,7 +78,7 @@ def create_config(app: Flask):
app.config["TFS_E2E_IP"] = os.getenv("TFS_E2E_IP", "127.0.0.1")
# WebUI
- app.config["WEBUI_DEPLOY"] = os.getenv("WEBUI_DEPLOY", "false").lower() == "true"
+ app.config["WEBUI_DEPLOY"] = _get_bool_env("WEBUI_DEPLOY", default=False)
# Restconf Controller
app.config["RESTCONF_IP"] = os.getenv("RESTCONF_IP", "127.0.0.1")
@@ -81,3 +95,4 @@ def create_config(app: Flask):
return app
+
diff --git a/src/config/constants.py b/src/config/constants.py
index 7bfeb6900765f68a3f894c297105fbaa1b12eeba..b1b8ee22adbfa85dcfd74c8c4a5066b9302c9457 100644
--- a/src/config/constants.py
+++ b/src/config/constants.py
@@ -14,22 +14,22 @@
# This file includes original contributions from Telefonica Innovación Digital S.L.
-from pathlib import Path
import os
+from pathlib import Path
+from typing import Final
# Default port for NSC deployment
-NSC_PORT = os.getenv("NSC_PORT", "8085")
+NSC_PORT: Final[str] = os.getenv("NSC_PORT", "8085")
# Paths
-BASE_DIR = Path(__file__).resolve().parent.parent.parent
-SRC_PATH = BASE_DIR / "src"
-TEMPLATES_PATH = SRC_PATH / "templates"
-DATABASE_PATH = SRC_PATH / "database"
-CONFIG_PATH = SRC_PATH / "config"
-NBI_L2_PATH = "restconf/data/ietf-l2vpn-svc:l2vpn-svc/vpn-services"
-NBI_L3_PATH = "restconf/data/ietf-l3vpn-svc:l3vpn-svc/vpn-services"
-NBI_IETF_NETWORKS_PATH = "restconf/data/ietf-network:networks"
-NBI_SIMAP_SUSCRIPTION_PATH = "/restconf/operations/subscriptions:establish-subscription"
-
-
-
+BASE_DIR: Final[Path] = Path(__file__).resolve().parent.parent.parent
+SRC_PATH: Final[Path] = BASE_DIR / "src"
+TEMPLATES_PATH: Final[Path] = SRC_PATH / "templates"
+DATABASE_PATH: Final[Path] = SRC_PATH / "database"
+CONFIG_PATH: Final[Path] = SRC_PATH / "config"
+
+# RESTCONF Endpoints
+NBI_L2_PATH: Final[str] = "restconf/data/ietf-l2vpn-svc:l2vpn-svc/vpn-services"
+NBI_L3_PATH: Final[str] = "restconf/data/ietf-l3vpn-svc:l3vpn-svc/vpn-services"
+NBI_IETF_NETWORKS_PATH: Final[str] = "restconf/data/ietf-network:networks"
+NBI_SIMAP_SUSCRIPTION_PATH: Final[str] = "/restconf/operations/subscriptions:establish-subscription"
diff --git a/src/database/alert_db.py b/src/database/alert_db.py
index 31f949d2b731294bb25c4c3ca1757d98a91ae619..989a9595274b54cdae8e6afc2da5cf0c0664c798 100644
--- a/src/database/alert_db.py
+++ b/src/database/alert_db.py
@@ -12,85 +12,86 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-import sqlite3
import json
import logging
+import sqlite3
+from typing import Any
+
+logger = logging.getLogger(__name__)
-DB_NAME = "alert.db"
+DB_NAME: str = "alert.db"
-def init_db():
+
+def init_db() -> None:
"""Initialize the SQLite database for alerts."""
- conn = sqlite3.connect(DB_NAME)
- cursor = conn.cursor()
- cursor.execute("""
- CREATE TABLE IF NOT EXISTS alert (
- alert_id TEXT PRIMARY KEY,
- data TEXT NOT NULL
- )
- """)
- conn.commit()
- conn.close()
-
-def save_alert(alert_id: str, data_dict: dict):
+ with sqlite3.connect(DB_NAME) as conn:
+ cursor = conn.cursor()
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS alert (
+ alert_id TEXT PRIMARY KEY,
+ data TEXT NOT NULL
+ )
+ """)
+ conn.commit()
+
+
+def save_alert(alert_id: str, data_dict: dict[str, Any]) -> None:
"""Save an alert to the database."""
data_str = json.dumps(data_dict)
- conn = sqlite3.connect(DB_NAME)
- cursor = conn.cursor()
try:
- cursor.execute("INSERT OR REPLACE INTO alert (alert_id, data) VALUES (?, ?)", (alert_id, data_str))
- conn.commit()
+ with sqlite3.connect(DB_NAME) as conn:
+ cursor = conn.cursor()
+ cursor.execute("INSERT OR REPLACE INTO alert (alert_id, data) VALUES (?, ?)", (alert_id, data_str))
+ conn.commit()
except sqlite3.Error as e:
raise ValueError(f"Database error: {e}")
- finally:
- conn.close()
-def update_alert(alert_id: str, data_dict: dict):
+
+def update_alert(alert_id: str, data_dict: dict[str, Any]) -> None:
"""Update an existing alert."""
data_str = json.dumps(data_dict)
- conn = sqlite3.connect(DB_NAME)
- cursor = conn.cursor()
- cursor.execute("UPDATE alert SET data = ? WHERE alert_id = ?", (data_str, alert_id))
- if cursor.rowcount == 0:
- conn.close()
- raise ValueError(f"No alert found with ID '{alert_id}' to update.")
- conn.commit()
- conn.close()
-
-def delete_alert(alert_id: str):
+ with sqlite3.connect(DB_NAME) as conn:
+ cursor = conn.cursor()
+ cursor.execute("UPDATE alert SET data = ? WHERE alert_id = ?", (data_str, alert_id))
+ if cursor.rowcount == 0:
+ raise ValueError(f"No alert found with ID '{alert_id}' to update.")
+ conn.commit()
+
+
+def delete_alert(alert_id: str) -> None:
"""Delete a specific alert from the database."""
- conn = sqlite3.connect(DB_NAME)
- cursor = conn.cursor()
- cursor.execute("DELETE FROM alert WHERE alert_id = ?", (alert_id,))
- if cursor.rowcount == 0:
- conn.close()
- raise ValueError(f"No alert found with ID '{alert_id}' to delete.")
- conn.commit()
- conn.close()
-
-def get_alert(alert_id: str) -> dict:
+ with sqlite3.connect(DB_NAME) as conn:
+ cursor = conn.cursor()
+ cursor.execute("DELETE FROM alert WHERE alert_id = ?", (alert_id,))
+ if cursor.rowcount == 0:
+ raise ValueError(f"No alert found with ID '{alert_id}' to delete.")
+ conn.commit()
+
+
+def get_alert(alert_id: str) -> dict[str, Any]:
"""Retrieve a specific alert from the database."""
- conn = sqlite3.connect(DB_NAME)
- cursor = conn.cursor()
- cursor.execute("SELECT data FROM alert WHERE alert_id = ?", (alert_id,))
- row = cursor.fetchone()
- conn.close()
- if row:
- return json.loads(row[0])
- raise ValueError(f"No alert found with ID '{alert_id}'.")
-
-def get_all_alerts() -> list:
+ with sqlite3.connect(DB_NAME) as conn:
+ cursor = conn.cursor()
+ cursor.execute("SELECT data FROM alert WHERE alert_id = ?", (alert_id,))
+ row = cursor.fetchone()
+ if row:
+ return json.loads(row[0])
+ raise ValueError(f"No alert found with ID '{alert_id}'.")
+
+
+def get_all_alerts() -> list[dict[str, Any]]:
"""Retrieve all alerts from the database."""
- conn = sqlite3.connect(DB_NAME)
- cursor = conn.cursor()
- cursor.execute("SELECT data FROM alert")
- rows = cursor.fetchall()
- conn.close()
- return [json.loads(row[0]) for row in rows]
-
-def delete_all_alerts():
+ with sqlite3.connect(DB_NAME) as conn:
+ cursor = conn.cursor()
+ cursor.execute("SELECT data FROM alert")
+ rows = cursor.fetchall()
+ return [json.loads(row[0]) for row in rows]
+
+
+def delete_all_alerts() -> None:
"""Delete all alerts from the database."""
- conn = sqlite3.connect(DB_NAME)
- cursor = conn.cursor()
- cursor.execute("DELETE FROM alert")
- conn.commit()
- conn.close()
+ with sqlite3.connect(DB_NAME) as conn:
+ cursor = conn.cursor()
+ cursor.execute("DELETE FROM alert")
+ conn.commit()
+
diff --git a/src/database/db.py b/src/database/db.py
index 2616e11eb3525d2011ca2f4ef8093b0919e82723..18370a8c4c8e2fe2e7c5e3b15804d9efb62cc517 100644
--- a/src/database/db.py
+++ b/src/database/db.py
@@ -12,147 +12,149 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-import sqlite3, json, logging
+import json
+import logging
+import sqlite3
+from typing import Any
+
+logger = logging.getLogger(__name__)
# Database file
-DB_NAME = "slice.db"
+DB_NAME: str = "slice.db"
+
+
+def init_db() -> None:
+ """Initialize the SQLite database and create the slice and subscription tables if not exist."""
+ with sqlite3.connect(DB_NAME) as conn:
+ cursor = conn.cursor()
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS slice (
+ slice_id TEXT PRIMARY KEY,
+ intent TEXT NOT NULL,
+ controller TEXT NOT NULL
+ )
+ """)
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS subscription (
+ subscription_id TEXT PRIMARY KEY,
+ slice_id TEXT NOT NULL
+ )
+ """)
+ conn.commit()
-# Initialize database and create table
-def init_db():
- """
- Initialize the SQLite database and create the slice table if not exists.
- """
- conn = sqlite3.connect(DB_NAME)
- cursor = conn.cursor()
- cursor.execute("""
- CREATE TABLE IF NOT EXISTS slice (
- slice_id TEXT PRIMARY KEY,
- intent TEXT NOT NULL,
- controller TEXT NOT NULL
- )
- """)
- cursor.execute("""
- CREATE TABLE IF NOT EXISTS subscription (
- subscription_id TEXT PRIMARY KEY,
- slice_id TEXT NOT NULL
- )
- """)
- conn.commit()
- conn.close()
-def save_subscription(subscription_id: str, slice_id: str):
- conn = sqlite3.connect(DB_NAME)
- cursor = conn.cursor()
- try:
- cursor.execute("INSERT OR REPLACE INTO subscription (subscription_id, slice_id) VALUES (?, ?)", (subscription_id, slice_id))
+def save_subscription(subscription_id: str, slice_id: str) -> None:
+ """Save or replace a subscription_id to slice_id mapping."""
+ with sqlite3.connect(DB_NAME) as conn:
+ cursor = conn.cursor()
+ cursor.execute(
+ "INSERT OR REPLACE INTO subscription (subscription_id, slice_id) VALUES (?, ?)",
+ (subscription_id, slice_id),
+ )
conn.commit()
- finally:
- conn.close()
-
-def get_slice_id_by_subscription(subscription_id: str) -> str:
- conn = sqlite3.connect(DB_NAME)
- cursor = conn.cursor()
- cursor.execute("SELECT slice_id FROM subscription WHERE subscription_id = ?", (subscription_id,))
- row = cursor.fetchone()
- conn.close()
- if row:
- return row[0]
- return None
-
-# Save data to the database
-def save_data(slice_id: str, intent_dict: dict, controller: str):
+
+
+def get_slice_id_by_subscription(subscription_id: str) -> str | None:
+ """Retrieve the slice_id associated with a subscription_id."""
+ with sqlite3.connect(DB_NAME) as conn:
+ cursor = conn.cursor()
+ cursor.execute("SELECT slice_id FROM subscription WHERE subscription_id = ?", (subscription_id,))
+ row = cursor.fetchone()
+ return row[0] if row else None
+
+
+def save_data(slice_id: str, intent_dict: dict[str, Any], controller: str) -> None:
"""
Save a new slice entry to the database.
Args:
- slice_id (str): Unique identifier for the slice
- intent_dict (dict): Intent data
- controller (str): Controller type
-
+ slice_id (str): Unique identifier for the slice.
+ intent_dict (dict[str, Any]): Intent data.
+ controller (str): Controller type.
+
Raises:
- ValueError: If a slice with the given slice_id already exists
+ ValueError: If a slice with the given slice_id already exists.
"""
intent_str = json.dumps(intent_dict)
- conn = sqlite3.connect(DB_NAME)
- cursor = conn.cursor()
try:
- cursor.execute("INSERT INTO slice (slice_id, intent, controller) VALUES (?, ?, ?)", (slice_id, intent_str, controller))
- conn.commit()
- # Handle duplicate slice ID
+ with sqlite3.connect(DB_NAME) as conn:
+ cursor = conn.cursor()
+ cursor.execute(
+ "INSERT INTO slice (slice_id, intent, controller) VALUES (?, ?, ?)",
+ (slice_id, intent_str, controller),
+ )
+ conn.commit()
except sqlite3.IntegrityError:
raise ValueError(f"Slice with id '{slice_id}' already exists.")
- finally:
- conn.close()
-# Update data in the database
-def update_data(slice_id: str, new_intent_dict: dict, controller: str):
+
+def update_data(slice_id: str, new_intent_dict: dict[str, Any], controller: str) -> None:
"""
Update an existing slice entry in the database.
Args:
- slice_id (str): Unique identifier for the slice
- new_intent_dict (dict): New intent data
- controller (str): Controller type
-
+ slice_id (str): Unique identifier for the slice.
+ new_intent_dict (dict[str, Any]): New intent data.
+ controller (str): Controller type.
+
Raises:
- ValueError: If no slice is found with the given slice_id
+ ValueError: If no slice is found with the given slice_id.
"""
intent_str = json.dumps(new_intent_dict)
- conn = sqlite3.connect(DB_NAME)
- cursor = conn.cursor()
- cursor.execute("UPDATE slice SET intent = ?, controller = ? WHERE slice_id = ?", (intent_str, controller, slice_id))
- if cursor.rowcount == 0:
- raise ValueError(f"No slice found with id '{slice_id}' to update.")
- else:
- logging.debug(f"Slice '{slice_id}' updated.")
- conn.commit()
- conn.close()
-
-# Delete data from the database
-def delete_data(slice_id: str):
+ with sqlite3.connect(DB_NAME) as conn:
+ cursor = conn.cursor()
+ cursor.execute(
+ "UPDATE slice SET intent = ?, controller = ? WHERE slice_id = ?",
+ (intent_str, controller, slice_id),
+ )
+ if cursor.rowcount == 0:
+ raise ValueError(f"No slice found with id '{slice_id}' to update.")
+ conn.commit()
+ logger.debug("Slice '%s' updated.", slice_id)
+
+
+def delete_data(slice_id: str) -> None:
"""
Delete a slice entry from the database.
Args:
- slice_id (str): Unique identifier for the slice to delete
-
+ slice_id (str): Unique identifier for the slice to delete.
+
Raises:
- ValueError: If no slice is found with the given slice_id
+ ValueError: If no slice is found with the given slice_id.
"""
- conn = sqlite3.connect(DB_NAME)
- cursor = conn.cursor()
- cursor.execute("DELETE FROM slice WHERE slice_id = ?", (slice_id,))
- if cursor.rowcount == 0:
- raise ValueError(f"No slice found with id '{slice_id}' to delete.")
- else:
- logging.debug(f"Slice '{slice_id}' deleted.")
- conn.commit()
- conn.close()
-
-# Get data from the database
-def get_data(slice_id: str) -> dict[str, dict, str]:
+ with sqlite3.connect(DB_NAME) as conn:
+ cursor = conn.cursor()
+ cursor.execute("DELETE FROM slice WHERE slice_id = ?", (slice_id,))
+ if cursor.rowcount == 0:
+ raise ValueError(f"No slice found with id '{slice_id}' to delete.")
+ conn.commit()
+ logger.debug("Slice '%s' deleted.", slice_id)
+
+
+def get_data(slice_id: str) -> dict[str, Any]:
"""
Retrieve a specific slice entry from the database.
Args:
- slice_id (str): Unique identifier for the slice to retrieve
-
+ slice_id (str): Unique identifier for the slice to retrieve.
+
Returns:
- dict: Slice data including slice_id, intent (as dict), and controller
-
+ dict[str, Any]: Slice data dictionary containing slice_id, intent, and controller.
+
Raises:
- ValueError: If no slice is found with the given slice_id
- Exception: For JSON decoding errors
+ ValueError: If no slice is found with the given slice_id.
+ Exception: If JSON decoding of the stored intent fails.
"""
- conn = sqlite3.connect(DB_NAME)
- cursor = conn.cursor()
- cursor.execute("SELECT * FROM slice WHERE slice_id = ?", (slice_id,))
- row = cursor.fetchone()
- conn.close()
+ with sqlite3.connect(DB_NAME) as conn:
+ cursor = conn.cursor()
+ cursor.execute("SELECT * FROM slice WHERE slice_id = ?", (slice_id,))
+ row = cursor.fetchone()
+ if not row:
+ raise ValueError(f"No slice found with id '{slice_id}'.")
- if row:
column_names = [description[0] for description in cursor.description]
- result = dict(zip(column_names, row))
+ result: dict[str, Any] = dict(zip(column_names, row))
if isinstance(result.get("intent"), str):
try:
result["intent"] = json.loads(result["intent"])
@@ -160,61 +162,33 @@ def get_data(slice_id: str) -> dict[str, dict, str]:
raise Exception("Warning: 'intent' is not a valid JSON string.")
return result
- else:
- raise ValueError(f"No slice found with id '{slice_id}'.")
-# Get all slices
-def get_all_data() -> dict[str, dict, str]:
+def get_all_data() -> list[dict[str, Any]]:
"""
Retrieve all slice entries from the database.
Returns:
- list: List of slice data dictionaries including slice_id, intent (as dict), and controller
- """
- conn = sqlite3.connect(DB_NAME)
- cursor = conn.cursor()
- cursor.execute("SELECT * FROM slice")
- rows = cursor.fetchall()
- conn.close()
- return [
- {
- "slice_id": row[0],
- "intent": json.loads(row[1]),
- "controller": row[2]
- }
- for row in rows
- ]
-
-def delete_all_data():
+ list[dict[str, Any]]: List of slice data dictionaries.
"""
- Delete all slice entries from the database.
- """
- conn = sqlite3.connect(DB_NAME)
- cursor = conn.cursor()
- cursor.execute("DELETE FROM slice")
- conn.commit()
- conn.close()
- logging.debug("All slice data deleted.")
-
-# Example usage
-if __name__ == "__main__":
- init_db()
-
- # Save a slice
- test_intent = {"bandwidth": "1Gbps", "latency": "10ms", "provider": "opensec"}
- save_data("slice-001", test_intent, "TFS")
-
- # Get the slice
- result = get_data("slice-001")
- if result:
- print(f"Retrieved intent for slice-001: {result}")
-
- # Update the slice
- updated_intent = {"bandwidth": "2Gbps", "latency": "5ms", "provider": "opensec"}
- update_data("slice-001", updated_intent, "TFS")
-
- # Delete the slice
- delete_data("slice-001")
-
- get_all_data()
- delete_all_data()
+ with sqlite3.connect(DB_NAME) as conn:
+ cursor = conn.cursor()
+ cursor.execute("SELECT slice_id, intent, controller FROM slice")
+ rows = cursor.fetchall()
+ return [
+ {
+ "slice_id": row[0],
+ "intent": json.loads(row[1]),
+ "controller": row[2],
+ }
+ for row in rows
+ ]
+
+
+def delete_all_data() -> None:
+ """Delete all slice entries from the database."""
+ with sqlite3.connect(DB_NAME) as conn:
+ cursor = conn.cursor()
+ cursor.execute("DELETE FROM slice")
+ conn.commit()
+ logger.debug("All slice data deleted.")
+
diff --git a/src/database/service_db.py b/src/database/service_db.py
index 0e2aa1fb94aa58216a1a5510a9029b4d15acd52d..6f4121f151dad5f37538808a16b56ad68235f182 100644
--- a/src/database/service_db.py
+++ b/src/database/service_db.py
@@ -13,146 +13,136 @@
# limitations under the License.
# This file is an original contribution from Telefonica Innovación Digital S.L.
+import logging
+import sqlite3
-import sqlite3, logging
+logger = logging.getLogger(__name__)
# Database file
-DB_NAME = "service.db"
+DB_NAME: str = "service.db"
+
+
+def init_db() -> None:
+ """Initialize the SQLite database and create the service table if not exists."""
+ with sqlite3.connect(DB_NAME) as conn:
+ cursor = conn.cursor()
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS service (
+ service_id TEXT PRIMARY KEY,
+ slice_id TEXT NOT NULL
+ )
+ """)
+ conn.commit()
-# Initialize database and create table
-def init_db():
- """
- Initialize the SQLite database and create the service table if not exists.
- """
- conn = sqlite3.connect(DB_NAME)
- cursor = conn.cursor()
- cursor.execute("""
- CREATE TABLE IF NOT EXISTS service (
- service_id TEXT PRIMARY KEY,
- slice_id TEXT NOT NULL
- )
- """)
- conn.commit()
- conn.close()
-# Save data to the database
-def save_data(service_id: str, slice_id: str):
+def save_data(service_id: str, slice_id: str) -> None:
"""
Save a new service entry to the database.
Args:
- service_id (str): Unique identifier for the service
- slice_id (dict): Unique identifier for the slice
-
+ service_id (str): Unique identifier for the service.
+ slice_id (str): Unique identifier for the slice.
+
Raises:
- ValueError: If a service with the given service_id already exists
+ ValueError: If a service with the given service_id already exists.
"""
- conn = sqlite3.connect(DB_NAME)
- cursor = conn.cursor()
try:
- cursor.execute("INSERT INTO service (service_id, slice_id) VALUES (?, ?)", (service_id, slice_id))
- conn.commit()
- # Handle duplicate service ID
+ with sqlite3.connect(DB_NAME) as conn:
+ cursor = conn.cursor()
+ cursor.execute(
+ "INSERT INTO service (service_id, slice_id) VALUES (?, ?)",
+ (service_id, slice_id),
+ )
+ conn.commit()
except sqlite3.IntegrityError:
raise ValueError(f"Service with id '{service_id}' already exists.")
- finally:
- conn.close()
-# Update data in the database
-def update_data(service_id: str, new_slice_id: str):
+
+def update_data(service_id: str, new_slice_id: str) -> None:
"""
Update the slice_id for an existing service entry in the database.
Args:
- service_id (str): Unique identifier for the service to update
- new_slice_id (str): New slice ID to associate with the service
+ service_id (str): Unique identifier for the service to update.
+ new_slice_id (str): New slice ID to associate with the service.
Raises:
- ValueError: If no service is found with the given service_id
+ ValueError: If no service is found with the given service_id.
"""
- conn = sqlite3.connect(DB_NAME)
- cursor = conn.cursor()
- try:
- cursor.execute("UPDATE service SET slice_id = ? WHERE service_id = ?", (new_slice_id, service_id))
+ with sqlite3.connect(DB_NAME) as conn:
+ cursor = conn.cursor()
+ cursor.execute(
+ "UPDATE service SET slice_id = ? WHERE service_id = ?",
+ (new_slice_id, service_id),
+ )
if cursor.rowcount == 0:
raise ValueError(f"No slice found with id '{service_id}' to update.")
- else:
- logging.debug(f"Slice '{service_id}' updated.")
conn.commit()
- finally:
- conn.close()
+ logger.debug("Slice '%s' updated.", service_id)
-# Delete data from the database
-def delete_data(service_id: str):
+
+def delete_data(service_id: str) -> None:
"""
Delete a service entry from the database.
Args:
- service_id (str): Unique identifier for the service to delete
+ service_id (str): Unique identifier for the service to delete.
Raises:
- ValueError: If no service is found with the given service_id
+ ValueError: If no service is found with the given service_id.
"""
- conn = sqlite3.connect(DB_NAME)
- cursor = conn.cursor()
- try:
+ with sqlite3.connect(DB_NAME) as conn:
+ cursor = conn.cursor()
cursor.execute("DELETE FROM service WHERE service_id = ?", (service_id,))
if cursor.rowcount == 0:
raise ValueError(f"No service found with id '{service_id}' to delete.")
- else:
- logging.debug(f"Service '{service_id}' deleted.")
conn.commit()
- finally:
- conn.close()
+ logger.debug("Service '%s' deleted.", service_id)
+
-# Get data from the database
def get_data(service_id: str) -> dict[str, str]:
- conn = sqlite3.connect(DB_NAME)
- cursor = conn.cursor()
-
- cursor.execute("SELECT * FROM service WHERE service_id = ?", (service_id,))
- row = cursor.fetchone()
-
- if not row:
- conn.close()
- raise ValueError(f"No service found with id '{service_id}'.")
-
- column_names = [desc[0] for desc in cursor.description]
- conn.close()
-
- return dict(zip(column_names, row))
-
-
-# Get all services
-def get_all_data() -> list[dict[str, str]]:
"""
- Retrieve all service entries from the database.
+ Retrieve a specific service entry from the database.
+
+ Args:
+ service_id (str): Unique identifier for the service to retrieve.
Returns:
- list: List of service data dictionaries including service_id and slice_id
- """
- conn = sqlite3.connect(DB_NAME)
- cursor = conn.cursor()
- cursor.execute("SELECT * FROM service")
-
- rows = cursor.fetchall()
- column_names = [description[0] for description in cursor.description]
-
- conn.close()
+ dict[str, str]: Service entry dictionary (service_id, slice_id).
- return [dict(zip(column_names, row)) for row in rows]
+ Raises:
+ ValueError: If no service is found with the given service_id.
+ """
+ with sqlite3.connect(DB_NAME) as conn:
+ cursor = conn.cursor()
+ cursor.execute("SELECT service_id, slice_id FROM service WHERE service_id = ?", (service_id,))
+ row = cursor.fetchone()
+ if not row:
+ raise ValueError(f"No service found with id '{service_id}'.")
+ return {"service_id": row[0], "slice_id": row[1]}
-def delete_all_data():
+def get_all_data() -> list[dict[str, str]]:
"""
- Delete all service entries from the database.
+ Retrieve all service entries from the database.
+
+ Returns:
+ list[dict[str, str]]: List of service data dictionaries.
"""
- conn = sqlite3.connect(DB_NAME)
- cursor = conn.cursor()
- cursor.execute("DELETE FROM service")
- conn.commit()
- conn.close()
- logging.debug("All service data deleted.")
+ with sqlite3.connect(DB_NAME) as conn:
+ cursor = conn.cursor()
+ cursor.execute("SELECT service_id, slice_id FROM service")
+ rows = cursor.fetchall()
+ return [{"service_id": row[0], "slice_id": row[1]} for row in rows]
+
+
+def delete_all_data() -> None:
+ """Delete all service entries from the database."""
+ with sqlite3.connect(DB_NAME) as conn:
+ cursor = conn.cursor()
+ cursor.execute("DELETE FROM service")
+ conn.commit()
+ logger.debug("All service data deleted.")
def get_data_by_slice_id(slice_id: str) -> list[dict[str, str]]:
@@ -160,53 +150,40 @@ def get_data_by_slice_id(slice_id: str) -> list[dict[str, str]]:
Retrieve all service entries associated with a given slice_id.
Args:
- slice_id (str): Identifier of the slice
-
+ slice_id (str): Identifier of the slice.
+
Returns:
- list: List of service data dictionaries including service_id and slice_id
-
+ list[dict[str, str]]: List of service data dictionaries.
+
Raises:
- ValueError: If no services are found with the given slice_id
+ ValueError: If no services are found with the given slice_id.
"""
- conn = sqlite3.connect(DB_NAME)
- cursor = conn.cursor()
-
- cursor.execute("SELECT * FROM service WHERE slice_id = ?", (slice_id,))
- rows = cursor.fetchall()
-
- if not rows:
- conn.close()
- raise ValueError(f"No services found with slice_id '{slice_id}'.")
-
- column_names = [desc[0] for desc in cursor.description]
- conn.close()
-
- return [dict(zip(column_names, row)) for row in rows]
-
-def delete_by_slice_id(slice_id: str):
+ with sqlite3.connect(DB_NAME) as conn:
+ cursor = conn.cursor()
+ cursor.execute("SELECT service_id, slice_id FROM service WHERE slice_id = ?", (slice_id,))
+ rows = cursor.fetchall()
+ if not rows:
+ raise ValueError(f"No services found with slice_id '{slice_id}'.")
+ return [{"service_id": row[0], "slice_id": row[1]} for row in rows]
+
+
+def delete_by_slice_id(slice_id: str) -> None:
"""
Delete all service entries associated with a given slice_id.
Args:
- slice_id (str): Identifier of the slice whose services will be deleted
-
+ slice_id (str): Identifier of the slice whose services will be deleted.
+
Raises:
- ValueError: If no services are found with the given slice_id
+ ValueError: If no services are found with the given slice_id.
"""
- conn = sqlite3.connect(DB_NAME)
- cursor = conn.cursor()
-
- cursor.execute("DELETE FROM service WHERE slice_id = ?", (slice_id,))
-
- if cursor.rowcount == 0:
- conn.close()
- raise ValueError(f"No services found with slice_id '{slice_id}' to delete.")
-
- logging.debug(f"All services with slice_id '{slice_id}' deleted.")
-
- conn.commit()
- conn.close()
-
+ with sqlite3.connect(DB_NAME) as conn:
+ cursor = conn.cursor()
+ cursor.execute("DELETE FROM service WHERE slice_id = ?", (slice_id,))
+ if cursor.rowcount == 0:
+ raise ValueError(f"No services found with slice_id '{slice_id}' to delete.")
+ conn.commit()
+ logger.debug("All services with slice_id '%s' deleted.", slice_id)
# Example usage
if __name__ == "__main__":
diff --git a/src/database/store_data.py b/src/database/store_data.py
index 97a2c202f2e8086ad64f7f8693384d13a4069390..a959e6a44fa35f38c70dc93eeea4be91ad94a462 100644
--- a/src/database/store_data.py
+++ b/src/database/store_data.py
@@ -14,29 +14,38 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
-from src.database.db import save_data, update_data
-from src.database.sysrepo_store import create_data_store
+import logging
+from typing import Any
+
+from src.database import db
+from src.database.sysrepo_store import update_data_store
-def store_data(intent, slice_id, controller_type=None):
- """
- Store network slice intent information in a JSON database file.
- This method:
- 1. Creates a JSON file if it doesn't exist
- 2. Reads existing content
- 3. Updates or adds new slice intent information
+def store_data(
+ intent: dict[str, Any],
+ slice_id: str | None = None,
+ controller_type: str | None = None,
+) -> None:
+ """
+ Store network slice intent information in the database.
Args:
- intent (dict): Network slice intent to be stored
+ intent (dict[str, Any]): Network slice intent to be stored.
slice_id (str, optional): Existing slice ID to update. Defaults to None.
+ controller_type (str, optional): Controller type. Defaults to None.
"""
- # Update or add new slice intent
+ if controller_type == "RESTCONF":
+ try:
+ update_data_store(intent)
+ return
+ except Exception as e:
+ logging.warning(f"Could not update sysrepo datastore: {e}")
+
+ effective_controller = controller_type or "TFS"
+
if slice_id:
- if controller_type != "RESTCONF":
- update_data(slice_id, intent, controller_type)
-
- else:
- # Add new slice intent
- slice_id = intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"]
- if controller_type != "RESTCONF":
- save_data(slice_id, intent, controller_type)
\ No newline at end of file
+ db.update_data(slice_id, intent, effective_controller)
+ return
+
+ resolved_slice_id = intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"]
+ db.save_data(resolved_slice_id, intent, effective_controller)
diff --git a/src/database/sysrepo_store.py b/src/database/sysrepo_store.py
index 26ad0a5cc20980be2ebb08f94d69e8b3c89aae2e..fef81071b560fb89748c1e865ec74e1615516be6 100644
--- a/src/database/sysrepo_store.py
+++ b/src/database/sysrepo_store.py
@@ -14,60 +14,61 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
-import sysrepo
import logging
+from typing import Any
+
+try:
+ import sysrepo
+except ImportError:
+ sysrepo = None
+
+logger = logging.getLogger(__name__)
-#_conn = sysrepo.SysrepoConnection()
def _get_connection():
- """
- Creates a new connection each time
- """
+ """Create a new SysrepoConnection instance."""
+ if sysrepo is None:
+ raise RuntimeError("sysrepo module is not installed in the environment.")
return sysrepo.SysrepoConnection()
-def create_data_store(intent: dict, xpath: str= ""):
- """
- CREATE: Creates new data in the datastore
- """
+
+def create_data_store(intent: dict[str, Any], xpath: str = "") -> bool:
+ """CREATE: Creates new data in the datastore."""
conn = _get_connection()
sess = conn.start_session()
-
+
try:
_write_dict(sess, xpath, intent)
sess.apply_changes()
- logging.debug(f"Created data at {xpath}")
+ logger.debug("Created data at %s", xpath)
return True
except Exception as e:
sess.discard_changes()
- logging.error(f"Error creating data: {e}")
- raise Exception(f"Error creating data: {e}")
-
+ logger.error("Error creating data: %s", e)
+ raise Exception(f"Error creating data: {e}") from e
finally:
sess.stop()
-def get_data_store(xpath: str = ""):
- """
- Gets all slices from the datastore
- """
- conn = _get_connection()
- sess = conn.start_session()
+def get_data_store(xpath: str = "") -> Any:
+ """Gets all slices from the datastore."""
try:
- # get_data devuelve un objeto libyang que debe ser procesado
- data = sess.get_data(xpath)
- if data is None:
- return None
-
- # Convertir a dict/JSON para uso seguro
- return normalize_libyang_data(data)
-
+ conn = _get_connection()
+ sess = conn.start_session()
+ try:
+ data = sess.get_data(xpath)
+ if data is None:
+ return None
+ return normalize_libyang_data(data)
+ finally:
+ sess.stop()
except Exception as e:
- logging.warning(f"Slices not found: {e}")
+ logger.warning("Slices not found: %s", e)
return None
- finally:
- sess.stop()
-def update_data_store(intent: dict, xpath: str = ""):
+
+def update_data_store(intent: dict[str, Any], xpath: str = "") -> bool:
+
"""
UPDATE: Modifies data in the datastore
@@ -84,86 +85,64 @@ def update_data_store(intent: dict, xpath: str = ""):
"""
conn = _get_connection()
sess = conn.start_session()
-
- # PUT: Delete and recreate (complete replacement)
+
try:
if not xpath:
sess.delete_item("/ietf-network-slice-service:network-slice-services")
else:
sess.delete_item(xpath)
- logging.debug(f"Deleted existing data at {xpath} for replacement")
+ logger.debug("Deleted existing data at %s for replacement", xpath)
_write_dict(sess, xpath, intent)
sess.apply_changes()
- logging.debug(f"Replaced data at {xpath}")
+ logger.debug("Replaced data at %s", xpath)
return True
-
except Exception as e:
sess.discard_changes()
- logging.error(f"Error updating data: {e}")
- raise Exception(f"Error updating data: {e}")
-
+ logger.error("Error updating data: %s", e)
+ raise Exception(f"Error updating data: {e}") from e
finally:
sess.stop()
-def patch_data_store(intent: dict, xpath: str = ""):
- """
- UPDATE: Modifies data in the datastore
- Args:
- intent: Data to modify
- xpath: Path to resource
- operation:
- - "merge" (default): PATCH - updates specific fields
- - "replace": PUT - completely replaces the resource
- - "create": POST - only creates if it doesn't exist
-
- Returns:
- bool: True if successful
- """
+def patch_data_store(intent: dict[str, Any], xpath: str = "") -> bool:
+ """PATCH: Merges data in the datastore."""
conn = _get_connection()
sess = conn.start_session()
- # PUT: Delete and recreate (complete replacement)
try:
- # PATCH: Merge with existing data
_write_dict(sess, xpath, intent)
sess.apply_changes()
- logging.debug(f"Merged data at {xpath}")
+ logger.debug("Merged data at %s", xpath)
return True
-
except Exception as e:
sess.discard_changes()
- logging.error(f"Error patching data: {e}")
- raise Exception(f"Error patching data: {e}")
-
+ logger.error("Error patching data: %s", e)
+ raise Exception(f"Error patching data: {e}") from e
finally:
sess.stop()
-def delete_data_store(xpath: str = ""):
- """
- DELETE: Deletes data from the datastore
- """
+
+def delete_data_store(xpath: str = "") -> bool:
+ """DELETE: Deletes data from the datastore."""
conn = _get_connection()
sess = conn.start_session()
-
+
try:
sess.delete_item(xpath)
sess.apply_changes()
- logging.debug(f"Deleted data at {xpath}")
+ logger.debug("Deleted data at %s", xpath)
return True
except Exception as e:
sess.discard_changes()
- logging.error(f"Error deleting data at {xpath}: {e}")
- raise Exception(f"Error deleting data at {xpath}: {e}")
+ logger.error("Error deleting data at %s: %s", xpath, e)
+ raise Exception(f"Error deleting data at {xpath}: {e}") from e
finally:
sess.stop()
-def _write_dict(sess, base_xpath, data, parent_key=None):
- """
- Converts dict → YANG XPaths
- parent_key: key already in xpath and should be excluded
- """
+def _write_dict(sess: Any, base_xpath: str, data: Any, parent_key: str | None = None) -> None:
+ """Converts dictionary to YANG XPaths and sets them in sysrepo session."""
+
LIST_KEYS = {
'slo-sle-template': 'id',
'metric-bound': 'metric-type',
@@ -239,7 +218,7 @@ def _write_dict(sess, base_xpath, data, parent_key=None):
for value in data:
if value is None or value == '':
- logging.debug(f"Skipping None/empty value in leaf-list")
+ logging.debug("Skipping None/empty value in leaf-list")
continue
logging.debug(f"Adding leaf-list value: {value}")
diff --git a/src/database/telemetry_client_db.py b/src/database/telemetry_client_db.py
index d9d23528ebafb0e7bc01e337b55e3f97aba6f2c4..fa256dfdd89ebb6f8591f3fc9715195f5b621b8e 100644
--- a/src/database/telemetry_client_db.py
+++ b/src/database/telemetry_client_db.py
@@ -1,314 +1,236 @@
-# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
-
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-
-# http://www.apache.org/licenses/LICENSE-2.0
-
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-# This file is an original contribution from Telefonica Innovación Digital S.L.
-
-import sqlite3, logging, json
-
-DB_NAME = "telemetry_client.db"
-
-
-def init_db():
- """
- Initialize database and create telemetry_client table if not exists.
- """
- conn = sqlite3.connect(DB_NAME)
- cursor = conn.cursor()
-
- cursor.execute("""
- CREATE TABLE IF NOT EXISTS client (
- client_id TEXT PRIMARY KEY
- )
- """)
-
- cursor.execute("""
- CREATE TABLE IF NOT EXISTS client_slice_subscription (
- client_id TEXT,
- slice_id TEXT,
- frequency INTEGER NOT NULL,
-
- PRIMARY KEY (client_id, slice_id),
-
- FOREIGN KEY (client_id) REFERENCES client(client_id) ON DELETE CASCADE
- )
- """)
-
- cursor.execute("""
- CREATE TABLE IF NOT EXISTS telemetry (
- slice_id TEXT PRIMARY KEY,
-
- telemetry_list TEXT NOT NULL,
- slo_sle_compliance TEXT NOT NULL,
-
- timestamp REAL NOT NULL
- )
- """)
-
- conn.commit()
- conn.close()
-
-def get_conn():
- conn = sqlite3.connect(DB_NAME)
- conn.execute("PRAGMA foreign_keys = ON")
- return conn
-
-def create_client(client_id: str):
- conn = get_conn()
- cursor = conn.cursor()
-
- try:
- cursor.execute(
- "INSERT INTO client (client_id) VALUES (?)",
- (client_id,)
- )
- conn.commit()
-
- except sqlite3.IntegrityError:
- raise ValueError(f"Client '{client_id}' already exists")
-
- finally:
- conn.close()
-
-def delete_client(client_id: str):
- """
- Delete client.
- """
- conn = get_conn()
- cursor = conn.cursor()
-
- cursor.execute(
- "DELETE FROM client WHERE client_id = ?",
- (client_id,)
- )
-
- if cursor.rowcount == 0:
- raise ValueError(f"No client found with id '{client_id}' to delete.")
-
- logging.debug(f"Client '{client_id}' deleted.")
-
- conn.commit()
- conn.close()
-
-
-def get_client(client_id: str) -> dict:
- conn = get_conn()
- cursor = conn.cursor()
-
- cursor.execute(
- "SELECT * FROM client WHERE client_id = ?",
- (client_id,)
- )
-
- row = cursor.fetchone()
-
- if not row:
- conn.close()
- raise ValueError(f"No client found with id '{client_id}'.")
-
- result = {
- "client_id": row[0]
- }
-
- conn.close()
-
- return result
-
-
-def get_all_clients() -> list[dict]:
- conn = get_conn()
- cursor = conn.cursor()
-
- cursor.execute("SELECT client_id FROM client")
-
- rows = cursor.fetchall()
-
- conn.close()
-
- return [
- {
- "client_id": row[0]
- }
- for row in rows
- ]
-
-
-def delete_all_clients():
- conn = get_conn()
- cursor = conn.cursor()
-
- cursor.execute("DELETE FROM client")
-
- conn.commit()
- conn.close()
-
- logging.debug("All clients deleted.")
-
-def upsert_subscription(client_id: str, slice_id: str, frequency: int):
- conn = get_conn()
- cursor = conn.cursor()
-
- cursor.execute("""
- INSERT INTO client_slice_subscription (client_id, slice_id, frequency)
- VALUES (?, ?, ?)
- ON CONFLICT(client_id, slice_id)
- DO UPDATE SET frequency = excluded.frequency
- """, (client_id, slice_id, frequency))
-
- conn.commit()
- conn.close()
-
-def get_subscription(client_id: str, slice_id: str) -> dict:
- conn = get_conn()
- cursor = conn.cursor()
-
- cursor.execute("""
- SELECT client_id, slice_id, frequency
- FROM client_slice_subscription
- WHERE client_id = ? AND slice_id = ?
- """, (client_id, slice_id))
-
- row = cursor.fetchone()
- conn.close()
-
- if not row:
- raise ValueError("Subscription not found")
-
- return {
- "client_id": row[0],
- "slice_id": row[1],
- "frequency": row[2]
- }
-
-def get_client_subscriptions(client_id: str) -> list[dict]:
- conn = get_conn()
- cursor = conn.cursor()
-
- cursor.execute("""
- SELECT slice_id, frequency
- FROM client_slice_subscription
- WHERE client_id = ?
- """, (client_id,))
-
- rows = cursor.fetchall()
- conn.close()
-
- return [
- {
- "slice_id": r[0],
- "frequency": r[1]
- }
- for r in rows
- ]
-
-def delete_subscription(client_id: str, slice_id: str):
- conn = get_conn()
- cursor = conn.cursor()
-
- cursor.execute("""
- DELETE FROM client_slice_subscription
- WHERE client_id = ? AND slice_id = ?
- """, (client_id, slice_id))
-
- if cursor.rowcount == 0:
- raise ValueError("Subscription not found")
-
- conn.commit()
- conn.close()
-
-def delete_all_subscriptions(client_id: str):
- conn = get_conn()
- cursor = conn.cursor()
-
- cursor.execute("""
- DELETE FROM client_slice_subscription
- WHERE client_id = ?
- """, (client_id,))
-
- conn.commit()
- conn.close()
-
-def upsert_telemetry(
- slice_id: str,
- telemetry_list: dict,
- slo_sle_compliance: dict,
- timestamp: float
-):
- conn = get_conn()
- cursor = conn.cursor()
-
- cursor.execute("""
- INSERT INTO telemetry (
- slice_id,
- telemetry_list,
- slo_sle_compliance,
- timestamp
- )
- VALUES (?, ?, ?, ?)
-
- ON CONFLICT(slice_id)
- DO UPDATE SET
- telemetry_list = excluded.telemetry_list,
- slo_sle_compliance = excluded.slo_sle_compliance,
- timestamp = excluded.timestamp
- """, (
- slice_id,
- json.dumps(telemetry_list),
- json.dumps(slo_sle_compliance),
- timestamp
- ))
-
- conn.commit()
- conn.close()
-
-def get_telemetry(slice_id: str):
- conn = get_conn()
- cursor = conn.cursor()
-
- cursor.execute("""
- SELECT
- slice_id,
- telemetry_list,
- slo_sle_compliance,
- timestamp
- FROM telemetry
- WHERE slice_id = ?
- """, (slice_id,))
-
- row = cursor.fetchone()
-
- conn.close()
-
- if not row:
- raise ValueError(
- f"No telemetry found for slice '{slice_id}'"
- )
-
- return {
- "slice_id": row[0],
- "telemetry_list": json.loads(row[1]),
- "slo_sle_compliance": json.loads(row[2]),
- "timestamp": row[3]
- }
-
-def delete_telemetry(slice_id: str):
- conn = get_conn()
- cursor = conn.cursor()
-
- cursor.execute("""
- DELETE FROM telemetry
- WHERE slice_id = ?
- """, (slice_id,))
-
- conn.commit()
- conn.close()
\ No newline at end of file
+# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
+
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+
+# http://www.apache.org/licenses/LICENSE-2.0
+
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# This file is an original contribution from Telefonica Innovación Digital S.L.
+
+import json
+import logging
+import sqlite3
+from typing import Any
+
+logger = logging.getLogger(__name__)
+
+DB_NAME: str = "telemetry_client.db"
+
+
+def init_db() -> None:
+ """Initialize database and create tables if not exist."""
+ with get_conn() as conn:
+ cursor = conn.cursor()
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS client (
+ client_id TEXT PRIMARY KEY
+ )
+ """)
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS client_slice_subscription (
+ client_id TEXT,
+ slice_id TEXT,
+ frequency INTEGER NOT NULL,
+ PRIMARY KEY (client_id, slice_id),
+ FOREIGN KEY (client_id) REFERENCES client(client_id) ON DELETE CASCADE
+ )
+ """)
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS telemetry (
+ slice_id TEXT PRIMARY KEY,
+ telemetry_list TEXT NOT NULL,
+ slo_sle_compliance TEXT NOT NULL,
+ timestamp REAL NOT NULL
+ )
+ """)
+ conn.commit()
+
+
+def get_conn() -> sqlite3.Connection:
+ """Create a new SQLite connection with foreign keys enabled."""
+ conn = sqlite3.connect(DB_NAME)
+ conn.execute("PRAGMA foreign_keys = ON")
+ return conn
+
+
+def create_client(client_id: str) -> None:
+ """Create a new telemetry client."""
+ try:
+ with get_conn() as conn:
+ cursor = conn.cursor()
+ cursor.execute("INSERT INTO client (client_id) VALUES (?)", (client_id,))
+ conn.commit()
+ except sqlite3.IntegrityError:
+ raise ValueError(f"Client '{client_id}' already exists")
+
+
+def delete_client(client_id: str) -> None:
+ """Delete client and associated subscriptions."""
+ with get_conn() as conn:
+ cursor = conn.cursor()
+ cursor.execute("DELETE FROM client WHERE client_id = ?", (client_id,))
+ if cursor.rowcount == 0:
+ raise ValueError(f"No client found with id '{client_id}' to delete.")
+ conn.commit()
+ logger.debug("Client '%s' deleted.", client_id)
+
+
+def get_client(client_id: str) -> dict[str, str]:
+ """Retrieve client details."""
+ with get_conn() as conn:
+ cursor = conn.cursor()
+ cursor.execute("SELECT client_id FROM client WHERE client_id = ?", (client_id,))
+ row = cursor.fetchone()
+ if not row:
+ raise ValueError(f"No client found with id '{client_id}'.")
+ return {"client_id": row[0]}
+
+
+def get_all_clients() -> list[dict[str, str]]:
+ """Retrieve all clients."""
+ with get_conn() as conn:
+ cursor = conn.cursor()
+ cursor.execute("SELECT client_id FROM client")
+ rows = cursor.fetchall()
+ return [{"client_id": row[0]} for row in rows]
+
+
+def delete_all_clients() -> None:
+ """Delete all clients."""
+ with get_conn() as conn:
+ cursor = conn.cursor()
+ cursor.execute("DELETE FROM client")
+ conn.commit()
+ logger.debug("All clients deleted.")
+
+
+def upsert_subscription(client_id: str, slice_id: str, frequency: int) -> None:
+ """Insert or update a client-slice subscription."""
+ with get_conn() as conn:
+ cursor = conn.cursor()
+ cursor.execute("""
+ INSERT INTO client_slice_subscription (client_id, slice_id, frequency)
+ VALUES (?, ?, ?)
+ ON CONFLICT(client_id, slice_id)
+ DO UPDATE SET frequency = excluded.frequency
+ """, (client_id, slice_id, frequency))
+ conn.commit()
+
+
+def get_subscription(client_id: str, slice_id: str) -> dict[str, Any]:
+ """Retrieve a specific subscription."""
+ with get_conn() as conn:
+ cursor = conn.cursor()
+ cursor.execute("""
+ SELECT client_id, slice_id, frequency
+ FROM client_slice_subscription
+ WHERE client_id = ? AND slice_id = ?
+ """, (client_id, slice_id))
+ row = cursor.fetchone()
+ if not row:
+ raise ValueError("Subscription not found")
+ return {
+ "client_id": row[0],
+ "slice_id": row[1],
+ "frequency": row[2],
+ }
+
+
+def get_client_subscriptions(client_id: str) -> list[dict[str, Any]]:
+ """Retrieve all subscriptions for a given client."""
+ with get_conn() as conn:
+ cursor = conn.cursor()
+ cursor.execute("""
+ SELECT slice_id, frequency
+ FROM client_slice_subscription
+ WHERE client_id = ?
+ """, (client_id,))
+ rows = cursor.fetchall()
+ return [{"slice_id": r[0], "frequency": r[1]} for r in rows]
+
+
+def delete_subscription(client_id: str, slice_id: str) -> None:
+ """Delete a specific subscription."""
+ with get_conn() as conn:
+ cursor = conn.cursor()
+ cursor.execute("""
+ DELETE FROM client_slice_subscription
+ WHERE client_id = ? AND slice_id = ?
+ """, (client_id, slice_id))
+ if cursor.rowcount == 0:
+ raise ValueError("Subscription not found")
+ conn.commit()
+
+
+def delete_all_subscriptions(client_id: str) -> None:
+ """Delete all subscriptions for a client."""
+ with get_conn() as conn:
+ cursor = conn.cursor()
+ cursor.execute("DELETE FROM client_slice_subscription WHERE client_id = ?", (client_id,))
+ conn.commit()
+
+
+def upsert_telemetry(
+ slice_id: str,
+ telemetry_list: dict[str, Any],
+ slo_sle_compliance: dict[str, Any],
+ timestamp: float,
+) -> None:
+ """Insert or update telemetry data for a slice."""
+ with get_conn() as conn:
+ cursor = conn.cursor()
+ cursor.execute("""
+ INSERT INTO telemetry (
+ slice_id,
+ telemetry_list,
+ slo_sle_compliance,
+ timestamp
+ )
+ VALUES (?, ?, ?, ?)
+ ON CONFLICT(slice_id)
+ DO UPDATE SET
+ telemetry_list = excluded.telemetry_list,
+ slo_sle_compliance = excluded.slo_sle_compliance,
+ timestamp = excluded.timestamp
+ """, (
+ slice_id,
+ json.dumps(telemetry_list),
+ json.dumps(slo_sle_compliance),
+ timestamp,
+ ))
+ conn.commit()
+
+
+def get_telemetry(slice_id: str) -> dict[str, Any]:
+ """Retrieve telemetry metrics for a slice."""
+ with get_conn() as conn:
+ cursor = conn.cursor()
+ cursor.execute("""
+ SELECT slice_id, telemetry_list, slo_sle_compliance, timestamp
+ FROM telemetry
+ WHERE slice_id = ?
+ """, (slice_id,))
+ row = cursor.fetchone()
+ if not row:
+ raise ValueError(f"No telemetry found for slice '{slice_id}'")
+ return {
+ "slice_id": row[0],
+ "telemetry_list": json.loads(row[1]),
+ "slo_sle_compliance": json.loads(row[2]),
+ "timestamp": row[3],
+ }
+
+
+def delete_telemetry(slice_id: str) -> None:
+ """Delete telemetry record for a slice."""
+ with get_conn() as conn:
+ cursor = conn.cursor()
+ cursor.execute("DELETE FROM telemetry WHERE slice_id = ?", (slice_id,))
+ conn.commit()
\ No newline at end of file
diff --git a/src/main.py b/src/main.py
index 3bf626a3e3011268d883ef8017deee25b4e94769..20da2544730b379d2179ac47ac75c5db89103a32 100644
--- a/src/main.py
+++ b/src/main.py
@@ -16,215 +16,268 @@
import logging
import time
-from src.utils.dump_templates import dump_templates
-from src.utils.build_response import build_response
-from src.nbi_processor.main import nbi_processor
-from src.database.store_data import store_data
+from typing import Any
+
+import requests as http_requests
+from flask import current_app
+
+from src.database.db import save_subscription
from src.database.service_db import delete_data
+from src.database.store_data import store_data
+from src.database.sysrepo_store import get_data_store, update_data_store
from src.mapper.main import mapper
+from src.nbi_processor.main import nbi_processor
+from src.planner.planner import Planner
from src.realizer.main import realizer
from src.realizer.send_controller import send_controller
+from src.utils.build_response import build_response
+from src.utils.dump_templates import dump_templates
+
+
+def _subscribe_to_alerts(ietf_intents: list[dict[str, Any]], slice_id: str | None) -> None:
+ """Subscribe to TAPI notification alerts if enabled in application configuration."""
+ if not current_app.config.get("SUBSCRIBE_ALERTS", False):
+ return
+
+ sub_url = current_app.config.get("SUBSCRIBE_ALERTS_URL")
+ if not sub_url:
+ return
+
+ 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 not sub_slice_id:
+ return
+
+ 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:
+ 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}")
+
+
+
+
class NSController:
"""
- Network Slice Controller (NSC) - A class to manage network slice creation,
- modification, and deletion across different network domains.
-
- This controller handles the translation, mapping, and realization of network
- slice intents from different formats (3GPP and IETF) to network-specific
- configurations.
-
- Key Functionalities:
- - Intent Processing: Translate and process network slice intents
- - Slice Management: Create, modify, and delete network slices
- - NRP (Network Resource Partition) Mapping: Match slice requirements with available resources
- - Slice Realization: Convert intents to specific network configurations (L2VPN, L3VPN)
+ Network Slice Controller (NSC) - Manages network slice lifecycle across domains.
+
+ Handles translation, mapping, planning, and realization of network slice intents
+ from 3GPP and IETF formats to SDN controller configurations.
"""
- def __init__(self, controller_type = "TFS"):
+ def __init__(self, controller_type: str = "TFS") -> None:
"""
Initialize the Network Slice Controller.
Args:
- controller_type (str): Flag to determine if configurations
- should be uploaded to Teraflow or IXIA system.
-
- Attributes:
- controller_type (str): Flag for Teraflow or Ixia upload
- response (dict): Stores slice creation responses
- start_time (float): Tracks slice setup start time
- end_time (float): Tracks slice setup end time
- setup_time (float): Total time taken for slice setup in milliseconds
+ controller_type (str, optional): Target SDN controller ("TFS", "IXIA", "E2E", "RESTCONF").
"""
- self.controller_type = controller_type
-
- self.path = ""
- self.response = []
- self.start_time = 0
- self.end_time = 0
- self.setup_time = 0
-
- def nsc(self, intent_json, slice_id=None, old_service_id=None):
+ self.controller_type: str = controller_type
+ self.path: str = ""
+ self.response: list[dict[str, Any]] = []
+ self.start_time: float = 0.0
+ self.end_time: float = 0.0
+ self.setup_time: float = 0.0
+
+ def nsc(
+ self,
+ intent_json: dict[str, Any],
+ slice_id: str | None = None,
+ old_service_id: str | None = None
+ ) -> dict[str, Any]:
"""
- Main Network Slice Controller method to process and realize network slice intents.
-
- Workflow:
- 1. Load IETF template
- 2. Process intent (detect format, translate if needed)
- 3. Extract slice data
- 4. Store slice information
- 5. Map slice to Network Resource Pool (NRP)
- 6. Realize slice configuration
- 7. Send configuration to network controllers
+ Main Network Slice Controller workflow to process and realize network slice intents.
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
+ intent_json (dict[str, Any]): Network slice intent in 3GPP or IETF format.
+ slice_id (str, optional): Existing slice identifier for modification. Defaults to None.
+ old_service_id (str, optional): Old service identifier to delete when modifying. Defaults to None.
Returns:
- dict: Contains slice creation responses and setup time in milliseconds
-
+ dict[str, Any]: Contains slice creation/update responses and setup time in milliseconds.
"""
- # Start performance tracking
self.start_time = time.perf_counter()
+ requests: dict[str, list[Any]] = {"services": []}
+ services: list[dict[str, Any]] = []
- # Reset requests
- requests = {"services":[]}
- response = None
-
- # Process intent (translate if 3GPP)
ietf_intents = nbi_processor(intent_json)
-
- is_update = True if slice_id else False
+ is_update = bool(slice_id)
+ cs_result: dict[str, Any] | None = None
for intent in ietf_intents:
logging.debug(intent)
payload = {
"intent": intent,
- "is_update": is_update
+ "is_update": is_update,
+ "slice_id": slice_id,
}
- # Mapper
+ logging.debug(f"Is Update: {is_update}")
+ logging.debug(f"Slice ID: {slice_id}")
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)
- # Realizer
+ if is_update and current_app.config.get("PLANNER_TYPE") == "CHANGE_SCHEDULER" and isinstance(rules, dict):
+ cs_result = rules
+
+ self.response = build_response(intent, self.response, controller_type=self.controller_type)
+ logging.warning(self.response)
+
for service in services:
- request = realizer(service, controller_type=self.controller_type, response = self.response, rules = rules)
- # Store slice request details
- if request:
+ request = realizer(
+ service,
+ controller_type=self.controller_type,
+ response=self.response,
+ rules=rules,
+ )
+ if request:
requests["services"].append(request)
+
store_data(intent, slice_id, controller_type=self.controller_type)
- # Store the generated template for debugging
dump_templates(intent_json, ietf_intents, requests)
- # Check if there are services to process
if not requests.get("services"):
raise RuntimeError("No service to process.")
- # Send config to controllers
- is_update = True if slice_id else False
try:
- response = send_controller(self.controller_type, requests, is_update=is_update, old_service_id=old_service_id)
+ response = send_controller(
+ self.controller_type,
+ requests,
+ is_update=is_update,
+ old_service_id=old_service_id,
+ )
except Exception as e:
for service in services:
- service_id = service.get("id")
- if service_id:
- delete_data(service_id)
+ service_id_to_delete = service.get("id")
+ if service_id_to_delete:
+ delete_data(service_id_to_delete)
raise Exception(f"Controller upload failed: {e}")
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}")
+ _subscribe_to_alerts(ietf_intents, slice_id)
- # End performance tracking
self.end_time = time.perf_counter()
setup_time = (self.end_time - self.start_time) * 1000
- return {
+ modification_summary = {
"slices": self.response,
- "setup_time": setup_time
+ "setup_time": setup_time,
}
-
- def monitoring(self, slice_id, slo_sle_template):
- """
- Monitor the status of a specific network slice.
- Workflow:
- 1. Validate the provided slice_id
- 2. Request the planner the shortest path for the slice
- 3. Request the realizer to retrieve the metrics for the links of the specified slice
- 4. Realizer retrieves the metrics from the controller for the specified slice
- 5. Request mapper to aggregate the metrics and store them in the database
- 6. Return the monitoring information for the specified slice
+ if isinstance(cs_result, dict):
+ cs_result["slice_modification"] = modification_summary
+ return cs_result
+
+ return modification_summary
+
+ def monitoring(self, slice_id: str, slo_sle_template: dict[str, Any]) -> dict[str, Any]:
+ """
+ Monitor status and SLO compliance of a specific network slice.
Args:
- slice_id (str): The identifier of the network slice to monitor
- slo_sle_template (dict): The SLO-SLE template for the network slice
- sdps (list): The SDPs for the network slice
+ slice_id (str): Network slice identifier.
+ slo_sle_template (dict[str, Any]): SLO-SLE template for compliance evaluation.
Returns:
- dict: Contains monitoring information for the specified slice
+ dict[str, Any]: Aggregated monitoring metrics and compliance status.
"""
+ if current_app.config.get("DUMMY_MODE"):
+ raise Exception("Dummy mode is enabled. Cannot monitor slice with dummy mode.")
+
payload = {
"slice_id": slice_id,
- "slo_sle_template": slo_sle_template
+ "slo_sle_template": slo_sle_template,
}
- # Request the realizer to retrieve the metrics for the links of the specified slice
realizer(payload, action="MONITOR", controller_type=self.controller_type)
+ metrics: dict[str, Any] = mapper(payload, action="MONITOR")
+
+ slo_compliance = metrics.get("slo_sle_compliance", {}) if isinstance(metrics, dict) else {}
+ is_compliant = slo_compliance.get("is_compliant", True)
+
+ if not is_compliant:
+ logging.warning(
+ f"SLO/SLE compliance violation (is_compliant=False) detected for slice '{slice_id}'."
+ )
+ planner_type = current_app.config.get("PLANNER_TYPE")
+ if planner_type == "CHANGE_SCHEDULER":
+ logging.info(f"Triggering automatic reconfig via self.nsc with planner_type='CHANGE_SCHEDULER' for slice '{slice_id}'...")
+ try:
+ intent = None
+ try:
+ xpath = "/ietf-network-slice-service:network-slice-services"
+ intent = get_data_store(xpath)
+ except Exception as e:
+ logging.warning(f"Could not retrieve slice '{slice_id}' from sysrepo: {e}")
+
+ if not intent:
+ try:
+ from src.database.db import get_data
+ slice_db_entry = get_data(slice_id)
+ intent = slice_db_entry.get("intent") if isinstance(slice_db_entry, dict) else None
+ except Exception as e:
+ logging.warning(f"Could not retrieve slice '{slice_id}' from DB: {e}")
+
+ if not intent:
+ intent = {
+ "ietf-network-slice-service:network-slice-services": {
+ "slice-service": [{"id": slice_id}]
+ }
+ }
+
+ if isinstance(intent, dict) and "network-slice-services" in intent:
+ intent["ietf-network-slice-service:network-slice-services"] = intent.pop("network-slice-services")
+
+ reconfig_res = self.nsc(intent, slice_id=slice_id, planner_type="CHANGE_SCHEDULER")
+ if isinstance(metrics, dict):
+ metrics["reconfig_result"] = reconfig_res
+ except Exception as e:
+ logging.error(f"Automatic reconfig failed for slice '{slice_id}': {e}")
+ if isinstance(metrics, dict):
+ metrics["reconfig_error"] = str(e)
+
+ return metrics
+
+
+
+
- # Request the mapper to aggregate the metrics and store them in the database
- metrics = mapper(payload, action="MONITOR")
- return metrics
\ No newline at end of file
diff --git a/src/mapper/aggregate_monitoring.py b/src/mapper/aggregate_monitoring.py
index 91d3c0f0cccd79addda2241c0fe0f8edbf4dcb10..5007c2cfce912efebdea414905fea7d0862a72ec 100644
--- a/src/mapper/aggregate_monitoring.py
+++ b/src/mapper/aggregate_monitoring.py
@@ -1,64 +1,79 @@
-import logging
-import time
-from flask import current_app
-from src.database.telemetry_client_db import get_telemetry, upsert_telemetry
-from src.utils.safe_get import safe_get
-
-
-def aggregate_monitoring(slice_id, slo_sle_template):
- """
- Aggregates monitoring data for a given slice
-
- Args:
- slice_id (str): The identifier of the network slice.
- slo_sle_template (dict): The SLO-SLE template for the network slice.
- """
-
- if slice_id in current_app.config["TELEMETRY_CACHE"]:
- slos = safe_get(slo_sle_template, ["slo-policy", "metric-bound"])
- slo_bandwidth = [ s["bound"] for s in slos if s.get("metric-type", "") == "ietf-network-slice-service:two-way-bandwidth" ][0] if slos else 0
- slo_latency = [ s["bound"] for s in slos if s.get("metric-type", "") == "ietf-network-slice-service:two-way-delay-maximum" ][0] if slos else "N/A"
-
- links = current_app.config["TELEMETRY_CACHE"][slice_id]
-
- if not links:
- raise Exception(f"No telemetry data available for slice '{slice_id}'")
-
- bandwidth = min([link["bandwidth"] for _, link in links.items()])
- latency = sum([link["latency"] for _, link in links.items()])
-
- violated_metrics = []
-
- if slo_latency != "N/A" and latency > float(slo_latency):
- violated_metrics.append("latency")
-
- if slo_bandwidth != 0 and bandwidth < float(slo_bandwidth):
- violated_metrics.append("bandwidth")
-
- telemetry_json = {
- "latency": latency,
- "bandwidth": bandwidth
- }
-
- compliance_json = {
- "is_compliant": len(violated_metrics) == 0,
- "violated_metrics": violated_metrics
- }
-
- current_timestamp = time.time()
-
- upsert_telemetry(
- slice_id=slice_id,
- telemetry_list=telemetry_json,
- slo_sle_compliance=compliance_json,
- timestamp=current_timestamp
- )
-
- return {
- "slice_id": slice_id,
- "telemetry_list": telemetry_json,
- "slo_sle_compliance": compliance_json,
- "timestamp": current_timestamp
- }
- else:
- raise Exception(f"Telemetry cache for slice '{slice_id}' is not initialized. Please make sure the SDN controller is sending telemetry data for this slice.")
\ No newline at end of file
+import time
+from typing import Any
+
+from flask import current_app
+
+from src.database.telemetry_client_db import upsert_telemetry
+from src.utils.safe_get import safe_get
+
+
+def aggregate_monitoring(slice_id: str, slo_sle_template: dict[str, Any] | None) -> dict[str, Any]:
+ """
+ Aggregates monitoring data for a given slice.
+
+ Args:
+ slice_id (str): The identifier of the network slice.
+ slo_sle_template (dict[str, Any] | None): The SLO-SLE template for the network slice.
+
+ Returns:
+ dict[str, Any]: Aggregated telemetry record.
+
+ Raises:
+ Exception: If telemetry cache is not initialized or no telemetry data is available.
+ """
+ telemetry_cache = current_app.config.get("TELEMETRY_CACHE", {})
+ if slice_id not in telemetry_cache:
+ raise Exception(
+ f"Telemetry cache for slice '{slice_id}' is not initialized. "
+ "Please make sure the SDN controller is sending telemetry data for this slice."
+ )
+
+ links = telemetry_cache[slice_id]
+ if not links:
+ raise Exception(f"No telemetry data available for slice '{slice_id}'")
+
+ slos = safe_get(slo_sle_template, ["slo-policy", "metric-bound"]) or []
+ slo_bandwidth = next(
+ (s["bound"] for s in slos if s.get("metric-type", "") == "ietf-network-slice-service:two-way-bandwidth"),
+ 0,
+ )
+ slo_latency = next(
+ (s["bound"] for s in slos if s.get("metric-type", "") == "ietf-network-slice-service:two-way-delay-maximum"),
+ "N/A",
+ )
+
+ bandwidth = min(link["bandwidth"] for link in links.values())
+ latency = sum(link["latency"] for link in links.values())
+
+ violated_metrics: list[str] = []
+ if slo_latency != "N/A" and latency > float(slo_latency):
+ violated_metrics.append("latency")
+
+ if slo_bandwidth != 0 and bandwidth < float(slo_bandwidth):
+ violated_metrics.append("bandwidth")
+
+ telemetry_json = {
+ "latency": latency,
+ "bandwidth": bandwidth,
+ }
+
+ compliance_json = {
+ "is_compliant": len(violated_metrics) == 0,
+ "violated_metrics": violated_metrics,
+ }
+
+ current_timestamp = time.time()
+
+ upsert_telemetry(
+ slice_id=slice_id,
+ telemetry_list=telemetry_json,
+ slo_sle_compliance=compliance_json,
+ timestamp=current_timestamp,
+ )
+
+ return {
+ "slice_id": slice_id,
+ "telemetry_list": telemetry_json,
+ "slo_sle_compliance": compliance_json,
+ "timestamp": current_timestamp,
+ }
\ No newline at end of file
diff --git a/src/mapper/extract_sdp_info.py b/src/mapper/extract_sdp_info.py
index 1c778314350e66567867933542574ecb70071d27..d9d33fe07a8189f5ff4592638d625ef9e232d3d9 100644
--- a/src/mapper/extract_sdp_info.py
+++ b/src/mapper/extract_sdp_info.py
@@ -15,6 +15,7 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
import logging
+
from src.utils.safe_get import safe_get
diff --git a/src/mapper/get_service_template.py b/src/mapper/get_service_template.py
index d7b5976139f8ded0ffaf82a04088018083659db4..4352b11c313e2d3002c973e994fa9311f74444e1 100644
--- a/src/mapper/get_service_template.py
+++ b/src/mapper/get_service_template.py
@@ -15,8 +15,10 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
from src.utils.safe_get import safe_get
+
from .get_template import get_template
+
def get_service_template(service_element, available_templates):
"""
Extract template from service element.
diff --git a/src/mapper/main.py b/src/mapper/main.py
index 30577e298af4d8d21ab9d553666161b776c10664..a6b8b7640909ed0373550499155f93afd0f9432a 100644
--- a/src/mapper/main.py
+++ b/src/mapper/main.py
@@ -15,172 +15,201 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
import logging
+import uuid
+from typing import Any
+
+from flask import current_app
+
+from src.database.service_db import save_data, delete_by_slice_id
+from src.database.sysrepo_store import get_data_store, normalize_libyang_data
from src.planner.planner import Planner
+from src.realizer.main import realizer
from src.utils.safe_get import safe_get
-from .slo_viability import slo_viability
-from .get_service_template import get_service_template
-from .process_connnectivity import process_connectivity
+
from .aggregate_monitoring import aggregate_monitoring
-from src.realizer.main import realizer
-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
+from .get_service_template import get_service_template
+from .process_connnectivity import normalize_connectivity_type, process_connectivity
+from .slo_viability import slo_viability
-def mapper(payload, controller_type="TFS", action="CREATE"):
- """
- Map an IETF network slice intent to the most suitable Network Resource Partition (NRP).
- This method:
- 1. If NRP is enabled, retrieves the current NRP view
- 2. Extracts Service Level Objectives (SLOs) from the intent
- 3. Finds NRPs that can meet the SLO requirements
- 4. Selects the best NRP based on viability and availability
- 5. Attaches the slice to the selected NRP or creates a new one
- 6. If planner is enabled, computes the optimal path for the slice
+def _handle_nrp_mapping(ietf_intent: dict[str, Any]) -> bool:
+ """Evaluate and assign NRP for slice intent if NRP is enabled."""
+ nrp_view = realizer(None, True, "READ")
+ slos = safe_get(
+ ietf_intent,
+ [
+ "ietf-network-slice-service:network-slice-services",
+ "slo-sle-templates",
+ "slo-sle-template",
+ 0,
+ "slo-policy",
+ "metric-bound",
+ ],
+ )
+ if not slos:
+ return True
+
+ candidates = [
+ (nrp, slo_viability(slos, nrp)[1])
+ for nrp in nrp_view
+ if slo_viability(slos, nrp)[0] and nrp.get("available")
+ ]
+ logging.debug(f"Candidates: {candidates}")
+
+ best_nrp = max(candidates, key=lambda x: x[1])[0] if candidates else None
+ logging.debug(f"Best NRP: {best_nrp}")
+
+ if best_nrp:
+ slice_id = safe_get(
+ ietf_intent,
+ ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "id"],
+ )
+ if slice_id:
+ best_nrp.setdefault("slices", []).append(slice_id)
+ realizer(ietf_intent, True, "UPDATE")
+ return True
+
+ answer = realizer(ietf_intent, True, "CREATE", best_nrp)
+ if not answer:
+ logging.error("Slice rejected due to lack of NRPs")
+ return False
+ return True
+
+
+
+def _collect_available_templates(ietf_intent: dict[str, Any]) -> list[dict[str, Any]]:
+ """Retrieve and combine sysrepo datastore and intent SLO/SLE templates."""
+ raw_templates = get_data_store(
+ "/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template"
+ )
+ normalized = normalize_libyang_data(raw_templates)
+ available_templates: list[dict[str, Any]] = safe_get(
+ normalized, ["slo-sle-templates", "slo-sle-template"]
+ ) or []
+
+ intent_templates = safe_get(
+ ietf_intent,
+ ["ietf-network-slice-service:network-slice-services", "slo-sle-templates", "slo-sle-template"],
+ ) or []
+ available_templates.extend(intent_templates)
+ return available_templates
+
+
+def _map_restconf_services(ietf_intent: dict[str, Any], is_update: bool) -> list[dict[str, Any]]:
+ """Map IETF intent into services."""
+ available_templates = _collect_available_templates(ietf_intent)
+ services: list[dict[str, Any]] = []
+
+ slice_services = safe_get(
+ ietf_intent, ["ietf-network-slice-service:network-slice-services", "slice-service"]
+ ) or []
+
+ for slice_service in slice_services:
+ service_id = safe_get(slice_service, ["id"])
+ way = safe_get(slice_service, ["service-tags", "tag-type", 0, "tag-type-value", 0])
+ service_template = get_service_template(slice_service, available_templates)
+ logging.debug(f"Service template: {service_template}")
+
+ connection_groups = safe_get(slice_service, ["connection-groups", "connection-group"]) or []
+ for connection_group in connection_groups:
+ cg_id = safe_get(connection_group, ["id"])
+ group_id = f"{service_id}-{cg_id}"
+
+ group_template = get_service_template(connection_group, available_templates) or service_template
+ logging.debug(f"Group template: {group_template}")
+ raw_conn_type = safe_get(connection_group, ["connectivity-type"])
+ connectivity_type = normalize_connectivity_type(raw_conn_type)
+
+ constructs = safe_get(connection_group, ["connectivity-construct"]) or []
+ for construct in constructs:
+ construct_id_raw = safe_get(construct, ["id"])
+ full_construct_id = f"{group_id}-{construct_id_raw}-{uuid.uuid4()}"
+
+ final_template = get_service_template(construct, available_templates) or group_template
+ logging.debug(f"Final template: {final_template}")
+
+ sdps = process_connectivity(
+ cg_id,
+ connectivity_type,
+ construct,
+ construct_id_raw,
+ slice_service,
+ )
+ if not sdps:
+ continue
+
+ service = {
+ "id": full_construct_id,
+ "template": final_template,
+ "connectivity_type": connectivity_type,
+ "sdps": sdps,
+ "way": way,
+ }
+ services.append(service)
+
+ if connectivity_type == "point-to-point":
+ break
+
+ if not current_app.config.get("DUMMY_MODE", False):
+ if is_update:
+ delete_by_slice_id(service_id)
+ for service in services:
+ save_data(service_id=service["id"], slice_id=service_id)
+
+ return services
+
+
+def mapper(
+ payload: dict[str, Any],
+ controller_type: str = "TFS",
+ action: str = "CREATE",
+) -> tuple[Any, Any] | dict[str, Any] | None:
+ """
+ Map an IETF network slice intent to suitable Network Resource Partitions or controllers.
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.
- controller_type (str): Type of SDN controller.
- is_update (bool): Whether it is a modification request.
+ payload (dict[str, Any]): Mapping request payload.
+ controller_type (str, optional): SDN controller type. Defaults to "TFS".
+ action (str, optional): Requested action ('CREATE', 'MONITOR'). Defaults to "CREATE".
Returns:
- dict or None: Optimal path if planner is enabled; otherwise, None.
+ tuple[Any, Any] | dict[str, Any] | None: Mapped services/optimal path or telemetry dictionary.
"""
- optimal_path = None
- services = None
-
- if action == "CREATE":
- ietf_intent = payload.get("intent", None)
- services = [ietf_intent]
- if current_app.config["NRP_ENABLED"]:
- # Retrieve NRP view
- nrp_view = realizer(None, True, "READ")
-
- # Extract Service Level Objectives (SLOs) from the intent
- slos = ietf_intent["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["slo-policy"]["metric-bound"]
- if slos:
- # Find candidate NRPs that can meet the SLO requirements
- candidates = [
- (nrp, slo_viability(slos, nrp)[1])
- for nrp in nrp_view
- if slo_viability(slos, nrp)[0] and nrp["available"]
- ]
- logging.debug(f"Candidates: {candidates}")
-
- # Select the best NRP based on candidates
- best_nrp = max(candidates, key=lambda x: x[1])[0] if candidates else None
- logging.debug(f"Best NRP: {best_nrp}")
-
- if best_nrp:
- best_nrp["slices"].append(ietf_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"])
- # Update NRP view
- realizer(ietf_intent, True, "UPDATE")
- # TODO Here we should put how the slice is attached to an already created nrp
- else:
- # Request the controller to create a new NRP that meets the SLOs
- answer = realizer(ietf_intent, True, "CREATE", best_nrp)
- if not answer:
- logging.error("Slice rejected due to lack of NRPs")
- return None
- # TODO Here we should put how the slice is attached to the new nrp
-
- if current_app.config["PLANNER_ENABLED"]:
+ match action:
+ case "CREATE":
+ ietf_intent = payload.get("intent")
+ services: Any = [ietf_intent]
+ optimal_path = None
+ slice_id = payload.get("slice_id", None)
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":
- # Initialize available templates
- templates = get_data_store("/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template")
- normalized_templates = normalize_libyang_data(templates)
- logging.debug(f"Normalized templates: {normalized_templates}")
- available_templates = safe_get(normalized_templates, ["slo-sle-templates", "slo-sle-template"]) or []
-
- # Add templates from intent
- for template in (safe_get(ietf_intent, ["ietf-network-slice-service:network-slice-services","slo-sle-templates", "slo-sle-template"]) or []):
- available_templates.append(template)
- logging.debug(f"Available templates: {available_templates}")
-
- services = []
-
- # Process each slice service
- for slice_service in (safe_get(ietf_intent, ["ietf-network-slice-service:network-slice-services", "slice-service"]) or []):
- service_id = safe_get(slice_service, ["id"])
- logging.debug(f"Service ID: {service_id}")
-
- way = safe_get(slice_service, ['service-tags', 'tag-type', 0, 'tag-type-value', 0])
- logging.debug(f"Way: {way}")
-
- # Get service-level template
- service_template = get_service_template(slice_service, available_templates)
- logging.debug(f"Service Template: {service_template}")
-
- # Process connection groups
- for connection_group in safe_get(slice_service, ["connection-groups", "connection-group"]):
- connection_group_id = safe_get(connection_group, ['id'])
- group_id = f"{service_id}-{connection_group_id}"
- logging.debug(f"Group ID: {group_id}")
-
- # Start with service-level template for this group
- template = service_template # Reset template for each connection group
-
- # Override template if specified at group level
- group_template = get_service_template(connection_group, available_templates)
- if group_template is not None:
- template = group_template
- logging.debug(f"Group Template: {template}")
-
- connectivity_type = safe_get(connection_group, ['connectivity-type'])
- logging.debug(f"Connectivity Type: {connectivity_type}")
-
- # Process connectivity constructs
- for connectivity_construct in safe_get(connection_group, ["connectivity-construct"]):
- connectivity_construct_id = safe_get(connectivity_construct, ['id'])
- construct_id = f"{group_id}-{connectivity_construct_id}"
- logging.debug(f"Construct ID: {construct_id}")
-
- # Start with group-level template for this construct
- final_template = template # Reset template for each connectivity construct
-
- # Override template if specified at construct level
- construct_template = get_service_template(connectivity_construct, available_templates)
- if construct_template is not None:
- final_template = construct_template
- logging.debug(f"Final Template: {final_template}")
-
- # Process SDPs based on connectivity type
- sdps = process_connectivity(
- connection_group_id,
- connectivity_type,
- connectivity_construct,
- connectivity_construct_id,
- slice_service
- )
- logging.debug(f"SDPs: {sdps}")
- if sdps: # Only append if SDPs were found
- service = {
- "id": construct_id,
- "template": final_template,
- "connectivity_type": connectivity_type,
- "sdps": sdps,
- "way": way
- }
- services.append(service)
- if not current_app.config["DUMMY_MODE"]:
- # Save mapping from service_id to slice_id
- save_data(service_id=safe_get(service, ["id"]), slice_id=service_id)
- logging.debug(f"Service added: {service}")
-
- # Break only for point-to-point
- if connectivity_type == "point-to-point":
- break
- elif action == "MONITOR":
- logging.debug("Mapper action: MONITOR")
- slice_id = payload.get("slice_id", None)
- slo_sle_template = payload.get("slo_sle_template", None)
- metrics = aggregate_monitoring(slice_id, slo_sle_template)
- return metrics
-
- return services, optimal_path
\ No newline at end of file
+
+ if current_app.config.get("NRP_ENABLED", False):
+ if not _handle_nrp_mapping(ietf_intent):
+ return None
+
+ planner_type = current_app.config.get("PLANNER_TYPE", None)
+ if current_app.config.get("PLANNER_ENABLED", False):
+ if not current_app.config.get("DUMMY_MODE", False) or planner_type == "ENERGY":
+ if is_update and planner_type == "CHANGE_SCHEDULER":
+ reconfig_data = realizer({"slice_id": slice_id}, action="RECONFIG", controller_type=controller_type)
+ optimal_path = Planner().planner(reconfig_data, planner_type, is_update=is_update)
+ else:
+ optimal_path = Planner().planner(ietf_intent, planner_type, is_update=is_update)
+
+ logging.debug(f"Optimal path: {optimal_path}")
+
+ if controller_type == "RESTCONF":
+ logging.debug(f"SLICE ID is: {slice_id}")
+ services = _map_restconf_services(ietf_intent, is_update)
+
+ return services, optimal_path
+
+ case "MONITOR":
+ logging.debug("Mapper action: MONITOR")
+ slice_id = payload.get("slice_id")
+ slo_sle_template = payload.get("slo_sle_template")
+ return aggregate_monitoring(slice_id, slo_sle_template)
+
+ case _:
+ return None, None
+
diff --git a/src/mapper/process_connnectivity.py b/src/mapper/process_connnectivity.py
index 8dc1209440ac442fe92866936105c33622e704ba..4b51a99b191002126cb7f7efcef3328daab274da 100644
--- a/src/mapper/process_connnectivity.py
+++ b/src/mapper/process_connnectivity.py
@@ -1,89 +1,164 @@
-# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-# This file is an original contribution from Telefonica Innovación Digital S.L.
-
-from src.utils.safe_get import safe_get
-from .extract_sdp_info import extract_sdp_info
-
-def process_connectivity(connection_group_id, connectivity_type, connectivity_construct, connectivity_construct_id, slice_service):
- """
- Process connectivity construct based on type.
-
- Args:
- connectivity_type: Type of connectivity
- connectivity_construct: Dictionary with connectivity configuration
- slice_service: Parent slice service
-
- Returns:
- List of tuples: (sdp_info, direction)
- """
- sdps = []
- if connectivity_type == "ietf-vpn-common:any-to-any":
- a2a_list = safe_get(connectivity_construct, ["a2a-sdp"]) or []
- for sdp in a2a_list:
- sdp, match_criteria = extract_sdp_info(sdp, slice_service, connection_group_id, connectivity_construct_id)
- sdp = {
- "sdp": sdp,
- "match_criteria": match_criteria,
- "type": "both"
- }
- sdps.append(sdp)
-
- elif connectivity_type == "ietf-vpn-common:hub-spoke":
- # Process sender
- sender_sdp = safe_get(connectivity_construct, ["p2mp-sender-sdp"])
- if sender_sdp:
- sdp_source, match_criteria = extract_sdp_info(sender_sdp, slice_service, connection_group_id, connectivity_construct_id)
- sdp = {
- "sdp": sdp_source,
- "match_criteria": match_criteria,
- "type": "sender"
- }
- sdps.append(sdp)
-
- # Process receivers
- for sdp in safe_get(connectivity_construct, ["p2mp-receiver-sdp"]):
- sdp_info, match_criteria = extract_sdp_info(sdp, slice_service, connection_group_id, connectivity_construct_id)
- sdp = {
- "sdp": sdp_info,
- "match_criteria": match_criteria,
- "type": "receiver"
- }
- sdps.append(sdp)
-
- elif connectivity_type == "point-to-point":
- # Process sender
- sender_sdp = safe_get(connectivity_construct, ["p2p-sender-sdp"])
- if sender_sdp:
- sdp_source, match_criteria = extract_sdp_info(sender_sdp, slice_service, connection_group_id, connectivity_construct_id)
- sdp = {
- "sdp": sdp_source,
- "match_criteria": match_criteria,
- "type": "sender"
- }
- sdps.append(sdp)
-
- # Process receiver
- receiver_sdp = safe_get(connectivity_construct, ["p2p-receiver-sdp"])
- if receiver_sdp:
- sdp_destination, match_criteria = extract_sdp_info(receiver_sdp, slice_service, connection_group_id, connectivity_construct_id)
- sdp = {
- "sdp": sdp_destination,
- "match_criteria": match_criteria,
- "type": "receiver"
- }
- sdps.append(sdp)
-
- return sdps
\ No newline at end of file
+# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# This file is an original contribution from Telefonica Innovación Digital S.L.
+
+from typing import Any
+
+from src.utils.safe_get import safe_get
+
+from .extract_sdp_info import extract_sdp_info
+
+
+def normalize_connectivity_type(connectivity_type: str | None) -> str:
+ """
+ Normalize connectivity type to standard format: 'point-to-point', 'any-to-any', or 'hub-spoke'.
+
+ Args:
+ connectivity_type (str | None): Raw connectivity string (e.g. 'ietf-vpn-common:any-to-any').
+
+ Returns:
+ str: Normalized connectivity type name.
+ """
+ if not connectivity_type or not isinstance(connectivity_type, str):
+ return ""
+ return connectivity_type.split(":")[-1].strip().lower()
+
+
+def _process_any_to_any(
+ connectivity_construct: dict[str, Any],
+ slice_service: dict[str, Any],
+ connection_group_id: str,
+ connectivity_construct_id: str,
+) -> list[dict[str, Any]]:
+ """Process any-to-any connectivity SDPs."""
+ sdps: list[dict[str, Any]] = []
+ a2a_list = safe_get(connectivity_construct, ["a2a-sdp"]) or []
+ for sdp_id in a2a_list:
+ sdp_info, match_criteria = extract_sdp_info(
+ sdp_id, slice_service, connection_group_id, connectivity_construct_id
+ )
+ sdps.append({
+ "sdp": sdp_info,
+ "match_criteria": match_criteria,
+ "type": "both",
+ })
+ return sdps
+
+
+def _process_hub_spoke(
+ connectivity_construct: dict[str, Any],
+ slice_service: dict[str, Any],
+ connection_group_id: str,
+ connectivity_construct_id: str,
+) -> list[dict[str, Any]]:
+ """Process hub-and-spoke (P2MP) sender and receiver SDPs."""
+ sdps: list[dict[str, Any]] = []
+
+ sender_sdp = safe_get(connectivity_construct, ["p2mp-sender-sdp"])
+ if sender_sdp:
+ sdp_source, match_criteria = extract_sdp_info(
+ sender_sdp, slice_service, connection_group_id, connectivity_construct_id
+ )
+ sdps.append({
+ "sdp": sdp_source,
+ "match_criteria": match_criteria,
+ "type": "sender",
+ })
+
+ receivers = safe_get(connectivity_construct, ["p2mp-receiver-sdp"]) or []
+ for sdp_id in receivers:
+ sdp_info, match_criteria = extract_sdp_info(
+ sdp_id, slice_service, connection_group_id, connectivity_construct_id
+ )
+ sdps.append({
+ "sdp": sdp_info,
+ "match_criteria": match_criteria,
+ "type": "receiver",
+ })
+
+ return sdps
+
+
+def _process_point_to_point(
+ connectivity_construct: dict[str, Any],
+ slice_service: dict[str, Any],
+ connection_group_id: str,
+ connectivity_construct_id: str,
+) -> list[dict[str, Any]]:
+ """Process point-to-point (P2P) sender and receiver SDPs."""
+ sdps: list[dict[str, Any]] = []
+
+ sender_sdp = safe_get(connectivity_construct, ["p2p-sender-sdp"])
+ if sender_sdp:
+ sdp_source, match_criteria = extract_sdp_info(
+ sender_sdp, slice_service, connection_group_id, connectivity_construct_id
+ )
+ sdps.append({
+ "sdp": sdp_source,
+ "match_criteria": match_criteria,
+ "type": "sender",
+ })
+
+ receiver_sdp = safe_get(connectivity_construct, ["p2p-receiver-sdp"])
+ if receiver_sdp:
+ sdp_destination, match_criteria = extract_sdp_info(
+ receiver_sdp, slice_service, connection_group_id, connectivity_construct_id
+ )
+ sdps.append({
+ "sdp": sdp_destination,
+ "match_criteria": match_criteria,
+ "type": "receiver",
+ })
+
+ return sdps
+
+
+def process_connectivity(
+ connection_group_id: str,
+ connectivity_type: str,
+ connectivity_construct: dict[str, Any],
+ connectivity_construct_id: str,
+ slice_service: dict[str, Any],
+) -> list[dict[str, Any]]:
+ """
+ Process connectivity construct based on normalized type.
+
+ Args:
+ connection_group_id (str): ID of the connection group.
+ connectivity_type (str): Type of connectivity (raw or normalized).
+ connectivity_construct (dict[str, Any]): Connectivity configuration construct.
+ connectivity_construct_id (str): Construct ID.
+ slice_service (dict[str, Any]): Parent slice service dict.
+
+ Returns:
+ list[dict[str, Any]]: List of resolved SDP dictionaries.
+ """
+ normalized_type = normalize_connectivity_type(connectivity_type)
+
+ match normalized_type:
+ case "any-to-any":
+ return _process_any_to_any(
+ connectivity_construct, slice_service, connection_group_id, connectivity_construct_id
+ )
+ case "hub-spoke":
+ return _process_hub_spoke(
+ connectivity_construct, slice_service, connection_group_id, connectivity_construct_id
+ )
+ case "point-to-point":
+ return _process_point_to_point(
+ connectivity_construct, slice_service, connection_group_id, connectivity_construct_id
+ )
+ case _:
+ return []
\ No newline at end of file
diff --git a/src/mapper/slo_viability.py b/src/mapper/slo_viability.py
index cc6ea625c6522e4a0984f9fc7ba00d988d1ffb1a..d1851cbf48b7f74a9006c73eaeee25cee57302c7 100644
--- a/src/mapper/slo_viability.py
+++ b/src/mapper/slo_viability.py
@@ -1,64 +1,91 @@
-# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
-
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-
-# http://www.apache.org/licenses/LICENSE-2.0
-
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-# This file is an original contribution from Telefonica Innovación Digital S.L.
-
-import logging
-
-def slo_viability(slice_slos, nrp_slos):
- """
- Compare Service Level Objectives (SLOs) between a slice and a Network Resource Partition (NRP).
-
- This method assesses whether an NRP can satisfy the SLOs of a network slice.
-
- Args:
- slice_slos (list): Service Level Objectives of the slice
- nrp_slos (dict): Service Level Objectives of the Network Resource Pool
-
- Returns:
- tuple: A boolean indicating viability and a flexibility score
- - First value: True if NRP meets SLOs, False otherwise
- - Second value: A score representing how well the NRP meets the SLOs
- """
- # Define SLO types for maximum and minimum constraints
- slo_type = {
- "max": ["one-way-delay-maximum", "two-way-delay-maximum", "one-way-delay-percentile", "two-way-delay-percentile",
- "one-way-delay-variation-maximum", "two-way-delay-variation-maximum",
- "one-way-delay-variation-percentile", "two-way-delay-variation-percentile",
- "one-way-packet-loss", "two-way-packet-loss"],
- "min": ["one-way-bandwidth", "two-way-bandwidth", "shared-bandwidth"]
- }
- score = 0
- flexibility_scores = []
- for slo in slice_slos:
- for nrp_slo in nrp_slos['slos']:
- if slo["metric-type"] == nrp_slo["metric-type"]:
- # Handle maximum type SLOs
- if slo["metric-type"] in slo_type["max"]:
- logging.debug(f"SLO: {slo}, NRP SLO: {nrp_slo}")
- flexibility = (slo["bound"] - nrp_slo["bound"]) / slo["bound"]
- if slo["bound"] < nrp_slo["bound"]:
- return False, 0 # Does not meet maximum constraint
- # Handle minimum type SLOs
- if slo["metric-type"] in slo_type["min"]:
- logging.debug(f"SLO: {slo}, NRP SLO: {nrp_slo}")
- flexibility = (nrp_slo["bound"] - slo["bound"]) / slo["bound"]
- if slo["bound"] > nrp_slo["bound"]:
- return False, 0 # Does not meet minimum constraint
- flexibility_scores.append(flexibility)
- break # Exit inner loop after finding matching metric
-
- # Calculate final viability score
- score = sum(flexibility_scores) / len(flexibility_scores) if flexibility_scores else 0
- return True, score # If it passed all verifications, the NRP is viable
\ No newline at end of file
+# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
+
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+
+# http://www.apache.org/licenses/LICENSE-2.0
+
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# This file is an original contribution from Telefonica Innovación Digital S.L.
+
+import logging
+from typing import Any, Final
+
+logger = logging.getLogger(__name__)
+
+MAX_SLO_TYPES: Final[frozenset[str]] = frozenset({
+ "one-way-delay-maximum",
+ "two-way-delay-maximum",
+ "one-way-delay-percentile",
+ "two-way-delay-percentile",
+ "one-way-delay-variation-maximum",
+ "two-way-delay-variation-maximum",
+ "one-way-delay-variation-percentile",
+ "two-way-delay-variation-percentile",
+ "one-way-packet-loss",
+ "two-way-packet-loss",
+})
+
+MIN_SLO_TYPES: Final[frozenset[str]] = frozenset({
+ "one-way-bandwidth",
+ "two-way-bandwidth",
+ "shared-bandwidth",
+})
+
+
+def slo_viability(
+ slice_slos: list[dict[str, Any]],
+ nrp_slos: dict[str, Any],
+) -> tuple[bool, float]:
+ """
+ Compare Service Level Objectives (SLOs) between a slice and an NRP.
+
+ Args:
+ slice_slos (list[dict[str, Any]]): Service Level Objectives of the slice.
+ nrp_slos (dict[str, Any]): Service Level Objectives of the Network Resource Partition.
+
+ Returns:
+ tuple[bool, float]: (is_viable, flexibility_score)
+ """
+ nrp_slo_list = nrp_slos.get("slos", [])
+ flexibility_scores: list[float] = []
+
+ for slo in slice_slos:
+ metric_type = slo.get("metric-type")
+ slice_bound = slo.get("bound")
+ if slice_bound is None:
+ continue
+
+ for nrp_slo in nrp_slo_list:
+ if nrp_slo.get("metric-type") != metric_type:
+ continue
+
+ nrp_bound = nrp_slo.get("bound")
+ if nrp_bound is None:
+ continue
+
+ logger.debug("SLO: %s, NRP SLO: %s", slo, nrp_slo)
+ divisor = slice_bound if slice_bound != 0 else 1.0
+
+ if metric_type in MAX_SLO_TYPES:
+ if slice_bound < nrp_bound:
+ return False, 0.0
+ flexibility = (slice_bound - nrp_bound) / divisor
+ flexibility_scores.append(flexibility)
+
+ elif metric_type in MIN_SLO_TYPES:
+ if slice_bound > nrp_bound:
+ return False, 0.0
+ flexibility = (nrp_bound - slice_bound) / divisor
+ flexibility_scores.append(flexibility)
+
+ break
+
+ score = sum(flexibility_scores) / len(flexibility_scores) if flexibility_scores else 0.0
+ return True, score
\ No newline at end of file
diff --git a/src/nbi_processor/detect_format.py b/src/nbi_processor/detect_format.py
index 1bb834fc4ee89fe82c68807e12b34d74de884176..06d2760e8a3f944267503600883d5b20ce06ce01 100644
--- a/src/nbi_processor/detect_format.py
+++ b/src/nbi_processor/detect_format.py
@@ -1,40 +1,51 @@
-# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
-
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-
-# http://www.apache.org/licenses/LICENSE-2.0
-
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-# This file is an original contribution from Telefonica Innovación Digital S.L.
-
-def detect_format(json_data):
- """
- Detect the format of the input network slice intent.
-
- This method identifies whether the input JSON is in 3GPP or IETF format
- by checking for specific keys in the JSON structure.
-
- Args:
- json_data (dict): Input network slice intent JSON
-
- Returns:
- str or None:
- - "IETF" if IETF-specific keys are found
- - "3GPP" if 3GPP-specific keys are found
- - None if no recognizable format is detected
- """
- # Check for IETF-specific key
- if "ietf-network-slice-service:network-slice-services" in json_data:
- return "IETF"
- # Check for 3GPP-specific keys
- if any(key in json_data for key in ["NetworkSlice1", "TopSliceSubnet1", "CNSliceSubnet1", "RANSliceSubnet1"]):
- return "3GPP"
-
- return None
\ No newline at end of file
+# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
+
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+
+# http://www.apache.org/licenses/LICENSE-2.0
+
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# This file is an original contribution from Telefonica Innovación Digital S.L.
+
+from typing import Any, Final
+
+IETF_KEYS: Final[tuple[str, ...]] = (
+ "ietf-network-slice-service:network-slice-services",
+ "network-slice-services",
+)
+
+GPP_KEYS: Final[tuple[str, ...]] = (
+ "NetworkSlice1",
+ "TopSliceSubnet1",
+ "CNSliceSubnet1",
+ "RANSliceSubnet1",
+)
+
+
+def detect_format(json_data: Any) -> str | None:
+ """
+ Detect the format of the input network slice intent.
+
+ Args:
+ json_data (Any): Input network slice intent data.
+
+ Returns:
+ str | None: "IETF", "3GPP", or None if unrecognized.
+ """
+ if not isinstance(json_data, dict):
+ return None
+
+ if any(key in json_data for key in IETF_KEYS):
+ return "IETF"
+
+ if any(key in json_data for key in GPP_KEYS):
+ return "3GPP"
+
+ return None
\ No newline at end of file
diff --git a/src/nbi_processor/main.py b/src/nbi_processor/main.py
index 2e1787c8dd3882078fb5f8eb616b9076a1447d8f..1e74fad1839e0c1055bf302e0452f806dc655588 100644
--- a/src/nbi_processor/main.py
+++ b/src/nbi_processor/main.py
@@ -1,56 +1,55 @@
-# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
-
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-
-# http://www.apache.org/licenses/LICENSE-2.0
-
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-# This file is an original contribution from Telefonica Innovación Digital S.L.
-
-import logging
-from .detect_format import detect_format
-from .translator import translator
-
-def nbi_processor(intent_json):
- """
- Process and translate network slice intents from different formats (3GPP or IETF).
-
- This method detects the input JSON format and converts 3GPP intents to IETF format.
-
- Args:
- intent_json (dict): Input network slice intent in either 3GPP or IETF format.
-
- Returns:
- list: A list of IETF-formatted network slice intents.
-
- Raises:
- ValueError: If the JSON request format is not recognized.
- """
- # Detect the input JSON format (3GPP or IETF)
- format = detect_format(intent_json)
- ietf_intents = []
-
- # TODO Needs to be generalized to support different names of slicesubnets
- # Process different input formats
- if format == "3GPP":
- # Translate each subnet in 3GPP format to IETF format
- for subnet in intent_json["RANSliceSubnet1"]["networkSliceSubnetRef"]:
- ietf_intents.append(translator(intent_json, subnet))
- logging.info(f"3GPP requests translated to IETF template")
- elif format == "IETF":
- # If already in IETF format, add directly
- logging.info(f"IETF intent received")
- ietf_intents.append(intent_json)
- else:
- # Handle unrecognized format
- logging.error(f"JSON request format not recognized")
- raise ValueError("JSON request format not recognized")
-
- return ietf_intents or None
\ No newline at end of file
+# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
+
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+
+# http://www.apache.org/licenses/LICENSE-2.0
+
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# This file is an original contribution from Telefonica Innovación Digital S.L.
+
+import logging
+from typing import Any
+
+from .detect_format import detect_format
+from .translator import translator
+
+logger = logging.getLogger(__name__)
+
+
+def nbi_processor(intent_json: dict[str, Any]) -> list[dict[str, Any]] | None:
+ """
+ Process and translate network slice intents from different formats (3GPP or IETF).
+
+ Args:
+ intent_json (dict[str, Any]): Input network slice intent in either 3GPP or IETF format.
+
+ Returns:
+ list[dict[str, Any]] | None: A list of IETF-formatted network slice intents.
+
+ Raises:
+ ValueError: If the JSON request format is not recognized.
+ """
+ detected_format = detect_format(intent_json)
+ ietf_intents: list[dict[str, Any]] = []
+
+ match detected_format:
+ case "3GPP":
+ for subnet in intent_json["RANSliceSubnet1"]["networkSliceSubnetRef"]:
+ ietf_intents.append(translator(intent_json, subnet))
+ logger.info("3GPP requests translated to IETF template")
+ case "IETF":
+
+ logger.info("IETF intent received")
+ ietf_intents.append(intent_json)
+ case _:
+ logger.error("JSON request format not recognized")
+ raise ValueError("JSON request format not recognized")
+
+ return ietf_intents if ietf_intents else None
\ No newline at end of file
diff --git a/src/nbi_processor/translator.py b/src/nbi_processor/translator.py
index 79c1aaf718afe43fc45c3c525dbeb671586107ae..476e7962e7306f2ba95e0bf1e7825ffff211238a 100644
--- a/src/nbi_processor/translator.py
+++ b/src/nbi_processor/translator.py
@@ -1,107 +1,125 @@
-# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
-
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-
-# http://www.apache.org/licenses/LICENSE-2.0
-
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-# This file is an original contribution from Telefonica Innovación Digital S.L.
-
-import uuid, os
-from src.utils.load_template import load_template
-from src.config.constants import TEMPLATES_PATH
-
-def translator(gpp_intent, subnet):
- """
- Translate a 3GPP network slice intent to IETF format.
-
- This method converts a 3GPP intent into a standardized IETF intent template,
- mapping key parameters such as QoS profiles, service endpoints, and connection details.
-
- Notes:
- - Generates a unique slice service ID using UUID
- - Maps QoS requirements, source/destination endpoints
- - Logs the translated intent to a JSON file for reference
-
- Args:
- gpp_intent (dict): Original 3GPP network slice intent
- subnet (str): Specific subnet reference within the 3GPP intent
-
- Returns:
- dict: Translated IETF-formatted network slice intent
- """
- # Load IETF template and create a copy to modify
- ietf_i = load_template(os.path.join(TEMPLATES_PATH, "ietf_template_empty.json"))
-
- # Extract endpoint transport objects
- ep_transport_objects = gpp_intent[subnet]["EpTransport"]
-
- # Populate template with SLOs (currently supporting QoS profile, latency and bandwidth)
- ietf_i["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["id"] = gpp_intent[ep_transport_objects[0]]["qosProfile"]
-
- profile = gpp_intent.get(subnet, {}).get("SliceProfileList", [{}])[0].get("RANSliceSubnetProfile", {})
-
-
- metrics = {
- ("uLThptPerSliceSubnet", "MaxThpt"): ("one-way-bandwidth", "kbps"),
- ("uLLatency",): ("one-way-delay-maximum", "milliseconds"),
- ("EnergyConsumption",): ("energy_consumption", "Joules"),
- ("EnergyEfficiency",): ("energy_efficiency", "W/bps"),
- ("CarbonEmissions",): ("carbon_emission", "gCO2eq"),
- ("RenewableEnergyUsage",): ("renewable_energy_usage", "rate")
- }
-
- # Aux
- def get_nested(d, keys):
- for k in keys:
- if isinstance(d, dict) and k in d:
- d = d[k]
- else:
- return None
- return d
-
- for key_path, (metric_type, metric_unit) in metrics.items():
- value = get_nested(profile, key_path)
- if value is not None:
- ietf_i["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]\
- ["slo-sle-template"][0]["slo-policy"]["metric-bound"].append({
- "metric-type": metric_type,
- "metric-unit": metric_unit,
- "bound": value
- })
-
-
- # Generate unique slice service ID and description
- ietf_i["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"] = f"slice-service-{uuid.uuid4()}"
- ietf_i["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["description"] = f"Transport network slice mapped with 3GPP slice {next(iter(gpp_intent))}"
- ietf_i["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["slo-sle-template"] = ietf_i["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["id"]
-
- # Configure Source SDP
- ietf_i["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][0]["node-id"] = ep_transport_objects[0].split(" ", 1)[1]
- ietf_i["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][0]["sdp-ip-address"] = gpp_intent[gpp_intent[ep_transport_objects[0]]["EpApplicationRef"][0]]["localAddress"]
- ietf_i["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][0]["service-match-criteria"]["match-criterion"][0]["match-type"][0]["type"] = gpp_intent[ep_transport_objects[0]]["logicalInterfaceInfo"]["logicalInterfaceType"].lower()
- ietf_i["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][0]["service-match-criteria"]["match-criterion"][0]["match-type"][0]["vlan"] = [gpp_intent[ep_transport_objects[0]]["logicalInterfaceInfo"]["logicalInterfaceId"]]
- ietf_i["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][0]["attachment-circuits"]["attachment-circuit"][0]["ac-ipv4-address"] = gpp_intent[ep_transport_objects[0]]["IpAddress"]
- ietf_i["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][0]["attachment-circuits"]["attachment-circuit"][0]["sdp-peering"]["peer-sap-id"] = gpp_intent[ep_transport_objects[0]]["NextHopInfo"]
-
- # Configure Destination SDP
- ietf_i["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][1]["node-id"] = ep_transport_objects[1].split(" ", 1)[1]
- ietf_i["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][1]["sdp-ip-address"] = gpp_intent[gpp_intent[ep_transport_objects[1]]["EpApplicationRef"][0]]["localAddress"]
- ietf_i["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][1]["service-match-criteria"]["match-criterion"][0]["match-type"][0]["type"] = gpp_intent[ep_transport_objects[1]]["logicalInterfaceInfo"]["logicalInterfaceType"].lower()
- ietf_i["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][1]["service-match-criteria"]["match-criterion"][0]["match-type"][0]["vlan"] = [gpp_intent[ep_transport_objects[1]]["logicalInterfaceInfo"]["logicalInterfaceId"]]
- ietf_i["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][1]["attachment-circuits"]["attachment-circuit"][0]["ac-ipv4-address"] = gpp_intent[ep_transport_objects[1]]["IpAddress"]
- ietf_i["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][1]["attachment-circuits"]["attachment-circuit"][0]["sdp-peering"]["peer-sap-id"] = gpp_intent[ep_transport_objects[1]]["NextHopInfo"]
-
- # Configure Connection Group and match-criteria
- ietf_i["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["connection-groups"]["connection-group"][0]["id"] = f"{ep_transport_objects[0].split(' ', 1)[1]}_{ep_transport_objects[1].split(' ', 1)[1]}"
- ietf_i["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][0]["service-match-criteria"]["match-criterion"][0]["target-connection-group-id"] = f"{ep_transport_objects[0].split(' ', 1)[1]}_{ep_transport_objects[1].split(' ', 1)[1]}"
- ietf_i["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][1]["service-match-criteria"]["match-criterion"][0]["target-connection-group-id"] = f"{ep_transport_objects[0].split(' ', 1)[1]}_{ep_transport_objects[1].split(' ', 1)[1]}"
-
- return ietf_i
\ No newline at end of file
+# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
+
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+
+# http://www.apache.org/licenses/LICENSE-2.0
+
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# This file is an original contribution from Telefonica Innovación Digital S.L.
+
import uuid
+from pathlib import Path
+from typing import Any, Final
+
+from src.config.constants import TEMPLATES_PATH
+from src.utils.load_template import load_template
+from src.utils.safe_get import safe_get
+
+METRICS_MAPPING: Final[dict[tuple[str, ...], tuple[str, str]]] = {
+ ("uLThptPerSliceSubnet", "MaxThpt"): ("one-way-bandwidth", "kbps"),
+ ("uLLatency",): ("one-way-delay-maximum", "milliseconds"),
+ ("EnergyConsumption",): ("energy_consumption", "Joules"),
+ ("EnergyEfficiency",): ("energy_efficiency", "W/bps"),
+ ("CarbonEmissions",): ("carbon_emission", "gCO2eq"),
+ ("RenewableEnergyUsage",): ("renewable_energy_usage", "rate"),
+}
+
+
+def _extract_metrics_bounds(profile: dict[str, Any]) -> list[dict[str, Any]]:
+ """Extract metric bounds from a 3GPP slice profile dictionary."""
+ metric_bounds: list[dict[str, Any]] = []
+ for key_path, (metric_type, metric_unit) in METRICS_MAPPING.items():
+ value = safe_get(profile, key_path)
+ if value is not None:
+ metric_bounds.append({
+ "metric-type": metric_type,
+ "metric-unit": metric_unit,
+ "bound": value,
+ })
+ return metric_bounds
+
+
+def _configure_sdp(sdp_entry: dict[str, Any], ep_name: str, gpp_intent: dict[str, Any]) -> str:
+ """Configure SDP node, IP, VLAN, and attachment circuits from 3GPP intent."""
+ ep_data = gpp_intent.get(ep_name, {})
+ node_id = ep_name.split(" ", 1)[1] if " " in ep_name else ep_name
+
+ app_ref = ep_data.get("EpApplicationRef", [None])[0]
+ local_address = gpp_intent.get(app_ref, {}).get("localAddress", "") if app_ref else ""
+ logical_info = ep_data.get("logicalInterfaceInfo", {})
+ interface_type = logical_info.get("logicalInterfaceType", "").lower()
+ vlan_id = logical_info.get("logicalInterfaceId")
+
+ sdp_entry["node-id"] = node_id
+ sdp_entry["sdp-ip-address"] = local_address
+
+ match_criterion = sdp_entry["service-match-criteria"]["match-criterion"][0]
+ match_type = match_criterion["match-type"][0]
+ match_type["type"] = interface_type
+ match_type["vlan"] = [vlan_id] if vlan_id is not None else []
+
+ attachment = sdp_entry["attachment-circuits"]["attachment-circuit"][0]
+ attachment["ac-ipv4-address"] = ep_data.get("IpAddress", "")
+ attachment["sdp-peering"]["peer-sap-id"] = ep_data.get("NextHopInfo", "")
+
+ return node_id
+
+
+def translator(gpp_intent: dict[str, Any], subnet: str) -> dict[str, Any]:
+ """
+ Translate a 3GPP network slice intent to IETF format.
+
+ Args:
+ gpp_intent (dict[str, Any]): Original 3GPP network slice intent.
+ subnet (str): Specific subnet reference within the 3GPP intent.
+
+ Returns:
+ dict[str, Any]: Translated IETF-formatted network slice intent.
+ """
+ template_file = Path(TEMPLATES_PATH) / "ietf_template_empty.json"
+ ietf_intent = load_template(str(template_file))
+ if not isinstance(ietf_intent, dict):
+ raise ValueError("Failed to load empty IETF template.")
+
+ ep_transport_objects = gpp_intent[subnet]["EpTransport"]
+
+
+ # Populate SLO template
+ ep_0 = gpp_intent.get(ep_transport_objects[0], {})
+ qos_profile = ep_0.get("qosProfile", "")
+
+ slo_template = ietf_intent["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]
+ slo_template["id"] = qos_profile
+
+ profile_list = gpp_intent.get(subnet, {}).get("SliceProfileList", [{}])
+ profile = profile_list[0].get("RANSliceSubnetProfile", {}) if profile_list else {}
+
+ bounds = _extract_metrics_bounds(profile)
+ slo_template["slo-policy"]["metric-bound"].extend(bounds)
+
+ # Slice service headers
+ slice_service = ietf_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]
+ slice_id = f"slice-service-{uuid.uuid4()}"
+ gpp_root_key = next(iter(gpp_intent))
+
+ slice_service["id"] = slice_id
+ slice_service["description"] = f"Transport network slice mapped with 3GPP slice {gpp_root_key}"
+ slice_service["slo-sle-template"] = qos_profile
+
+ # Configure SDPs
+ sdp_list = slice_service["sdps"]["sdp"]
+ node_id_src = _configure_sdp(sdp_list[0], ep_transport_objects[0], gpp_intent)
+ node_id_dst = _configure_sdp(sdp_list[1], ep_transport_objects[1], gpp_intent)
+
+ connection_group_id = f"{node_id_src}_{node_id_dst}"
+ slice_service["connection-groups"]["connection-group"][0]["id"] = connection_group_id
+ sdp_list[0]["service-match-criteria"]["match-criterion"][0]["target-connection-group-id"] = connection_group_id
+ sdp_list[1]["service-match-criteria"]["match-criterion"][0]["target-connection-group-id"] = connection_group_id
+
+ return ietf_intent
\ No newline at end of file
diff --git a/src/planner/change_scheduler_planner/change_scheduler.py b/src/planner/change_scheduler_planner/change_scheduler.py
new file mode 100644
index 0000000000000000000000000000000000000000..47ae2218c711d5aba4d23a44866c40243119820c
--- /dev/null
+++ b/src/planner/change_scheduler_planner/change_scheduler.py
@@ -0,0 +1,292 @@
+# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# This file is an original contribution from Telefonica Innovación Digital S.L.
+
+import logging
+from datetime import datetime, timezone
+
+import requests
+from flask import current_app
+
+from src.planner.shortest_path import get_shortest_path, normalize_node_id
+from src.utils.safe_get import safe_get
+
+
+def _find_link_info(u: str, v: str, underlay_links: list) -> tuple[str, str]:
+ """
+ Find source node and link ID in the physical topology matching node pair (u, v).
+ """
+ norm_u = normalize_node_id(u)
+ norm_v = normalize_node_id(v)
+
+ for link in underlay_links:
+ if not isinstance(link, dict):
+ continue
+ src_n = normalize_node_id(str(safe_get(link, ["source", "source-node"]) or ""))
+ dst_n = normalize_node_id(str(safe_get(link, ["destination", "dest-node"]) or ""))
+ if (src_n == norm_u and dst_n == norm_v) or (src_n == norm_v and dst_n == norm_u):
+ source_node = str(safe_get(link, ["source", "source-node"]) or u)
+ source_link_id = link.get("source-link-id") or link.get("link-id") or f"{u}-{v}"
+ return source_node, source_link_id
+
+ return u, f"{u}-{v}"
+
+
+def change_scheduler_planner(
+ slice_id: str,
+ current_path: list = None,
+ network: dict = None,
+ ip: str = None,
+ port: int = None,
+ change_scheduler_url: str = None
+):
+ """
+ Change Scheduler Planner:
+ 1. Receive or retrieve current service path and physical topology.
+ 2. Compute optimal path via get_shortest_path.
+ 3. Calculate differences in nodes and links.
+ 4. Construct IETF TVR topology schedule request body.
+ 5. Send POST request to Change Scheduler at /change-scheduler/request.
+
+ Args:
+ slice_id (str): Identifier of the network slice.
+ current_path (list, optional): Active service path provided by realizer.
+ network (dict, optional): Network topology provided by realizer.
+ ip (str, optional): Change Scheduler IP address.
+ port (int, optional): Change Scheduler port.
+ change_scheduler_url (str, optional): Target URL for Change Scheduler service.
+
+ Returns:
+ tuple[dict, int]: Result payload and HTTP status code.
+ """
+ if not change_scheduler_url:
+ cs_ip = ip
+ cs_port = port
+ if not cs_ip or not cs_port:
+ try:
+ cs_ip = cs_ip or current_app.config.get("CHANGE_SCHEDULER_IP", "127.0.0.1")
+ cs_port = cs_port or current_app.config.get("CHANGE_SCHEDULER_PORT", 8090)
+ except RuntimeError:
+ cs_ip = cs_ip or "127.0.0.1"
+ cs_port = cs_port or 8090
+ change_scheduler_url = f"http://{cs_ip}:{cs_port}/change-scheduler/request"
+
+ if not current_path or len(current_path) < 2:
+ raise Exception(f"Invalid current service path for slice '{slice_id}'.")
+
+ # Calculate optimal path
+ src_node = current_path[0]
+ dst_node = current_path[-1]
+
+ optimal_path, opt_code = get_shortest_path(network, src_node, dst_node, directed_graph=False)
+ if opt_code != 200 or not optimal_path:
+ logging.error(f"Failed to calculate shortest path between '{src_node}' and '{dst_node}'")
+ raise Exception(f"Could not compute shortest path from '{src_node}' to '{dst_node}'.")
+
+ # Compare current_path vs optimal_path
+ current_nodes = set(normalize_node_id(n) for n in current_path)
+ optimal_nodes = set(normalize_node_id(n) for n in optimal_path)
+
+ nodes_to_off = current_nodes - optimal_nodes
+ nodes_to_on = optimal_nodes - current_nodes
+
+ current_links = [(current_path[i], current_path[i + 1]) for i in range(len(current_path) - 1)]
+ optimal_links = [(optimal_path[i], optimal_path[i + 1]) for i in range(len(optimal_path) - 1)]
+
+ def canonical_link(u, v):
+ return tuple(sorted([normalize_node_id(u), normalize_node_id(v)]))
+
+ current_canon = set(canonical_link(u, v) for u, v in current_links)
+ optimal_canon = set(canonical_link(u, v) for u, v in optimal_links)
+
+ links_to_off = [link for link in current_links if canonical_link(*link) not in optimal_canon]
+ links_to_on = [link for link in optimal_links if canonical_link(*link) not in current_canon]
+
+ underlay_links = network.get("ietf-network-topology:link", []) if isinstance(network, dict) else []
+
+ now_utc = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+
+ # 5. Build TVR schedule payload
+ links_schedule = []
+ schedule_counter = 1
+
+ for u, v in links_to_off:
+ source_node, source_link_id = _find_link_info(u, v, underlay_links)
+ links_schedule.append({
+ "source-node": source_node,
+ "source-link-id": source_link_id,
+ "available": {
+ "default-link-available": "false",
+ "schedules": [
+ {
+ "schedule-id": schedule_counter,
+ "schedule-type": {
+ "recurrence": {
+ "recurrence-first": {
+ "utc-start-time": now_utc,
+ "duration": 0
+ },
+ "recurrence-bound": {
+ "count": 1
+ },
+ "frequency": 1
+ }
+ },
+ "attr-value": {
+ "link-available": "false"
+ }
+ }
+ ],
+ "time-zone-identifier": "UTC"
+ }
+ })
+ schedule_counter += 1
+
+ for u, v in links_to_on:
+ source_node, source_link_id = _find_link_info(u, v, underlay_links)
+ links_schedule.append({
+ "source-node": source_node,
+ "source-link-id": source_link_id,
+ "available": {
+ "default-link-available": "true",
+ "schedules": [
+ {
+ "schedule-id": schedule_counter,
+ "schedule-type": {
+ "recurrence": {
+ "recurrence-first": {
+ "utc-start-time": now_utc,
+ "duration": 0
+ },
+ "recurrence-bound": {
+ "count": 1
+ },
+ "frequency": 1
+ }
+ },
+ "attr-value": {
+ "link-available": "true"
+ }
+ }
+ ],
+ "time-zone-identifier": "UTC"
+ }
+ })
+ schedule_counter += 1
+
+ nodes_schedule = []
+ for node_name in sorted(nodes_to_off):
+ nodes_schedule.append({
+ "node-id": node_name,
+ "available": {
+ "default-node-available": "false",
+ "schedules": [
+ {
+ "schedule-id": schedule_counter,
+ "schedule-type": {
+ "recurrence": {
+ "recurrence-first": {
+ "utc-start-time": now_utc,
+ "duration": 0
+ },
+ "recurrence-bound": {
+ "count": 1
+ },
+ "frequency": 1
+ }
+ },
+ "attr-value": {
+ "node-available": "false"
+ }
+ }
+ ],
+ "time-zone-identifier": "UTC"
+ }
+ })
+ schedule_counter += 1
+
+ for node_name in sorted(nodes_to_on):
+ nodes_schedule.append({
+ "node-id": node_name,
+ "available": {
+ "default-node-available": "true",
+ "schedules": [
+ {
+ "schedule-id": schedule_counter,
+ "schedule-type": {
+ "recurrence": {
+ "recurrence-first": {
+ "utc-start-time": now_utc,
+ "duration": 0
+ },
+ "recurrence-bound": {
+ "count": 1
+ },
+ "frequency": 1
+ }
+ },
+ "attr-value": {
+ "node-available": "true"
+ }
+ }
+ ],
+ "time-zone-identifier": "UTC"
+ }
+ })
+ schedule_counter += 1
+
+ tvr_topology_schedule = {
+ "links": links_schedule
+ }
+ if nodes_schedule:
+ tvr_topology_schedule["nodes"] = nodes_schedule
+
+ payload = {
+ "ietf-tvr-topology:topology-schedule": tvr_topology_schedule
+ }
+
+ # 6. Send request to Change Scheduler
+ if current_app.config.get("DUMMY_MODE", False):
+ return {
+ "slice_id": slice_id,
+ "new_path": optimal_path,
+ "request_payload": payload,
+ "response": {"status": "feasible", "setup_time": 0.0},
+ }
+
+ logging.info(f"Sending Change Scheduler request to '{change_scheduler_url}': {payload}")
+ headers = {"Content-Type": "application/json"}
+ try:
+ resp = requests.post(change_scheduler_url, json=payload, headers=headers, timeout=30)
+ status_code = resp.status_code
+ try:
+ resp_data = resp.json()
+ except Exception:
+ resp_data = resp.text
+
+ if status_code not in (200, 201, 202):
+ raise Exception(f"Change Scheduler request failed with status {status_code}: {resp_data}")
+
+ return {
+ "slice_id": slice_id,
+ "new_path": optimal_path,
+ "request_payload": payload,
+ "response": resp_data
+ }
+ except Exception as e:
+ logging.error(f"Error sending request to Change Scheduler at '{change_scheduler_url}': {e}")
+ raise Exception(f"Failed to connect to Change Scheduler: {e}")
+
+
diff --git a/src/planner/e2e_optical_planner/e2e_optical.py b/src/planner/e2e_optical_planner/e2e_optical.py
index 75854b4e75c8ea9a51f43386ba17640e9d8b1b7f..ee187198360d0653768eaafd3d9a95f2daa2f1e2 100644
--- a/src/planner/e2e_optical_planner/e2e_optical.py
+++ b/src/planner/e2e_optical_planner/e2e_optical.py
@@ -12,10 +12,11 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+import json
import logging
+
import requests
-import json
-from src.utils.safe_get import safe_get
+
def e2e_optical_planner(intent, ip: str, action: str = "create") -> dict:
"""
diff --git a/src/planner/energy_planner/energy.py b/src/planner/energy_planner/energy.py
index 5f23c5c736f2ce7741c51c5772597277c7287500..00978ae931dbefb478d5129a21ff356079f5c515 100644
--- a/src/planner/energy_planner/energy.py
+++ b/src/planner/energy_planner/energy.py
@@ -1,393 +1,333 @@
-# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
-
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-
-# http://www.apache.org/licenses/LICENSE-2.0
-
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-# This file is an original contribution from Telefonica Innovación Digital S.L.
-
-import logging, random, os, json, heapq
-from src.config.constants import SRC_PATH
-from flask import current_app
-from src.utils.safe_get import safe_get
-
-
-def energy_planner(intent):
- """
- Plan an optimal network path based on energy consumption metrics.
-
- This function calculates the most energy-efficient path between source
- and destination nodes, considering energy consumption, carbon emissions,
- energy efficiency, and renewable energy usage constraints.
-
- Args:
- intent (dict): Network slice intent containing service delivery points
- and energy-related SLO constraints
-
- Returns:
- list or None: Ordered list of node names representing the optimal path,
- or None if no valid path is found or topology is not recognized
-
- Notes:
- - Only supports topology with nodes A through G
- - Can use external PCE or internal Dijkstra-based algorithm
- - Considers DLOS (Delay and Loss Objectives) for energy metrics:
- EC (Energy Consumption), CE (Carbon Emission),
- EE (Energy Efficiency), URE (Renewable Energy Usage)
-
- Raises:
- Exception: For errors in energy metrics or topology retrieval
- """
- energy_metrics = retrieve_energy()
- topology = retrieve_topology()
- source = safe_get(intent, ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "sdps", "sdp", 0, "node-id"])
- destination = safe_get(intent, ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "sdps", "sdp", 1, "node-id"])
- optimal_path = []
- allowed_ids = {"A", "B", "C", "D", "E", "F", "G"}
-
- if source not in allowed_ids or destination not in allowed_ids:
- logging.warning(f"Topology not recognized (source: {source}, destination: {destination}). Skipping energy-based planning.")
- return None
-
- # If using an external PCE
- if current_app.config["PCE_EXTERNAL"]:
- logging.debug("Using external PCE for path planning")
- def build_slice_input(node_source, node_destination):
- """Build input format for external PCE slice computation."""
- return {
- "clientName": "demo-client",
- "requestId": random.randint(1000, 9999),
- "sites": [node_source["nodeId"], node_destination["nodeId"]],
- "graph": {
- "nodes": [
- {
- "nodeId": node_source["nodeId"],
- "name": node_source["name"],
- "footprint": node_source["footprint"],
- "sticky": [node_source["nodeId"]]
- },
- {
- "nodeId": node_destination["nodeId"],
- "name": node_destination["name"],
- "footprint": node_destination["footprint"],
- "sticky": [node_destination["nodeId"]]
- }
- ],
- "links": [
- {
- "fromNodeId": node_source["nodeId"],
- "toNodeId": node_destination["nodeId"],
- "bandwidth": 1000000000,
- "metrics": [
- {
- "metric": "DELAY",
- "value": 10,
- "bound": True,
- "required": True
- }
- ]
- }
- ],
- "constraints": {
- "maxVulnerability": 3,
- "maxDeployedServices": 10,
- "metricLimits": []
- }
- }
- }
-
- source = next((node for node in topology["nodes"] if node["name"] == source), None)
- destination = next((node for node in topology["nodes"] if node["name"] == destination), None)
- slice_input = build_slice_input(source, destination)
-
- def simulate_slice_output(input_data):
- """
- Simulate external PCE response for slice computation.
-
- Args:
- input_data (dict): Input data for slice computation
-
- Returns:
- dict: Simulated slice output with path information
- """
- return {
- "input": input_data,
- "slice": {
- "nodes": [
- {"site": 1, "service": 1},
- {"site": 2, "service": 2}
- ],
- "links": [
- {
- "fromNodeId": 1,
- "toNodeId": 2,
- "lspId": 500,
- "path": {
- "ingressNodeId": 1,
- "egressNodeId": 2,
- "hops": [
- {"nodeId": 3, "linkId": "A-C", "portId": 1},
- {"nodeId": 2, "linkId": "C-B", "portId": 2}
- ]
- }
- }
- ],
- "metric": {"value": 9}
- },
- "error": None
- }
-
- slice_output = simulate_slice_output(slice_input)
- # Build optimal path from PCE response
- optimal_path.append(source["name"])
- for link in slice_output["slice"]["links"]:
- for hop in link["path"]["hops"]:
- optimal_path.append(next((node for node in topology["nodes"] if node["nodeId"] == hop['nodeId']), None)["name"])
-
- else:
- logging.debug("Using internal PCE for path planning")
- ietf_dlos = intent["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["slo-policy"]["metric-bound"]
- logging.debug(ietf_dlos)
-
- # Extract DLOS (Delay and Loss Objectives) constraints
- dlos = {
- "EC": next((item.get("bound") for item in ietf_dlos if item.get("metric-type") == "energy_consumption"), None),
- "CE": next((item.get("bound") for item in ietf_dlos if item.get("metric-type") == "carbon_emission"), None),
- "EE": next((item.get("bound") for item in ietf_dlos if item.get("metric-type") == "energy_efficiency"), None),
- "URE": next((item.get("bound") for item in ietf_dlos if item.get("metric-type") == "renewable_energy_usage"), None)
- }
- logging.debug(f"Planning optimal path from {source} to {destination} with DLOS: {dlos}")
- optimal_path = calculate_optimal_path(topology, energy_metrics, source, destination, dlos)
-
- if not optimal_path:
- logging.error("No valid energy path found")
- return None
-
- return optimal_path
-
-
-def retrieve_energy():
- """
- Retrieve energy consumption data for network nodes.
-
- Returns:
- dict: Energy metrics including power consumption, carbon emissions,
- efficiency, and renewable energy usage for each node
-
- Notes:
- TODO: Implement logic to retrieve real-time data from controller
- Currently reads from static JSON file
- """
- with open(os.path.join(SRC_PATH, "planner/energy_planner/energy_ddbb.json"), "r") as archivo:
- energy_metrics = json.load(archivo)
- return energy_metrics
-
-
-def retrieve_topology():
- """
- Retrieve network topology information.
-
- Returns:
- dict: Network topology with nodes and links
-
- Notes:
- - If PCE_EXTERNAL is True, retrieves topology for external PCE format
- - Otherwise retrieves topology in internal format
- TODO: Implement logic to retrieve real-time data from controller
- Currently reads from static JSON files
- """
- if current_app.config["PCE_EXTERNAL"]:
- # TODO: Implement the logic to retrieve topology data from external PCE
- # GET /sss/v1/topology/node and /sss/v1/topology/link
- with open(os.path.join(SRC_PATH, "planner/energy_planner/ext_topo_ddbb.json"), "r") as archivo:
- topology = json.load(archivo)
- else:
- # TODO: Implement the logic to retrieve topology data from controller
- with open(os.path.join(SRC_PATH, "planner/energy_planner/topo_ddbb.json"), "r") as archivo:
- topology = json.load(archivo)
- return topology
-
-
-def calculate_optimal_path(topology, energy_metrics, source, destination, dlos):
- """
- Calculate the optimal path using Dijkstra's algorithm with energy constraints.
-
- This function implements a constrained shortest path algorithm that considers
- energy consumption, carbon emissions, energy efficiency, and renewable energy
- usage as optimization criteria.
-
- Args:
- topology (dict): Network topology with nodes and links
- energy_metrics (dict): Energy consumption data for each node
- source (str): Source node identifier
- destination (str): Destination node identifier
- dlos (dict): Constraint bounds for:
- - EC: Energy Consumption limit
- - CE: Carbon Emission limit
- - EE: Energy Efficiency limit
- - URE: Minimum Renewable Energy Usage
-
- Returns:
- list: Ordered list of node names forming the optimal path,
- or empty list if no valid path exists
-
- Notes:
- - Uses modified Dijkstra's algorithm with multiple constraints
- - Paths violating any DLOS constraint are discarded
- - Node weights computed using compute_node_weight function
- """
- logging.debug("Starting optimal path calculation...")
-
- # Create a dictionary with the weights of each node
- node_data_map = {}
- for node_data in energy_metrics:
- node_id = node_data["name"]
- ec = node_data["typical-power"]
- ce = node_data["carbon-emissions"]
- ee = node_data["efficiency"]
- ure = node_data["renewable-energy-usage"]
-
- total_power_supply = sum(ps["typical-power"] for ps in node_data["power-supply"])
- total_power_boards = sum(b["typical-power"] for b in node_data["boards"])
- total_power_components = sum(c["typical-power"] for c in node_data["components"])
- total_power_transceivers = sum(t["typical-power"] for t in node_data["transceivers"])
-
- logging.debug(f"Node {node_id}: EC={ec}, CE={ce}, EE={ee}, URE={ure}")
- logging.debug(f"Node {node_id}: PS={total_power_supply}, BO={total_power_boards}, CO={total_power_components}, TR={total_power_transceivers}")
-
- weight = compute_node_weight(ec, ce, ee, ure,
- total_power_supply,
- total_power_boards,
- total_power_components,
- total_power_transceivers)
- logging.debug(f"Weight for node {node_id}: {weight}")
-
- node_data_map[node_id] = {
- "weight": weight,
- "ec": ec,
- "ce": ce,
- "ee": ee,
- "ure": ure
- }
-
- # Create a graph representation of the topology
- graph = {}
- for node in topology["ietf-network:networks"]["network"][0]["node"]:
- graph[node["node-id"]] = []
- for link in topology["ietf-network:networks"]["network"][0]["link"]:
- src = link["source"]["source-node"]
- dst = link["destination"]["dest-node"]
- graph[src].append((dst, node_data_map[dst]["weight"]))
- logging.debug(f"Added link: {src} -> {dst} with weight {node_data_map[dst]['weight']}")
-
- # Dijkstra's algorithm with restrictions
- # Queue: (accumulated cost, current node, path, sum_ec, sum_ce, sum_ee, min_ure)
- queue = [(0, source, [], 0, 0, 0, 1)]
- visited = set()
-
- logging.debug(f"Starting search from {source} to {destination} with restrictions: {dlos}")
-
- while queue:
- cost, node, path, sum_ec, sum_ce, sum_ee, min_ure = heapq.heappop(queue)
- logging.debug(f"Exploring node {node} with cost {cost} and path {path + [node]}")
-
- if node in visited:
- logging.debug(f"Node {node} already visited, skipped.")
- continue
- visited.add(node)
- path = path + [node]
-
- node_metrics = node_data_map[node]
- sum_ec += node_metrics["ec"]
- sum_ce += node_metrics["ce"]
- sum_ee += node_metrics["ee"]
- min_ure = min(min_ure, node_metrics["ure"]) if path[:-1] else node_metrics["ure"]
-
- logging.debug(f"Accumulated -> EC: {sum_ec}, CE: {sum_ce}, EE: {sum_ee}, URE min: {min_ure}")
-
- # Check constraint violations
- if dlos["EC"] is not None and sum_ec > dlos["EC"]:
- logging.debug(f"Discarded path {path} for exceeding EC ({sum_ec} > {dlos['EC']})")
- continue
- if dlos["CE"] is not None and sum_ce > dlos["CE"]:
- logging.debug(f"Discarded path {path} for exceeding CE ({sum_ce} > {dlos['CE']})")
- continue
- if dlos["EE"] is not None and sum_ee > dlos["EE"]:
- logging.debug(f"Discarded path {path} for exceeding EE ({sum_ee} > {dlos['EE']})")
- continue
- if dlos["URE"] is not None and min_ure < dlos["URE"]:
- logging.debug(f"Discarded path {path} for not reaching minimum URE ({min_ure} < {dlos['URE']})")
- continue
-
- if node == destination:
- logging.debug(f"Destination {destination} reached with a valid path: {path}")
- return path
-
- for neighbor, weight in graph.get(node, []):
- if neighbor not in visited:
- logging.debug(f"Queue -> neighbour: {neighbor}, weight: {weight}")
- heapq.heappush(queue, (
- cost + weight,
- neighbor,
- path,
- sum_ec,
- sum_ce,
- sum_ee,
- min_ure
- ))
-
- logging.debug("No valid path found that meets the restrictions.")
- return []
-
-
-def compute_node_weight(ec, ce, ee, ure, total_power_supply, total_power_boards,
- total_power_components, total_power_transceivers,
- alpha=1, beta=1, gamma=1, delta=1):
- """
- Calculate node weight based on energy and environmental metrics.
-
- Computes a green index that represents the environmental impact of routing
- traffic through a node, considering power consumption and carbon emissions.
-
- Args:
- ec (float): Base energy consumption of the node
- ce (float): Carbon emissions factor
- ee (float): Energy efficiency metric
- ure (float): Renewable energy usage ratio (0-1)
- total_power_supply (float): Total power from supply units
- total_power_boards (float): Total power consumed by boards
- total_power_components (float): Total power consumed by components
- total_power_transceivers (float): Total power consumed by transceivers
- alpha (float, optional): Weight for energy consumption. Defaults to 1
- beta (float, optional): Weight for carbon emissions. Defaults to 1
- gamma (float, optional): Weight for energy efficiency. Defaults to 1
- delta (float, optional): Weight for renewable energy. Defaults to 1
-
- Returns:
- float: Computed green index representing environmental impact
-
- Notes:
- Formula: green_index = (power_idle + power_traffic) * time / 1000 * (1 - ure) * ce
- - Assumes 100 units of traffic
- - Measured over 1 hour time period
- """
- traffic = 100
- # Measure one hour of traffic
- time = 1
-
- power_idle = ec + total_power_supply + total_power_boards + total_power_components + total_power_transceivers
- power_traffic = traffic * ee
-
- power_total = (power_idle + power_traffic)
-
- green_index = power_total * time / 1000 * (1 - ure) * ce
-
- return green_index
\ No newline at end of file
+# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
+
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+
+# http://www.apache.org/licenses/LICENSE-2.0
+
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# This file is an original contribution from Telefonica Innovación Digital S.L.
+
+import heapq
+import json
+import logging
+import random
+from pathlib import Path
+from typing import Any
+
+from flask import current_app
+
+from src.config.constants import SRC_PATH
+from src.utils.safe_get import safe_get
+
+ALLOWED_ENERGY_NODE_IDS = frozenset({"A", "B", "C", "D", "E", "F", "G"})
+
+
+def _build_slice_input(node_source: dict[str, Any], node_destination: dict[str, Any]) -> dict[str, Any]:
+ """Build input format for external PCE slice computation."""
+ return {
+ "clientName": "demo-client",
+ "requestId": random.randint(1000, 9999),
+ "sites": [node_source["nodeId"], node_destination["nodeId"]],
+ "graph": {
+ "nodes": [
+ {
+ "nodeId": node_source["nodeId"],
+ "name": node_source["name"],
+ "footprint": node_source["footprint"],
+ "sticky": [node_source["nodeId"]],
+ },
+ {
+ "nodeId": node_destination["nodeId"],
+ "name": node_destination["name"],
+ "footprint": node_destination["footprint"],
+ "sticky": [node_destination["nodeId"]],
+ },
+ ],
+ "links": [
+ {
+ "fromNodeId": node_source["nodeId"],
+ "toNodeId": node_destination["nodeId"],
+ "bandwidth": 1000000000,
+ "metrics": [
+ {
+ "metric": "DELAY",
+ "value": 10,
+ "bound": True,
+ "required": True,
+ }
+ ],
+ }
+ ],
+ "constraints": {
+ "maxVulnerability": 3,
+ "maxDeployedServices": 10,
+ "metricLimits": [],
+ },
+ },
+ }
+
+
+def _simulate_slice_output(input_data: dict[str, Any]) -> dict[str, Any]:
+ """Simulate external PCE response for slice computation."""
+ return {
+ "input": input_data,
+ "slice": {
+ "nodes": [
+ {"site": 1, "service": 1},
+ {"site": 2, "service": 2},
+ ],
+ "links": [
+ {
+ "fromNodeId": 1,
+ "toNodeId": 2,
+ "lspId": 500,
+ "path": {
+ "ingressNodeId": 1,
+ "egressNodeId": 2,
+ "hops": [
+ {"nodeId": 3, "linkId": "A-C", "portId": 1},
+ {"nodeId": 2, "linkId": "C-B", "portId": 2},
+ ],
+ },
+ }
+ ],
+ "metric": {"value": 9},
+ },
+ "error": None,
+ }
+
+
+def _plan_external_pce(
+ topology: dict[str, Any],
+ source_name: str,
+ destination_name: str,
+) -> list[str]:
+ """Compute optimal path using external PCE simulation."""
+ logging.debug("Using external PCE for path planning")
+ source_node = next((n for n in topology.get("nodes", []) if n.get("name") == source_name), None)
+ destination_node = next((n for n in topology.get("nodes", []) if n.get("name") == destination_name), None)
+
+ if not source_node or not destination_node:
+ return []
+
+ slice_input = _build_slice_input(source_node, destination_node)
+ slice_output = _simulate_slice_output(slice_input)
+
+ path = [source_node["name"]]
+ for link in slice_output["slice"]["links"]:
+ for hop in link["path"]["hops"]:
+ matched = next((n for n in topology.get("nodes", []) if n.get("nodeId") == hop["nodeId"]), None)
+ if matched:
+ path.append(matched["name"])
+ return path
+
+
+def energy_planner(intent: dict[str, Any]) -> list[str] | None:
+ """
+ Plan an optimal network path based on energy consumption metrics.
+
+ Args:
+ intent (dict[str, Any]): Network slice intent containing SDPs and energy SLOs.
+
+ Returns:
+ list[str] | None: Ordered list of node names representing the optimal path, or None.
+ """
+ energy_metrics = retrieve_energy()
+ topology = retrieve_topology()
+ source = safe_get(
+ intent,
+ ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "sdps", "sdp", 0, "node-id"],
+ )
+ destination = safe_get(
+ intent,
+ ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "sdps", "sdp", 1, "node-id"],
+ )
+
+ if source not in ALLOWED_ENERGY_NODE_IDS or destination not in ALLOWED_ENERGY_NODE_IDS:
+ logging.warning(
+ "Topology not recognized (source: %s, destination: %s). Skipping energy-based planning.",
+ source,
+ destination,
+ )
+ return None
+
+ if current_app.config.get("PCE_EXTERNAL", False):
+ optimal_path = _plan_external_pce(topology, source, destination)
+ else:
+ logging.debug("Using internal PCE for path planning")
+ ietf_dlos = safe_get(
+ intent,
+ [
+ "ietf-network-slice-service:network-slice-services",
+ "slo-sle-templates",
+ "slo-sle-template",
+ 0,
+ "slo-policy",
+ "metric-bound",
+ ],
+ ) or []
+ logging.debug(f"{ietf_dlos}")
+
+ dlos = {
+ "EC": next((item.get("bound") for item in ietf_dlos if item.get("metric-type") == "energy_consumption"), None),
+ "CE": next((item.get("bound") for item in ietf_dlos if item.get("metric-type") == "carbon_emission"), None),
+ "EE": next((item.get("bound") for item in ietf_dlos if item.get("metric-type") == "energy_efficiency"), None),
+ "URE": next((item.get("bound") for item in ietf_dlos if item.get("metric-type") == "renewable_energy_usage"), None),
+ }
+ logging.debug(f"Planning optimal path from {source} to {destination} with DLOS: {dlos}")
+ optimal_path = calculate_optimal_path(topology, energy_metrics, source, destination, dlos)
+
+ if not optimal_path:
+ logging.error("No valid energy path found")
+ return None
+
+ return optimal_path
+
+
+def retrieve_energy() -> list[dict[str, Any]]:
+ """Retrieve energy consumption data for network nodes from JSON database."""
+ file_path = Path(SRC_PATH) / "planner" / "energy_planner" / "energy_ddbb.json"
+ with file_path.open("r", encoding="utf-8") as archivo:
+ return json.load(archivo)
+
+
+def retrieve_topology() -> dict[str, Any]:
+ """Retrieve network topology information."""
+ filename = "ext_topo_ddbb.json" if current_app.config.get("PCE_EXTERNAL", False) else "topo_ddbb.json"
+ file_path = Path(SRC_PATH) / "planner" / "energy_planner" / filename
+ with file_path.open("r", encoding="utf-8") as archivo:
+ return json.load(archivo)
+
+
+def calculate_optimal_path(
+ topology: dict[str, Any],
+ energy_metrics: list[dict[str, Any]],
+ source: str,
+ destination: str,
+ dlos: dict[str, float | None],
+) -> list[str]:
+ """Calculate the optimal path using Dijkstra's algorithm with energy constraints."""
+ logging.debug("Starting optimal path calculation...")
+
+ node_data_map: dict[str, dict[str, Any]] = {}
+ for node_data in energy_metrics:
+ node_id = node_data["name"]
+ ec = node_data["typical-power"]
+ ce = node_data["carbon-emissions"]
+ ee = node_data["efficiency"]
+ ure = node_data["renewable-energy-usage"]
+
+ total_power_supply = sum(ps["typical-power"] for ps in node_data.get("power-supply", []))
+ total_power_boards = sum(b["typical-power"] for b in node_data.get("boards", []))
+ total_power_components = sum(c["typical-power"] for c in node_data.get("components", []))
+ total_power_transceivers = sum(t["typical-power"] for t in node_data.get("transceivers", []))
+
+ weight = compute_node_weight(
+ ec,
+ ce,
+ ee,
+ ure,
+ total_power_supply,
+ total_power_boards,
+ total_power_components,
+ total_power_transceivers,
+ )
+ node_data_map[node_id] = {
+ "weight": weight,
+ "ec": ec,
+ "ce": ce,
+ "ee": ee,
+ "ure": ure,
+ }
+
+ graph: dict[str, list[tuple[str, float]]] = {}
+ network_list = safe_get(topology, ["ietf-network:networks", "network"]) or []
+ if network_list:
+ net = network_list[0]
+ for node in net.get("node", []):
+ graph[node["node-id"]] = []
+ for link in net.get("link", []):
+ src = link["source"]["source-node"]
+ dst = link["destination"]["dest-node"]
+ if dst in node_data_map:
+ graph.setdefault(src, []).append((dst, node_data_map[dst]["weight"]))
+
+ queue: list[tuple[float, str, list[str], float, float, float, float]] = [(0.0, source, [], 0.0, 0.0, 0.0, 1.0)]
+ visited: set[str] = set()
+
+ while queue:
+ cost, node, path, sum_ec, sum_ce, sum_ee, min_ure = heapq.heappop(queue)
+ if node in visited:
+ continue
+ visited.add(node)
+ current_path = path + [node]
+
+ if node not in node_data_map:
+ continue
+
+ node_metrics = node_data_map[node]
+ sum_ec += node_metrics["ec"]
+ sum_ce += node_metrics["ce"]
+ sum_ee += node_metrics["ee"]
+ min_ure = min(min_ure, node_metrics["ure"]) if path else node_metrics["ure"]
+
+ if dlos["EC"] is not None and sum_ec > dlos["EC"]:
+ continue
+ if dlos["CE"] is not None and sum_ce > dlos["CE"]:
+ continue
+ if dlos["EE"] is not None and sum_ee > dlos["EE"]:
+ continue
+ if dlos["URE"] is not None and min_ure < dlos["URE"]:
+ continue
+
+ if node == destination:
+ return current_path
+
+ for neighbor, weight in graph.get(node, []):
+ if neighbor not in visited:
+ heapq.heappush(
+ queue,
+ (
+ cost + weight,
+ neighbor,
+ current_path,
+ sum_ec,
+ sum_ce,
+ sum_ee,
+ min_ure,
+ ),
+ )
+
+ return []
+
+
+def compute_node_weight(
+ ec: float,
+ ce: float,
+ ee: float,
+ ure: float,
+ total_power_supply: float,
+ total_power_boards: float,
+ total_power_components: float,
+ total_power_transceivers: float,
+ alpha: float = 1,
+ beta: float = 1,
+ gamma: float = 1,
+ delta: float = 1,
+) -> float:
+ """Calculate node weight based on energy and environmental metrics."""
+ traffic = 100
+ time = 1
+
+ power_idle = ec + total_power_supply + total_power_boards + total_power_components + total_power_transceivers
+ power_traffic = traffic * ee
+ power_total = power_idle + power_traffic
+
+ return power_total * time / 1000 * (1 - ure) * ce
\ No newline at end of file
diff --git a/src/planner/hrat_planner/hrat.py b/src/planner/hrat_planner/hrat.py
index 9fd147b77d4175f5ed7a7a3f517c3b2226aeaef9..4b7f3528d262f70a3e8b3b55d400ae46a3afb30c 100644
--- a/src/planner/hrat_planner/hrat.py
+++ b/src/planner/hrat_planner/hrat.py
@@ -14,7 +14,10 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
-import logging, requests
+import logging
+
+import requests
+
def hrat_planner(data: str, ip: str, action: str = "create") -> dict:
"""
diff --git a/src/planner/planner.py b/src/planner/planner.py
index b73891092363dae3bb841f2762e8caf29add7aca..f75d0eb38e12b9adb980397f5a816386230bc2fd 100644
--- a/src/planner/planner.py
+++ b/src/planner/planner.py
@@ -15,42 +15,108 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
import logging
-from src.planner.energy_planner.energy import energy_planner
-from src.planner.hrat_planner.hrat import hrat_planner
-from src.planner.e2e_optical_planner.e2e_optical import e2e_optical_planner
+from typing import Any
+
from flask import current_app
+from src.planner.change_scheduler_planner.change_scheduler import (
+ change_scheduler_planner,
+)
+from src.planner.e2e_optical_planner.e2e_optical import e2e_optical_planner
+from src.planner.energy_planner.energy import energy_planner
+from src.planner.hrat_planner.hrat import hrat_planner
+
class Planner:
- """
- Planner class to compute optimal paths for network slices.
- Uses different strategies based on configuration.
- """
- """
- Planner class to compute the optimal path for a network slice based on energy consumption and topology.
- """
-
- def planner(self, intent, type, is_update=False):
+ """Planner class to compute optimal paths for network slices using different strategies."""
+
+ def _handle_change_scheduler(self, intent: Any) -> Any:
+ """Handle execution parameters for change scheduler planner."""
+ slice_id = None
+ current_path = None
+ network = None
+
+ if isinstance(intent, str):
+ slice_id = intent
+ elif isinstance(intent, dict):
+ slice_id = intent.get("slice_id")
+ current_path = intent.get("service_path")
+ network = intent.get("network_topology")
+
+ kwargs: dict[str, Any] = {}
+ try:
+ if "CHANGE_SCHEDULER_IP" in current_app.config:
+ kwargs["ip"] = current_app.config["CHANGE_SCHEDULER_IP"]
+ if "CHANGE_SCHEDULER_PORT" in current_app.config:
+ kwargs["port"] = current_app.config["CHANGE_SCHEDULER_PORT"]
+ except RuntimeError:
+ pass
+
+ return change_scheduler_planner(
+ slice_id,
+ current_path=current_path,
+ network=network,
+ **kwargs,
+ )
+
+
+ def _is_change_scheduler_viable(self, cs_result: Any) -> bool:
+ """Evaluate whether Change Scheduler response confirms viability."""
+ if not isinstance(cs_result, dict):
+ return False
+
+ if cs_result.get("success", True) or cs_result.get("status_code") in (200, 201, 202):
+ return True
+
+ cs_response = cs_result.get("response", {})
+ if isinstance(cs_response, dict):
+ if cs_response.get("status") in ("VIABLE", "SCHEDULED") or cs_response.get("viable") is True:
+ return True
+
+ return False
+
+ def planner(
+ self,
+ intent: Any,
+ type: str | None = None,
+ planner_type: str | None = None,
+ is_update: bool = False,
+ ) -> Any:
"""
- Plan the optimal path for a network slice based on energy consumption and topology.
+ Plan the optimal path for a network slice based on the requested strategy.
Args:
- intent (dict): Network slice intent
- type (str): Planner type (ENERGY, HRAT, TFS_OPTICAL)
- is_update (bool): Whether this is an update/modification request
+ intent (Any): Network slice intent data or identifier.
+ type (str, optional): Planner strategy (legacy param).
+ planner_type (str, optional): Planner strategy.
+ is_update (bool, optional): Whether this is an update request. Defaults to False.
Returns:
- dict or None: Planner result or None if type is invalid
+ Any: Result of the planner execution or None if unsupported.
"""
- # Log selected planner type
- logging.info(f"Planner type selected: {type}")
- # Use energy planner strategy
- if type == "ENERGY" : return energy_planner(intent)
- # Use HRAT planner with configured IP
- elif type == "HRAT" : return hrat_planner(intent, current_app.config["HRAT_IP"])
- # Use E2E optical planner with configured IP
- 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
+ effective_type = type or planner_type or ""
+ logging.info(f"Planner type selected: {effective_type}")
+
+ match effective_type:
+
+ case "ENERGY":
+ return energy_planner(intent)
+ case "HRAT":
+ return hrat_planner(intent, current_app.config["HRAT_IP"])
+ case "E2E_OPTICAL":
+ action = "update" if is_update else "create"
+ return e2e_optical_planner(
+ intent, current_app.config["E2E_OPTICAL_IP"], action=action
+ )
+ case "CHANGE_SCHEDULER":
+ if not is_update:
+ logging.info("Change Scheduler planner only supports update requests.")
+ return None
+ response = self._handle_change_scheduler(intent)
+ if not self._is_change_scheduler_viable(response):
+ logging.warning(f"Change Scheduler declared operation not viable for slice.")
+ return None
+ return response
+ case _:
+ return None
+
diff --git a/src/planner/shortest_path.py b/src/planner/shortest_path.py
index 8a68aff4df8186a0004d8d38eb4c891ebea0f965..7a79ac5a88797e69a6b991a8750c23f694d53f40 100644
--- a/src/planner/shortest_path.py
+++ b/src/planner/shortest_path.py
@@ -1,93 +1,111 @@
-# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
-
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-
-# http://www.apache.org/licenses/LICENSE-2.0
-
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-# This file is an original contribution from Telefonica Innovación Digital S.L.
-
-import logging, traceback
-from collections import deque
-from typing import Dict, Tuple
-
-import logging, traceback
-from collections import deque
-from typing import Dict, Tuple
-
-def normalize_node_id(node_id: str) -> str:
- if isinstance(node_id, str) and node_id.startswith("urn:tfs:node:"):
- return node_id.split(":")[-1]
- return node_id
-
-def get_shortest_path(network, src_node_id, dst_node_id, directed_graph: bool = False) -> Tuple[Dict[str, any], int]:
- try:
- source_node_idx = normalize_node_id(src_node_id)
- destination_node_idx = normalize_node_id(dst_node_id)
-
- nodes = network["node"]
- links = network["ietf-network-topology:link"]
-
- # Build graph
- graph = {
- normalize_node_id(node["node-id"]): set()
- for node in nodes
- }
-
- for link in links:
- src = normalize_node_id(link["source"]["source-node"])
- dst = normalize_node_id(link["destination"]["dest-node"])
-
- graph[src].add(dst)
- if not directed_graph:
- graph[dst].add(src)
-
- # BFS
- visited = {node: False for node in graph}
- prev = {node: None for node in graph}
-
- if source_node_idx not in graph:
- return {"message": f"Source node '{source_node_idx}' not found"}, 404
-
- if destination_node_idx not in graph:
- return {"message": f"Destination node '{destination_node_idx}' not found"}, 404
-
- queue = deque([source_node_idx])
- visited[source_node_idx] = True
-
- while queue:
- current = queue.popleft()
-
- if current == destination_node_idx:
- break
-
- for neighbor in graph[current]:
- if not visited[neighbor]:
- visited[neighbor] = True
- prev[neighbor] = current
- queue.append(neighbor)
-
- if not visited[destination_node_idx]:
- return {"message": "No path found"}, 404
-
- # Rebuild path
- path = []
- at = destination_node_idx
-
- while at is not None:
- path.append(at)
- at = prev[at]
-
- path.reverse()
- return path, 200
-
- except Exception as e:
- logging.exception("Error calculating shortest path")
- raise
\ No newline at end of file
+# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
+
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+
+# http://www.apache.org/licenses/LICENSE-2.0
+
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# This file is an original contribution from Telefonica Innovación Digital S.L.
+
+import logging
+from collections import deque
+from typing import Any
+
+logger = logging.getLogger(__name__)
+
+
+def normalize_node_id(node_id: str) -> str:
+ """Normalize node identifier by stripping the TFS URN prefix if present."""
+ if isinstance(node_id, str) and node_id.startswith("urn:tfs:node:"):
+ return node_id.split(":")[-1]
+ return node_id
+
+
+def get_shortest_path(
+ network: dict[str, Any],
+ src_node_id: str,
+ dst_node_id: str,
+ directed_graph: bool = False,
+) -> tuple[list[str] | dict[str, str], int]:
+ """
+ Calculate the shortest path between source and destination nodes using BFS.
+
+ Args:
+ network (dict[str, Any]): Network topology containing nodes and links.
+ src_node_id (str): Source node identifier.
+ dst_node_id (str): Destination node identifier.
+ directed_graph (bool, optional): Treat links as directed. Defaults to False.
+
+ Returns:
+ tuple[list[str] | dict[str, str], int]: (path_list or error_dict, http_status_code)
+ """
+ try:
+ source_node_idx = normalize_node_id(src_node_id)
+ destination_node_idx = normalize_node_id(dst_node_id)
+
+ nodes = network.get("node", [])
+ links = network.get("ietf-network-topology:link", [])
+
+ # Build graph
+ graph: dict[str, set[str]] = {
+ normalize_node_id(node["node-id"]): set()
+ for node in nodes
+ }
+
+ for link in links:
+ src = normalize_node_id(link["source"]["source-node"])
+ dst = normalize_node_id(link["destination"]["dest-node"])
+
+ if src in graph:
+ graph[src].add(dst)
+ if not directed_graph and dst in graph:
+ graph[dst].add(src)
+
+ if source_node_idx not in graph:
+ return {"message": f"Source node '{source_node_idx}' not found"}, 404
+
+ if destination_node_idx not in graph:
+ return {"message": f"Destination node '{destination_node_idx}' not found"}, 404
+
+ # BFS
+ visited: dict[str, bool] = {node: False for node in graph}
+ prev: dict[str, str | None] = {node: None for node in graph}
+
+ queue: deque[str] = deque([source_node_idx])
+ visited[source_node_idx] = True
+
+ while queue:
+ current = queue.popleft()
+ if current == destination_node_idx:
+ break
+
+ for neighbor in graph.get(current, set()):
+ if not visited.get(neighbor, False):
+ visited[neighbor] = True
+ prev[neighbor] = current
+ queue.append(neighbor)
+
+ if not visited.get(destination_node_idx, False):
+ return {"message": "No path found"}, 404
+
+ # Rebuild path
+ path: list[str] = []
+ at: str | None = destination_node_idx
+
+ while at is not None:
+ path.append(at)
+ at = prev.get(at)
+
+ path.reverse()
+ return path, 200
+
+ except Exception:
+ logger.exception("Error calculating shortest path")
+ raise
\ No newline at end of file
diff --git a/src/realizer/e2e/e2e_connect.py b/src/realizer/e2e/e2e_connect.py
index f4eea6e51d442f4e5be3f1f6082ddd997353825f..a3b81f4a5cd5f0d6dafdf6cf407d3d8606fcb4f0 100644
--- a/src/realizer/e2e/e2e_connect.py
+++ b/src/realizer/e2e/e2e_connect.py
@@ -1,11 +1,11 @@
# Copyright 2022-2026 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.
@@ -14,56 +14,80 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
-from ..tfs.helpers.tfs_connector import tfs_connector
-import logging, requests
+"""Connector for sending end-to-end slice requests to TeraFlowSDN E2E controller."""
+
+from __future__ import annotations
+
+import logging
import os
+from typing import Any
-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).
- """
+from ..tfs.helpers.tfs_connector import tfs_connector
+
+logger = logging.getLogger(__name__)
- def _extract_slice_id(payload):
- if isinstance(payload, dict):
- services = payload.get("services")
- else:
- services = payload
- if not services or not isinstance(services, list):
- return None
+def _extract_slice_id(payload: Any) -> str | None:
+ """Extract slice identifier from E2E service payload."""
+ if isinstance(payload, dict):
+ services = payload.get("services")
+ else:
+ services = payload
+
+ if not services or not isinstance(services, list):
+ return None
+
+ first_service = services[0]
+ if isinstance(first_service, list) and first_service:
+ first_service = first_service[0]
+
+ if not isinstance(first_service, dict):
+ return None
+
+ slice_id = first_service.get("service_id", {}).get("service_uuid", {}).get("uuid")
+ if slice_id:
+ return str(slice_id)
- first_service = services[0]
- if isinstance(first_service, list) and first_service:
- first_service = first_service[0]
+ rule_set = first_service.get("rule_set", {})
+ l3vpn = rule_set.get("l3vpn", {})
+ slice_id = l3vpn.get("tunnel-uuid") or l3vpn.get("uuid")
+ if slice_id:
+ return str(slice_id)
- if not isinstance(first_service, dict):
- return None
+ rule_set_uuid = rule_set.get("uuid")
+ return str(rule_set_uuid) if rule_set_uuid else None
- slice_id = first_service.get("service_id", {}).get("service_uuid", {}).get("uuid")
- if slice_id:
- return slice_id
- rule_set = first_service.get("rule_set", {})
- l3vpn = rule_set.get("l3vpn", {})
- slice_id = l3vpn.get("tunnel-uuid") or l3vpn.get("uuid")
- if slice_id:
- return slice_id
+def e2e_connect(
+ requests: Any,
+ controller_ip: str,
+ is_update: bool = False,
+ old_service_id: str | None = None,
+) -> Any:
+ """Connect end-to-end services in TeraFlowSDN (TFS) controller.
- return rule_set.get("uuid")
+ Args:
+ requests: List or dict of requests to be sent to the TFS E2E controller.
+ controller_ip: IP address of the TFS E2E controller.
+ is_update: If True, updates the service instead of creating it.
+ old_service_id: Old service ID to delete (optional).
+ Returns:
+ Response from the TFS E2E controller.
+ """
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)
+ logger.info(
+ "Connecting end-to-end services in TFS controller at %s with slice ID %s",
+ controller_ip,
+ slice_id,
+ )
+
+ target_ip = os.getenv("E2E_OPTICAL_IP") or controller_ip
+ connector = tfs_connector()
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
+ return connector.ipowdm_put(target_ip, slice_id, payload)
+
+ return connector.ipowdm_post(target_ip, slice_id, requests)
diff --git a/src/realizer/e2e/main.py b/src/realizer/e2e/main.py
index ed63429dfa3c0d99a0becc5f48b89fa6ff821f64..a8e4390dc2b810d7e716fb4a30b64beade828902 100644
--- a/src/realizer/e2e/main.py
+++ b/src/realizer/e2e/main.py
@@ -1,11 +1,11 @@
# Copyright 2022-2026 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.
@@ -14,15 +14,42 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
+"""E2E Orchestrator realization entry point."""
+
+from __future__ import annotations
+
import logging
+from typing import Any
+
from .service_types.del_l3ipowdm_slice import del_l3ipowdm_slice
from .service_types.l3ipowdm_slice import l3ipowdm_slice
-def e2e(ietf_intent, way=None, response=None, rules = None):
- logging.debug(f"E2E Realizer selected: {way}")
- if way == "L3oWDM": realizing_request = l3ipowdm_slice(rules)
- elif way == "DEL_L3oWDM": realizing_request = del_l3ipowdm_slice(rules, response)
- else:
- logging.warning(f"Unsupported way: {way}.")
- realizing_request = None
- return realizing_request
\ No newline at end of file
+logger = logging.getLogger(__name__)
+
+
+def e2e(
+ ietf_intent: Any,
+ way: str | None = None,
+ response: Any = None,
+ rules: Any = None,
+) -> Any:
+ """Generate E2E orchestrator realization request based on technology way.
+
+ Args:
+ ietf_intent: IETF intent dictionary.
+ way: Realization technology way ('L3oWDM', 'DEL_L3oWDM', etc.).
+ response: Response dictionary or context.
+ rules: Dynamic rules dictionary.
+
+ Returns:
+ Realization payload or None if way is unsupported.
+ """
+ logger.debug("E2E Realizer selected: %s", way)
+ match way:
+ case "L3oWDM":
+ return l3ipowdm_slice(rules)
+ case "DEL_L3oWDM":
+ return del_l3ipowdm_slice(rules, response)
+ case _:
+ logger.warning("Unsupported way: %s.", way)
+ return None
diff --git a/src/realizer/e2e/service_types/del_l3ipowdm_slice.py b/src/realizer/e2e/service_types/del_l3ipowdm_slice.py
index 4eb0557c37ad44b130263a3a292987bf5cf9d9a4..0786bfa061ddaf90616ac9761c7efa02e7bea34f 100644
--- a/src/realizer/e2e/service_types/del_l3ipowdm_slice.py
+++ b/src/realizer/e2e/service_types/del_l3ipowdm_slice.py
@@ -1,11 +1,11 @@
# Copyright 2022-2026 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.
@@ -14,164 +14,226 @@
# This file includes original contributions from Telefonica Innovación Digital S.L.
-import logging, os
-from src.config.constants import TEMPLATES_PATH, NBI_L2_PATH
-from src.utils.load_template import load_template
+"""E2E service template builders for deletion and deprovisioning of L3oWDM slices."""
+
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+from typing import Any
+
from flask import current_app
-def del_l3ipowdm_slice(ietf_intent, response):
- """
- Translate slice intent into a TeraFlow service request.
+from src.config.constants import NBI_L2_PATH, TEMPLATES_PATH
+from src.realizer.tfs.helpers.cisco_connector import cisco_connector
+from src.utils.load_template import load_template
+from src.utils.safe_get import safe_get
+
+logger = logging.getLogger(__name__)
+
+
+def _build_webui_del_request(
+ ietf_intent: dict[str, Any],
+ slice_data: dict[str, Any] | None,
+ origin_router_id: str,
+ destination_router_id: str,
+) -> dict[str, Any]:
+ """Build WEBUI deletion descriptor structure."""
+ origin_router_if = "0/0/0-GigabitEthernet0/0/0/0"
+ destination_router_if = "0/0/0-GigabitEthernet0/0/0/0"
+
+ tfs_request = load_template(str(Path(TEMPLATES_PATH) / "L2-VPN_template_empty.json"))["services"][0]
+ slice_id = safe_get(
+ ietf_intent,
+ ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "id"],
+ )
+ tfs_request["service_id"]["service_uuid"]["uuid"] = slice_id
+
+ for endpoint in tfs_request["service_endpoint_ids"]:
+ is_first = endpoint is tfs_request["service_endpoint_ids"][0]
+ endpoint["device_id"]["device_uuid"]["uuid"] = origin_router_id if is_first else destination_router_id
+ endpoint["endpoint_uuid"]["uuid"] = origin_router_if if is_first else destination_router_if
+
+ if slice_data:
+ for constraint in slice_data.get("requirements", []):
+ tfs_request["service_constraints"].append({"custom": constraint})
- This method prepares a L2VPN service request by:
- 1. Defining endpoint routers
- 2. Loading a service template
- 3. Generating a unique service UUID
- 4. Configuring service endpoints
- 5. Adding QoS constraints
- 6. Preparing configuration rules for network interfaces
+ for i, config_rule in enumerate(tfs_request["service_config"]["config_rules"][1:], start=1):
+ router_id = origin_router_id if i == 1 else destination_router_id
+ router_if = origin_router_if if i == 1 else destination_router_if
+ resource_value = config_rule["custom"]["resource_value"]
+
+ sdp_index = i - 1
+ vlan_value = safe_get(
+ ietf_intent,
+ [
+ "ietf-network-slice-service:network-slice-services",
+ "slice-service",
+ 0,
+ "sdps",
+ "sdp",
+ sdp_index,
+ "service-match-criteria",
+ "match-criterion",
+ 0,
+ "match-type",
+ 0,
+ "vlan",
+ 0,
+ ],
+ )
+ if vlan_value:
+ resource_value["vlan_id"] = int(vlan_value)
+ resource_value["circuit_id"] = vlan_value
+ resource_value["remote_router"] = destination_router_id if i == 1 else origin_router_id
+ resource_value["ni_name"] = f"ELAN{vlan_value!s}"
+ config_rule["custom"]["resource_key"] = f"/device[{router_id}]/endpoint[{router_if}]/settings"
- Args:
- ietf_intent (dict): IETF-formatted network slice intent.
+ return tfs_request
- Returns:
- dict: A TeraFlow service request for L2VPN configuration.
- """
- # Hardcoded router endpoints
- # TODO (should be dynamically determined)
- origin_router_id = ietf_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][0]["attachment-circuits"]["attachment-circuit"][0]["sdp-peering"]["peer-sap-id"]
- origin_router_if = '0/0/0-GigabitEthernet0/0/0/0'
- destination_router_id = ietf_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][1]["attachment-circuits"]["attachment-circuit"][0]["sdp-peering"]["peer-sap-id"]
- destination_router_if = '0/0/0-GigabitEthernet0/0/0/0'
- id = ietf_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"]
- slice = next((d for d in response if d.get("id") == id), None)
-
- if current_app.config["UPLOAD_TYPE"] == "WEBUI":
- # Load L2VPN service template
- tfs_request = load_template(os.path.join(TEMPLATES_PATH, "L2-VPN_template_empty.json"))["services"][0]
-
- # Configure service UUID
- tfs_request["service_id"]["service_uuid"]["uuid"] = ietf_intent['ietf-network-slice-service:network-slice-services']['slice-service'][0]["id"]
-
- # Configure service endpoints
- for endpoint in tfs_request["service_endpoint_ids"]:
- endpoint["device_id"]["device_uuid"]["uuid"] = origin_router_id if endpoint is tfs_request["service_endpoint_ids"][0] else destination_router_id
- endpoint["endpoint_uuid"]["uuid"] = origin_router_if if endpoint is tfs_request["service_endpoint_ids"][0] else destination_router_if
-
- # Add service constraints
- for constraint in slice.get("requirements", []):
- tfs_request["service_constraints"].append({"custom": constraint})
+def _build_nbi_del_request(
+ ietf_intent: dict[str, Any],
+ origin_router_id: str,
+ destination_router_id: str,
+) -> dict[str, Any]:
+ """Build NBI deletion descriptor structure."""
+ tfs_request = load_template(str(Path(TEMPLATES_PATH) / "ietfL2VPN_template_empty.json"))
+ tfs_request["path"] = NBI_L2_PATH
+
+ full_id = (
+ safe_get(
+ ietf_intent,
+ ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "id"],
+ )
+ or ""
+ )
+ uuid_only = full_id.split("slice-service-")[-1]
+ tfs_request["ietf-l2vpn-svc:vpn-service"][0]["vpn-id"] = uuid_only
+
+ sites = tfs_request["ietf-l2vpn-svc:vpn-service"][0]["site"]
+ sdps = (
+ safe_get(
+ ietf_intent,
+ ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "sdps", "sdp"],
+ )
+ or []
+ )
+
+ for i, site in enumerate(sites):
+ is_origin = i == 0
+ router_id = origin_router_id if is_origin else destination_router_id
+ sdp = sdps[0] if is_origin and len(sdps) > 0 else (sdps[1] if len(sdps) > 1 else {})
+ site["site-id"] = router_id
+ site["site-location"] = sdp.get("node-id")
+ site["site-network-access"]["interface"]["ip-address"] = sdp.get("sdp-ip-address")
- # Add configuration rules
- for i, config_rule in enumerate(tfs_request["service_config"]["config_rules"][1:], start=1):
- router_id = origin_router_id if i == 1 else destination_router_id
- router_if = origin_router_if if i == 1 else destination_router_if
- resource_value = config_rule["custom"]["resource_value"]
-
- sdp_index = i - 1
- vlan_value = ietf_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][sdp_index]["service-match-criteria"]["match-criterion"][0]["match-type"][0]["vlan"][0]
- if vlan_value:
- resource_value["vlan_id"] = int(vlan_value)
- resource_value["circuit_id"] = vlan_value
- resource_value["remote_router"] = destination_router_id if i == 1 else origin_router_id
- resource_value["ni_name"] = 'ELAN{:s}'.format(str(vlan_value))
- config_rule["custom"]["resource_key"] = f"/device[{router_id}]/endpoint[{router_if}]/settings"
-
- elif current_app.config["UPLOAD_TYPE"] == "NBI":
- #self.path = NBI_L2_PATH
- # Load IETF L2VPN service template
- tfs_request = load_template(os.path.join(TEMPLATES_PATH, "ietfL2VPN_template_empty.json"))
-
- # Add path to the request
- tfs_request["path"] = NBI_L2_PATH
-
- # Generate service UUID
- full_id = ietf_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"]
- uuid_only = full_id.split("slice-service-")[-1]
- tfs_request["ietf-l2vpn-svc:vpn-service"][0]["vpn-id"] = uuid_only
-
- # Configure service endpoints
- sites = tfs_request["ietf-l2vpn-svc:vpn-service"][0]["site"]
- sdps = ietf_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"]
-
- for i, site in enumerate(sites):
- is_origin = (i == 0)
- router_id = origin_router_id if is_origin else destination_router_id
- sdp = sdps[0] if is_origin else sdps[1]
- site["site-id"] = router_id
- site["site-location"] = sdp["node-id"]
- site["site-network-access"]["interface"]["ip-address"] = sdp["sdp-ip-address"]
-
- logging.info(f"L2VPN Intent realized\n")
return tfs_request
-def tfs_l2vpn_support(requests):
- """
- Configuration support for L2VPN with path selection based on MPLS traffic-engineering tunnels
+
+def del_l3ipowdm_slice(ietf_intent: dict[str, Any], response: list[dict[str, Any]] | None) -> dict[str, Any] | None:
+ """Translate slice intent into a deprovisioning TeraFlow service request.
Args:
- requests (list): A list of configuration parameters.
+ ietf_intent: IETF formatted network slice intent.
+ response: Existing slice records.
+ Returns:
+ Prepared TFS service deprovisioning request.
"""
- sources={
- "source": "10.60.125.44",
- "config":[]
- }
- destinations={
- "destination": "10.60.125.45",
- "config":[]
- }
+ origin_router_id = safe_get(
+ ietf_intent,
+ [
+ "ietf-network-slice-service:network-slice-services",
+ "slice-service",
+ 0,
+ "sdps",
+ "sdp",
+ 0,
+ "attachment-circuits",
+ "attachment-circuit",
+ 0,
+ "sdp-peering",
+ "peer-sap-id",
+ ],
+ )
+ destination_router_id = safe_get(
+ ietf_intent,
+ [
+ "ietf-network-slice-service:network-slice-services",
+ "slice-service",
+ 0,
+ "sdps",
+ "sdp",
+ 1,
+ "attachment-circuits",
+ "attachment-circuit",
+ 0,
+ "sdp-peering",
+ "peer-sap-id",
+ ],
+ )
+ slice_id = safe_get(
+ ietf_intent,
+ ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "id"],
+ )
+ slice_data = next((d for d in (response or []) if d.get("id") == slice_id), None)
+
+ upload_type = current_app.config.get("UPLOAD_TYPE", "WEBUI")
+ if upload_type == "WEBUI":
+ tfs_request = _build_webui_del_request(ietf_intent, slice_data, origin_router_id, destination_router_id)
+ elif upload_type == "NBI":
+ tfs_request = _build_nbi_del_request(ietf_intent, origin_router_id, destination_router_id)
+ else:
+ logger.warning("Unsupported upload type: %s", upload_type)
+ return None
+
+ logger.info("L2VPN Intent realized for deletion")
+ return tfs_request
+
+
+def tfs_l2vpn_support(requests: list[dict[str, Any]]) -> None:
+ """Configure Cisco routers for L2VPN with MPLS traffic-engineering tunnels."""
+ sources: dict[str, Any] = {"source": "10.60.125.44", "config": []}
+ destinations: dict[str, Any] = {"destination": "10.60.125.45", "config": []}
+
for request in requests:
- # Configure Source Endpoint
temp_source = request["service_config"]["config_rules"][1]["custom"]["resource_value"]
endpoints = request["service_endpoint_ids"]
- config = {
- "ni_name": temp_source["ni_name"],
- "remote_router": temp_source["remote_router"],
- "interface": endpoints[0]["endpoint_uuid"]["uuid"].replace("0/0/0-", ""),
- "vlan" : temp_source["vlan_id"],
- "number" : temp_source["vlan_id"] % 10 + 1
- }
- sources["config"].append(config)
-
- # Configure Destination Endpoint
+ sources["config"].append(
+ {
+ "ni_name": temp_source["ni_name"],
+ "remote_router": temp_source["remote_router"],
+ "interface": endpoints[0]["endpoint_uuid"]["uuid"].replace("0/0/0-", ""),
+ "vlan": temp_source["vlan_id"],
+ "number": temp_source["vlan_id"] % 10 + 1,
+ }
+ )
+
temp_destiny = request["service_config"]["config_rules"][2]["custom"]["resource_value"]
- config = {
- "ni_name": temp_destiny["ni_name"],
- "remote_router": temp_destiny["remote_router"],
- "interface": endpoints[1]["endpoint_uuid"]["uuid"].replace("0/0/3-", ""),
- "vlan" : temp_destiny["vlan_id"],
- "number" : temp_destiny["vlan_id"] % 10 + 1
- }
- destinations["config"].append(config)
-
- #cisco_source = cisco_connector(source_address, ni_name, remote_router, vlan, vlan % 10 + 1)
+ destinations["config"].append(
+ {
+ "ni_name": temp_destiny["ni_name"],
+ "remote_router": temp_destiny["remote_router"],
+ "interface": endpoints[1]["endpoint_uuid"]["uuid"].replace("0/0/3-", ""),
+ "vlan": temp_destiny["vlan_id"],
+ "number": temp_destiny["vlan_id"] % 10 + 1,
+ }
+ )
+
cisco_source = cisco_connector(sources["source"], sources["config"])
- commands = cisco_source.full_create_command_template()
- cisco_source.execute_commands(commands)
+ cisco_source.execute_commands(cisco_source.full_create_command_template())
- #cisco_destiny = cisco_connector(destination_address, ni_name, remote_router, vlan, vlan % 10 + 1)
cisco_destiny = cisco_connector(destinations["destination"], destinations["config"])
- commands = cisco_destiny.full_create_command_template()
- cisco_destiny.execute_commands(commands)
+ cisco_destiny.execute_commands(cisco_destiny.full_create_command_template())
-def tfs_l2vpn_delete():
- """
- Delete L2VPN configurations from Cisco devices.
-
- This method removes L2VPN configurations from Cisco routers
- Notes:
- - Uses cisco_connector to generate and execute deletion commands
- - Clears Network Interface (NI) settings
- """
- # Delete Source Endpoint Configuration
+def tfs_l2vpn_delete() -> None:
+ """Delete L2VPN configurations from Cisco devices."""
source_address = "10.60.125.44"
cisco_source = cisco_connector(source_address)
cisco_source.execute_commands(cisco_source.create_command_template_delete())
- # Delete Destination Endpoint Configuration
destination_address = "10.60.125.45"
cisco_destiny = cisco_connector(destination_address)
- cisco_destiny.execute_commands(cisco_destiny.create_command_template_delete())
\ No newline at end of file
+ cisco_destiny.execute_commands(cisco_destiny.create_command_template_delete())
diff --git a/src/realizer/e2e/service_types/l3ipowdm_slice.py b/src/realizer/e2e/service_types/l3ipowdm_slice.py
index 6e6f9d3b533b0f2c9500ff0adc4e856a8e61ea92..51e70743d8e3e2a6af686cc76bf0623f5e2b1c02 100644
--- a/src/realizer/e2e/service_types/l3ipowdm_slice.py
+++ b/src/realizer/e2e/service_types/l3ipowdm_slice.py
@@ -1,11 +1,11 @@
# Copyright 2022-2026 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.
@@ -14,155 +14,173 @@
# This file includes original contributions from Telefonica Innovación Digital S.L.
-import logging, os
+"""E2E service template builders for L3 over WDM optical slices."""
+
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+from typing import Any
+
from src.config.constants import TEMPLATES_PATH
from src.utils.load_template import load_template
-def l3ipowdm_slice(rules):
- """
- Prepare a Optical service request for an optical slice.
+logger = logging.getLogger(__name__)
+
- This method prepares a TeraFlow service request for an optical slice by:
- 1. Defining endpoint routers
- 2. Loading a service template
- 3. Generating a unique service UUID
- 4. Configuring service endpoints
- 5. Adding QoS constraints
+def optical_slice_template(template: dict[str, Any], rule: dict[str, Any]) -> dict[str, Any]:
+ """Complete the optical slice template with topology, SIP, and context metadata.
Args:
- ietf_intent (dict): IETF-formatted network slice intent.
- rules (dict, optional): Configuration rules for the optical slice.
+ template: Base optical slice template dictionary.
+ rule: Rules dictionary containing action configurations.
Returns:
- dict: A TeraFlow service request for optical slice configuration.
+ Populated template dictionary.
"""
- transceiver_params = []
- bandwidth = 0
+ for action in rule.get("actions", []):
+ content = action.get("content", {})
+ nodes = content.get("node", [])
+ for node in nodes:
+ for onp in node.get("owned-node-edge-point", []):
+ if "media-channel-node-edge-point-spec" in onp:
+ onp["tapi-photonic-media:media-channel-node-edge-point-spec"] = onp.pop(
+ "media-channel-node-edge-point-spec"
+ )
+
+ actions = rule.get("actions", [])
+ first_content = actions[0].get("content", {}) if actions else {}
+ sips_data = first_content.get("service-interface-point", [])
+
+ template_sips = template.get("tapi-common:context", {}).get("service-interface-point", [])
+ for i, sip in enumerate(template_sips):
+ if i < len(sips_data):
+ sip["uuid"] = sips_data[i]["uuid"]
+
+ topo_context = template["tapi-common:context"]["tapi-topology:topology-context"]["topology"][0]
+ nodes_template = topo_context["node"]
+ for new_node in first_content.get("node", []):
+ nodes_template.append(new_node)
- logging.debug(f"Preparing L3oWDM slice with rules: {rules}")
- tfs_requests = []
- for rule in rules["actions"]:
- logging.debug(f"Processing rule: {rule['type']}")
- if rule["type"] == "CREATE_OPTICAL_SLICE":
- tfs_request = load_template(os.path.join(TEMPLATES_PATH, "Optical_slice.json"))
- request = optical_slice_template(tfs_request, rules)
- logging.debug(f"Sending Optical Slice to Optical Controller {request}")
- tfs_requests.append(request)
-
- elif rule["type"] == "PROVISION_MEDIA_CHANNEL_OLS_PATH":
-
- origin_router_id = rule["content"]["src-sip-uuid"]
- destination_router_id = rule["content"]["dest-sip-uuid"]
- direction = rule["content"]["direction"]
- bandwidth = rule["content"]["bandwidth-ghz"]
- service_uuid = rule["content"]["ols-path-uuid"]
- tenant_uuid = rule["tenant-uuid"]
- layer_protocol_name = rule["content"]["layer-protocol-name"]
- layer_protocol_qualifier = rule["content"]["layer-protocol-qualifier"]
- lower_frequency_mhz = rule["content"]["lower-frequency-mhz"]
- upper_frequency_mhz = rule["content"]["upper-frequency-mhz"]
- link_uuid_path = rule["content"]["link-uuid-path"]
- granularity = rule["content"]["adjustment-granularity"]
- grid = rule["content"]["grid-type"]
-
- tfs_request = load_template(os.path.join(TEMPLATES_PATH, "TAPI_service.json"))
-
- tfs_request["services"][0]["service_id"]["service_uuid"]["uuid"] = service_uuid
- config_rules = tfs_request["services"][0]["service_config"]["config_rules"][0]
-
- config_rules["tapi_lsp"]["rule_set"]["src"] = origin_router_id
- config_rules["tapi_lsp"]["rule_set"]["dst"] = destination_router_id
- config_rules["tapi_lsp"]["rule_set"]["uuid"] = service_uuid
- config_rules["tapi_lsp"]["rule_set"]["bw"] = str(bandwidth)
- config_rules["tapi_lsp"]["rule_set"]["tenant_uuid"] = tenant_uuid
- config_rules["tapi_lsp"]["rule_set"]["direction"] = direction
- config_rules["tapi_lsp"]["rule_set"]["layer_protocol_name"] = layer_protocol_name
- config_rules["tapi_lsp"]["rule_set"]["layer_protocol_qualifier"] = layer_protocol_qualifier
- config_rules["tapi_lsp"]["rule_set"]["lower_frequency_mhz"] = str(lower_frequency_mhz)
- config_rules["tapi_lsp"]["rule_set"]["upper_frequency_mhz"] = str(upper_frequency_mhz)
- config_rules["tapi_lsp"]["rule_set"]["link_uuid_path"] = link_uuid_path
- config_rules["tapi_lsp"]["rule_set"]["granularity"] = granularity
- config_rules["tapi_lsp"]["rule_set"]["grid_type"] = grid
-
- logging.debug(f"Sending Media Channel Service to Orchestrator: {tfs_request}")
- tfs_requests.append(tfs_request)
-
- elif rule["type"] == "XR_AGENT_ACTIVATE_TRANSCEIVER":
- transceiver_params = rule["content"]["components"]
-
- elif rule["type"] == "CONFIG_VPNL3":
- service_uuid = rule["content"]["tunnel-uuid"]
-
- src = [{
- 'uuid': rule["content"]["src-node-uuid"],
- 'ip_address': rule["content"]["src-ip-address"],
- 'ip_mask': rule["content"]["src-ip-mask"],
- 'vlan_id': rule["content"]["src-vlan-id"]
- }]
-
- dst = []
- i = 1
- while f"dest{i}-node-uuid" in rule["content"]:
- dst.append({
- 'uuid': rule["content"][f"dest{i}-node-uuid"],
- 'ip_address': rule["content"][f"dest{i}-ip-address"],
- 'ip_mask': rule["content"][f"dest{i}-ip-mask"],
- 'vlan_id': rule["content"][f"dest{i}-vlan-id"]
- })
- i += 1
-
- tfs_request = load_template(os.path.join(TEMPLATES_PATH, "IPoWDM_orchestrator.json"))
-
- config_rules = tfs_request
- config_rules["endpoint_id"]["device_id"]["device_uuid"]["uuid"] = rule["controller_uuid"]
- config_rules["rule_set"]["uuid"] = rule["controller_uuid"]
- config_rules["rule_set"]["src"] = src
- config_rules["rule_set"]["dst"] = dst
- config_rules["rule_set"]["transceiver"] = {
- "components": transceiver_params
+ links_template = topo_context["link"]
+ for link_t in first_content.get("link", []):
+ links_template.append(link_t)
+
+ template["tapi-common:context"]["uuid"] = first_content.get("tenant-uuid")
+ template["tapi-common:context"]["name"][0]["value"] = rule.get("network-slice-uuid")
+
+ return template
+
+
+def _build_media_channel_request(rule: dict[str, Any]) -> dict[str, Any]:
+ """Build TAPI Media Channel request from provision rule."""
+ content = rule.get("content", {})
+ tfs_request = load_template(str(Path(TEMPLATES_PATH) / "TAPI_service.json"))
+
+ service_uuid = content.get("ols-path-uuid")
+ tfs_request["services"][0]["service_id"]["service_uuid"]["uuid"] = service_uuid
+ config_rules = tfs_request["services"][0]["service_config"]["config_rules"][0]
+ rule_set = config_rules["tapi_lsp"]["rule_set"]
+
+ rule_set["src"] = content.get("src-sip-uuid")
+ rule_set["dst"] = content.get("dest-sip-uuid")
+ rule_set["uuid"] = service_uuid
+ rule_set["bw"] = str(content.get("bandwidth-ghz", 0))
+ rule_set["tenant_uuid"] = rule.get("tenant-uuid")
+ rule_set["direction"] = content.get("direction")
+ rule_set["layer_protocol_name"] = content.get("layer-protocol-name")
+ rule_set["layer_protocol_qualifier"] = content.get("layer-protocol-qualifier")
+ rule_set["lower_frequency_mhz"] = str(content.get("lower-frequency-mhz"))
+ rule_set["upper_frequency_mhz"] = str(content.get("upper-frequency-mhz"))
+ rule_set["link_uuid_path"] = content.get("link-uuid-path")
+ rule_set["granularity"] = content.get("adjustment-granularity")
+ rule_set["grid_type"] = content.get("grid-type")
+
+ return tfs_request
+
+
+def _build_vpnl3_request(
+ rule: dict[str, Any],
+ transceiver_params: list[Any],
+) -> dict[str, Any]:
+ """Build IPoWDM orchestrator request from VPN L3 rule and transceiver parameters."""
+ content = rule.get("content", {})
+ src = [
+ {
+ "uuid": content.get("src-node-uuid"),
+ "ip_address": content.get("src-ip-address"),
+ "ip_mask": content.get("src-ip-mask"),
+ "vlan_id": content.get("src-vlan-id"),
+ }
+ ]
+
+ dst = []
+ i = 1
+ while f"dest{i}-node-uuid" in content:
+ dst.append(
+ {
+ "uuid": content[f"dest{i}-node-uuid"],
+ "ip_address": content[f"dest{i}-ip-address"],
+ "ip_mask": content[f"dest{i}-ip-mask"],
+ "vlan_id": content[f"dest{i}-vlan-id"],
}
- config_rules["rule_set"]["l3vpn"] = rule["content"]
+ )
+ i += 1
- logging.debug(f"Sending IPoWDM Service to Orchestrator: {tfs_request}")
- tfs_requests.append(tfs_request)
+ tfs_request = load_template(str(Path(TEMPLATES_PATH) / "IPoWDM_orchestrator.json"))
+ controller_uuid = rule.get("controller_uuid")
- else:
- logging.debug("Unsupported rule type for optical slice: %s", rule["type"])
- return tfs_requests
+ tfs_request["endpoint_id"]["device_id"]["device_uuid"]["uuid"] = controller_uuid
+ tfs_request["rule_set"]["uuid"] = controller_uuid
+ tfs_request["rule_set"]["src"] = src
+ tfs_request["rule_set"]["dst"] = dst
+ tfs_request["rule_set"]["transceiver"] = {"components": transceiver_params}
+ tfs_request["rule_set"]["l3vpn"] = content
+
+ return tfs_request
+
+
+def l3ipowdm_slice(rules: dict[str, Any] | None) -> list[dict[str, Any]]:
+ """Prepare Optical service requests for an optical slice from rules.
-def optical_slice_template(template, rule):
- """
- Complete the optical slice template with the data provided.
Args:
- template (dict): optical slice template.
- data (dict): Data to complete the template.
+ rules: Configuration rules for the optical slice.
+
Returns:
- dict: Template completed.
+ List of TFS/IPoWDM service requests.
"""
+ if not rules or "actions" not in rules:
+ return []
- for action in rule.get('actions', []):
- content = action.get('content', {})
- nodes = content.get('node', [])
- for node in nodes:
- for onp in node.get('owned-node-edge-point', []):
- if 'media-channel-node-edge-point-spec' in onp:
- onp['tapi-photonic-media:media-channel-node-edge-point-spec'] = onp.pop('media-channel-node-edge-point-spec')
+ transceiver_params: list[Any] = []
+ tfs_requests: list[dict[str, Any]] = []
- for i, sip in enumerate(template['tapi-common:context']['service-interface-point']):
- if i < len(rule['actions'][0]['content']['service-interface-point']):
- sip['uuid'] = rule['actions'][0]['content']['service-interface-point'][i]['uuid']
+ logger.debug("Preparing L3oWDM slice with rules: %s", rules)
+ for rule in rules["actions"]:
+ rule_type = rule.get("type")
+ logger.debug("Processing rule: %s", rule_type)
- nodes_template = template['tapi-common:context']['tapi-topology:topology-context']['topology'][0]['node']
- nodes_data = rule['actions'][0]['content']['node']
- for new_node in nodes_data:
- nodes_template.append(new_node)
+ match rule_type:
+ case "CREATE_OPTICAL_SLICE":
+ tfs_request = load_template(str(Path(TEMPLATES_PATH) / "Optical_slice.json"))
+ request = optical_slice_template(tfs_request, rules)
+ logger.debug("Sending Optical Slice to Optical Controller: %s", request)
+ tfs_requests.append(request)
- links_template = template['tapi-common:context']['tapi-topology:topology-context']['topology'][0]['link']
- links_rule = rule['actions'][0]['content']['link']
- for link_t in links_rule:
- links_template.append(link_t)
+ case "PROVISION_MEDIA_CHANNEL_OLS_PATH":
+ tfs_request = _build_media_channel_request(rule)
+ logger.debug("Sending Media Channel Service to Orchestrator: %s", tfs_request)
+ tfs_requests.append(tfs_request)
- template['tapi-common:context']['uuid'] = rule['actions'][0]['content']['tenant-uuid']
- template['tapi-common:context']['name'][0]['value'] = rule['network-slice-uuid']
+ case "XR_AGENT_ACTIVATE_TRANSCEIVER":
+ transceiver_params = rule.get("content", {}).get("components", [])
- return template
+ case "CONFIG_VPNL3":
+ tfs_request = _build_vpnl3_request(rule, transceiver_params)
+ logger.debug("Sending IPoWDM Service to Orchestrator: %s", tfs_request)
+ tfs_requests.append(tfs_request)
+
+ case _:
+ logger.debug("Unsupported rule type for optical slice: %s", rule_type)
+
+ return tfs_requests
diff --git a/src/realizer/get_metrics.py b/src/realizer/get_metrics.py
index d204217c737ad8d7f83ac5609d42ec18557093bc..16f89b4cf19304a1d8a9736e6ccadf70c4728019 100644
--- a/src/realizer/get_metrics.py
+++ b/src/realizer/get_metrics.py
@@ -1,35 +1,79 @@
-import logging
+# Copyright 2022-2026 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.
+
+"""Telemetry metric gathering and stream initialization for network slices."""
+
+from __future__ import annotations
+
import asyncio
-from .restconf.connectors.tfs_connector import tfs_connector
+import logging
+from typing import Any
+
from flask import current_app
-def get_metrics(path, slice_id, controller_type):
- if controller_type == "RESTCONF":
- links = []
- if slice_id not in current_app.config["TELEMETRY_CACHE"]:
- logging.debug(f"Telemetry for slice '{slice_id}' not in cache. Initializing.")
- current_app.config["TELEMETRY_CACHE"][slice_id] = {}
- for i in range(len(path)-1):
- link_id = f"{path[i]}-{path[i+1]}"
- links.append(link_id)
- current_app.config["TELEMETRY_CACHE"][slice_id][link_id] = {
- "timestamp": None, #time.time(),
- "bandwidth": 0,
- "latency": 0,
- "services": []
- }
- # Get telemetry only of the links in the shortest path:
- logging.debug(f"Starting telemetry streams for slice '{slice_id}'")
- connector = tfs_connector()
- bg_loop = connector.get_background_loop()
- coro = connector.startStreams(
- current_app.config["RESTCONF_IP"],
- current_app.config["SDN_SUBSCRIPTION_PERIOD"],
- links,
- slice_id,
- current_app.config["TELEMETRY_CACHE"]
- )
- future = asyncio.run_coroutine_threadsafe(coro, bg_loop)
- future.result()
- else:
- logging.debug(f"Telemetry for slice '{slice_id}' already in cache. Skipping.")
\ No newline at end of file
+from .restconf.connectors.tfs_connector import tfs_connector
+
+logger = logging.getLogger(__name__)
+
+
+def _build_link_cache(path: list[str]) -> tuple[list[str], dict[str, dict[str, Any]]]:
+ """Build telemetry cache structures for consecutive path nodes."""
+ links: list[str] = []
+ link_entries: dict[str, dict[str, Any]] = {}
+ for i in range(len(path) - 1):
+ link_id = f"{path[i]}-{path[i + 1]}"
+ links.append(link_id)
+ link_entries[link_id] = {
+ "timestamp": None,
+ "bandwidth": 0,
+ "latency": 0,
+ "services": [],
+ }
+ return links, link_entries
+
+
+def get_metrics(path: list[str], slice_id: str, controller_type: str | None) -> None:
+ """Initialize and start telemetry stream monitoring for links along a service path.
+
+ Args:
+ path: Ordered list of node names in the slice service path.
+ slice_id: Unique network slice identifier.
+ controller_type: Controller type name (e.g., 'RESTCONF').
+ """
+ if controller_type != "RESTCONF":
+ return
+
+ telemetry_cache = current_app.config.setdefault("TELEMETRY_CACHE", {})
+ if slice_id in telemetry_cache:
+ logger.debug("Telemetry for slice '%s' already in cache. Skipping.", slice_id)
+ return
+
+ logger.debug("Telemetry for slice '%s' not in cache. Initializing.", slice_id)
+ links, link_entries = _build_link_cache(path)
+ telemetry_cache[slice_id] = link_entries
+
+ logger.debug("Starting telemetry streams for slice '%s'", slice_id)
+ connector = tfs_connector()
+ bg_loop = connector.get_background_loop()
+ coro = connector.startStreams(
+ current_app.config["RESTCONF_IP"],
+ current_app.config["SDN_SUBSCRIPTION_PERIOD"],
+ links,
+ slice_id,
+ telemetry_cache,
+ )
+ future = asyncio.run_coroutine_threadsafe(coro, bg_loop)
+ future.result()
diff --git a/src/realizer/ixia/helpers/NEII_V4.py b/src/realizer/ixia/helpers/NEII_V4.py
index 16ddaebe887c2457f5ab1b077832e414b9ae6e14..5103c8e69a2811b1a5dbc67ea4d72b343b024824 100644
--- a/src/realizer/ixia/helpers/NEII_V4.py
+++ b/src/realizer/ixia/helpers/NEII_V4.py
@@ -1,11 +1,11 @@
# Copyright 2022-2026 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.
@@ -14,361 +14,206 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
+"""IXIA NEII Controller management and profile orchestration."""
+
+from __future__ import annotations
+
+import ipaddress
+import logging
+from typing import Any
+
from .automatizacion_ne2v4 import automatizacion
-import ipaddress, logging
+
+logger = logging.getLogger(__name__)
+
class NEII_controller:
- def __init__(self, ixia_ip):
- self.ixia_ip = ixia_ip
-
- def menu_principal(self, ip):
- '''
- Inputs:
- Outputs:
- Work: The main menu of the application.
- Notes: If the file is executed from the terminal, ensure that the import
- of "automatizacion_ne2v4" does not include a dot at the beggining.
- '''
- ip=input("¿Cuál es la IP del network emulator?: ")
- accion=input("¿Qué deseas hacer?: \n(1) Ver información de IP\n(2) Consultar información del Hardware\n(3) Configurar un perfil nuevo\n(4) Consultar perfiles existentes\nSelecciona una opción: ")
- if accion=="1":
- self.ver_info(ip)
- if accion=="2":
- self.hardware(ip)
- if accion=="3":
- self.nuevo_perfil(ip)
- if accion=="4":
- self.existentes(ip)
- return
-
- ## MAIN MENU FUNCTIONS ##
-
- def ver_info(self,ip):
- '''
- Inputs: -ip: the ip where the Axia API is located.
- Outputs:
- Work: It gives the information of the API.
-
- '''
- informacion_ip=automatizacion.obtener_informacion_ip(ip)
- if informacion_ip:
- print(informacion_ip)
-
- def hardware(self,ip):
- '''
- Inputs: -ip: the ip where the Axia API is located.
- Outputs:
- Work: It gives the information of the hardware.
-
- '''
- informacion_hardware=automatizacion.obtener_informacion_hardware(ip)
- if informacion_hardware:
- print(informacion_hardware)
-
- def nuevo_perfil(self,ip):
- '''
- Inputs: -ip: the ip where the Axia API is located.
- Outputs:
- Work: Creates and configures the profiles requested in the Axia API on the specified port.
- Notes: It is NOT required to fill all the information requested.
-
- '''
- puerto=input("¿Qué puerto quieres configurar?: ")
- num_perfiles=int(input("¿Cuantos perfiles quieres añadir?: "))
- configuraciones_totales=[]
- for i in range(num_perfiles):
- nombre=f"perfil{i+1}"
- accion=input("¿Qué deseas configurar?\n1)IPv4\t2)IPv6\n3)VLAN\t4)Delay\n5)Packet Drop\t6)Policer (Rx Bandwidth)\n7)Shaper (Rx Bandwidth)\nPor favor, separa las opciones con comas: ")
- opciones=accion.split(',')
- configuraciones={}
- for opcion in opciones:
- opcion=opcion.strip()
- if opcion=="1":
- source_ip=input("Introduce la IP de origen (IPv4): ")
- destination_ip=input("Introduce la IP destino (IPv4): ")
- prt=input("¿Qué protocolo quieres usar, IP o TCP? ")
- configuraciones['ipv4']=self.ipv4(source_ip,destination_ip,prt)
- elif opcion=="2":
- source=input("Introduce la IP de origen (IPv6): ")
- destination=input("Introduce la IP destino (IPv6): ")
- configuraciones['ipv6']=self.ipv6(source, destination)
- elif opcion=="3":
- vlan_id=int(input("Introduce identificador de VLAN: "))
- configuraciones['vlan']=self.vlan(vlan_id)
- elif opcion=="4":
- delay_perfil=input("Introduce el delay que quieres introducir en el perfil test_api: ")
- configuraciones['ethernetDelay']=self.delay(delay_perfil)
- elif opcion=="5":
- configuraciones['packetDrop']=self.packetDrop()
- elif opcion=="6":
- bandwidth=int(input('Introduzca el ancho de banda (Kbps): '))
- configuraciones['policer']=self.policer(bandwidth)
- elif opcion=="7":
- configuraciones['shaper']=self.shaper()
- else:
- print(f"Opción '{opcion}' no es válida.")
- configuracion_perfil=self.configuracion_total(configuraciones, nombre)
- configuraciones_totales.append(configuracion_perfil)
- perfil_final = {'profiles': configuraciones_totales}
- configuracion_puerto=automatizacion.envio_peticion(ip, puerto, perfil_final)
- if configuracion_puerto:
- print(configuracion_puerto)
-
- def existentes(self,ip):
- '''
- Inputs: -ip: the ip where the Axia API is located.
- Outputs:
- Work: Shows the information of a given port-
-
- '''
- puerto=input("¿Qué puerto quieres consultar?: ")
- informacion_puerto=automatizacion.obtener_informacion_puerto(ip, puerto)
- if informacion_puerto:
- print(informacion_puerto)
-
- def existentes_auto(self,ip,puerto):
- '''
- Inputs: -ip: the ip where the Axia API is located.
- - puerto: the port we want to get the information.
- Outputs:
- Work: Creates and configures the profiles requested in the Axia API on the specified port.
-
- '''
- informacion_puerto=automatizacion.obtener_informacion_puerto(ip, puerto)
- if informacion_puerto:
- print(f'info puerto\n{informacion_puerto}')
- return informacion_puerto
-
- ## FUNCION PARA LA GUI DEL NSC ##
-
- import ipaddress
-
- def nscNEII(self, json_data):
- configuraciones = {}
+ """Controller interface for configuring and interacting with IXIA Network Emulator II."""
+
+ def __init__(self, ixia_ip: str | None = None) -> None:
+ self.ixia_ip = ixia_ip or ""
+
+ def nscNEII(self, json_data: dict[str, Any]) -> Any:
+ """Configure NEII profiles from slice intent JSON data.
+
+ Args:
+ json_data: Intent dictionary containing QoS, L2/L3 addressing, and impairment params.
+
+ Returns:
+ Status of the configured port.
+ """
+ configuraciones: dict[str, Any] = {}
ip = self.ixia_ip
puerto = "5"
- dataProfile = self.existentes_auto(ip, puerto)
-
- print(f'\n\n{json_data}\n')
-
- ip_version = json_data.get("ip_version", None)
- src_node_ip = json_data.get("src_node_ip", None)
- dst_node_ip = json_data.get("dst_node_ip", None)
- src_node_ipv6 = json_data.get("src_node_ipv6", None)
- dst_node_ipv6 = json_data.get("dst_node_ipv6", None)
- vlan_id = json_data.get("vlan_id", None)
- bandwidth = json_data.get("bandwidth", None)
- latency = json_data.get("latency", None)
- latency_version = json_data.get("latency_version", None)
- reliability = json_data.get("reliability", None)
- tolerance = json_data.get("tolerance", None)
- packet_reorder = json_data.get("packet_reorder", None)
- num_pack = json_data.get("num_pack", None)
- pack_reorder = json_data.get("pack_reorder", None)
- num_reorder = json_data.get("num_reorder", None)
- max_reorder = json_data.get("max_reorder", None)
- drop_version = json_data.get("drop_version", None)
- desv_reorder = json_data.get("desv_reorder", None)
- packets_drop = json_data.get("packets_drop", None)
- drops = json_data.get("drops", None)
- desv_drop = json_data.get("desv_drop", None)
-
- # --- Configuration variables ---
+ data_profile = self.existentes_auto(ip, puerto) or {"profiles": []}
+
+ src_node_ip = json_data.get("src_node_ip")
+ dst_node_ip = json_data.get("dst_node_ip")
+ src_node_ipv6 = json_data.get("src_node_ipv6")
+ dst_node_ipv6 = json_data.get("dst_node_ipv6")
+ vlan_id = json_data.get("vlan_id")
+ bandwidth = json_data.get("bandwidth")
+ latency = json_data.get("latency")
+ latency_version = json_data.get("latency_version")
+ reliability = json_data.get("reliability")
+ tolerance = json_data.get("tolerance")
+ packet_reorder = json_data.get("packet_reorder")
+ num_pack = json_data.get("num_pack")
+ pack_reorder = json_data.get("pack_reorder")
+ num_reorder = json_data.get("num_reorder")
+ max_reorder = json_data.get("max_reorder")
+ drop_version = json_data.get("drop_version")
+ desv_reorder = json_data.get("desv_reorder")
+ packets_drop = json_data.get("packets_drop")
+ drops = json_data.get("drops")
+ desv_drop = json_data.get("desv_drop")
# IPv4 / IPv6 configuration
if src_node_ip and dst_node_ip:
- if isinstance(ipaddress.ip_address(src_node_ip), ipaddress.IPv4Address) and isinstance(ipaddress.ip_address(dst_node_ip), ipaddress.IPv4Address):
- configuraciones['ipv4'] = self.ipv4(src_node_ip, dst_node_ip, 5)
+ try:
+ src_obj = ipaddress.ip_address(src_node_ip)
+ dst_obj = ipaddress.ip_address(dst_node_ip)
+ if isinstance(src_obj, ipaddress.IPv4Address) and isinstance(dst_obj, ipaddress.IPv4Address):
+ configuraciones["ipv4"] = self.ipv4(src_node_ip, dst_node_ip, "5")
+ except ValueError:
+ pass
+
if src_node_ipv6 and dst_node_ipv6:
- if isinstance(ipaddress.ip_address(src_node_ipv6), ipaddress.IPv6Address) and isinstance(ipaddress.ip_address(dst_node_ipv6), ipaddress.IPv6Address):
- configuraciones['ipv6'] = self.ipv6(src_node_ipv6, dst_node_ipv6)
+ try:
+ src6_obj = ipaddress.ip_address(src_node_ipv6)
+ dst6_obj = ipaddress.ip_address(dst_node_ipv6)
+ if isinstance(src6_obj, ipaddress.IPv6Address) and isinstance(dst6_obj, ipaddress.IPv6Address):
+ configuraciones["ipv6"] = self.ipv6(src_node_ipv6, dst_node_ipv6)
+ except ValueError:
+ pass
# VLAN
- if vlan_id:
- configuraciones['vlan'] = self.vlan(int(vlan_id))
+ if vlan_id is not None:
+ configuraciones["vlan"] = self.vlan(int(vlan_id))
# Policer
- if bandwidth:
- configuraciones['policer'] = self.policer(bandwidth)
+ if bandwidth is not None:
+ configuraciones["policer"] = self.policer(bandwidth)
- # Latencia
- if latency:
- if float(latency) > 0:
- configuraciones['ethernetDelay'] = self.delay_gui(float(latency), latency_version, float(tolerance))
+ # Latency
+ if latency is not None and float(latency) > 0:
+ max_lat = float(tolerance) if tolerance is not None else 0.0
+ configuraciones["ethernetDelay"] = self.delay_gui(float(latency), latency_version, max_lat)
# Packet Reorder
- if packet_reorder:
- configuraciones['reorder'] = self.packetReorder(num_reorder, pack_reorder, num_pack, max_reorder, packet_reorder, desv_reorder)
+ if packet_reorder is not None:
+ configuraciones["reorder"] = self.packetReorder(
+ num_reorder, pack_reorder, num_pack, max_reorder, packet_reorder, desv_reorder
+ )
- # Packet Reorder when reliability
- if reliability:
- configuraciones['reorder'] = self.packetReorder(int(reliability))
+ # Reliability
+ if reliability is not None:
+ configuraciones["reorder"] = self.packetReorder(int(reliability))
- #Dropper
+ # Dropper
if drop_version:
- configuraciones['packetDrop'] = self.packetDrop(drops, packets_drop, drop_version, desv_drop)
+ configuraciones["packetDrop"] = self.packetDrop(drops, packets_drop, drop_version, desv_drop)
- # Agregar perfil
- num_profiles = len(dataProfile.get("profiles", []))
+ # Append new profile
+ num_profiles = len(data_profile.get("profiles", []))
configuracion_perfil = self.configuracion_total(configuraciones, f"profile{num_profiles + 1}")
- dataProfile['profiles'].append(configuracion_perfil)
- logging.info(f"Configuración del perfil: {configuracion_perfil}")
-
- # Send the configuration
- automatizacion.envio_peticion(ip, puerto, dataProfile)
+ data_profile.setdefault("profiles", []).append(configuracion_perfil)
+ logger.info("Configuración del perfil: %s", configuracion_perfil)
+
+ automatizacion.envio_peticion(ip, puerto, data_profile)
return automatizacion.obtener_informacion_puerto(ip, puerto)
-
-
- ## PORT CONFIGURATION FUNCTIONS ##
-
- def delay(self,delay_perfil):
- '''
- Inputs: -delay_perfil: the delay we want to configurate.
- Outputs: the information of the delay for the controller.
- Work: Creates the configuration JSON for delay of the controller.
-
- '''
- delay_perfil = input("Enter the delay you want to set in the test_api profile: ")
- print(f"delay en main: {delay_perfil}")
- delay_type = input("Select one option of stadistics:\n1)None\t2)Gaussian\n3)Internet\n(Type the number)")
- configuracion_delay = automatizacion.añadir_configuracion_puerto_delay(delay_perfil,delay_type)
- print(f"Config delay:\n{configuracion_delay}")
- return configuracion_delay
-
- def delay_gui(self,delay_perfil, latency_version, max_latency):
- '''
- Inputs: -delay_perfil: the delay we want to configurate.
- Outputs: the information of the delay for the controller.
- Work: Creates the configuration JSON for delay of the controller.
-
- '''
- print(f'\nPero: {max_latency}\n')
- configuracion_delay = automatizacion.añadir_configuracion_puerto_delay(delay_perfil,latency_version,max_latency)
- return configuracion_delay
-
- def ipv4(self,source_ip,destination_ip,prt):
- '''
- Inputs: -source_ip: the source IPv4 we want to configurate.
- -destination_ip: the destination IPv4 we want to configurate.
- -prt: the protocol we want to configurate
- Outputs: the information of the IPv4 for the controller.
- Work: Creates the configuration JSON for IPv4 of the controller.
- Notes: by default, the protocol is TCP (6).
-
- '''
- source_hx=None
- destination_hx=None
- protocolo=None
- if source_ip:
- source_hx=hex(int(ipaddress.IPv4Address(source_ip)))[2:]
- if destination_ip:
- destination_hx=hex(int(ipaddress.IPv4Address(destination_ip)))[2:]
- if prt:
- protocolo="4" if prt=="IP" else "6"
- configuracion_puerto=automatizacion.añadir_configuracion_puerto_ipv4(source_hx, destination_hx, protocolo)
- return configuracion_puerto
-
- def ipv6(self,source, destination):
- '''
- Inputs: -source: the source IPv6 we want to configurate.
- -destination: the destination IPv6 we want to configurate.
- Outputs: the information of the IPv6 for the controller.
- Work: Creates the configuration JSON for IPv6 of the controller.
-
- '''
- if source:
- source=ipaddress.IPv6Address(source).exploded.replace(":", "")
- elif not source: source=None
- if destination:
- destination=ipaddress.IPv6Address(destination).exploded.replace(":", "")
- elif not destination: destination=None
- configuracion_puerto=automatizacion.añadir_configuracion_puerto_ipv6(source, destination)
- return configuracion_puerto
-
- def vlan(self,vlan_id):
- '''
- Inputs: -vlan_id: the VLAN we want to configurate.
- Outputs: the information of the VLAN for the controller.
- Work: Creates the configuration JSON for VLAN of the controller.
-
- '''
- configuracion_vlan=automatizacion.añadir_configuracion_VLAN(vlan_id)
- return configuracion_vlan
-
- def packetDrop(self, drop, total,version,dev):
- '''
- Inputs:
- Outputs: the information of the packet drop configuration for the controller.
- Work: Creates the configuration JSON for packet drop of the controller.
-
- '''
- configuracionPD=automatizacion.añadir_configuración_packetDrop(drop,total,version,dev)
- return configuracionPD
-
- def policer(self,bandwidth):
- '''
- Inputs: -bandwidth: the TX bandwdth we want to configurate.
- Outputs: the information of the TX bandwidth for the controller.
- Work: Creates the configuration JSON for the TX bandwidth of the controller.
-
- '''
- configuracion_policer=automatizacion.añadir_configuracion_policer(bandwidth)
- return configuracion_policer
-
- def shaper(self):
- '''
- Inputs:
- Outputs: the information of the RX bandwidth for the controller.
- Work: Creates the configuration JSON for the RX bandwidth of the controller.
-
- '''
- bandwidth=int(input('Introduzca el ancho de banda (Kbps): '))
- configuracion_policer=automatizacion.añadir_configuracion_shaper(bandwidth)
- return configuracion_policer
-
- def packetReorder(self,reorder,packages=None, npackagesreorder=None, maxreord=None, version=None, stev=None):
- print(f"\n\n\nNEII ANTES DEL PASO\nPACKAGES:{packages}\nREORDER:{reorder}\nMAXREORD:{maxreord}\nNPACKAGESORDER:{npackagesreorder}\n\n\n")
- configuracion_reorder=automatizacion.añadir_configuracion_reorder(packages,reorder,npackagesreorder, maxreord, version, stev)
- return configuracion_reorder
-
- def configuracion_total(self,configuraciones, nombre_perfil):
- '''
- Inputs: -configuraciones: the informtion of all the configurations for the controller.
- -nombre_perfil: the name of the profile where the information is going to be allocated.
- Outputs: the information of a configurated profile.
- Work: Creates the configuration JSON for a profile for the controller.
-
- '''
- perfil = {
- 'tag': nombre_perfil,
- 'dramAllocation': {},
- 'rules': [],
- 'ethernetDelay': {'enabled': False},
- 'packetDrop': {'enabled': False},
- 'enabled':True
+
+ def existentes_auto(self, ip: str, puerto: str) -> dict[str, Any] | None:
+ """Fetch port profile information."""
+ info = automatizacion.obtener_informacion_puerto(ip, puerto)
+ if info:
+ logger.debug("Port info retrieved for %s:%s", ip, puerto)
+ return info
+
+ def delay_gui(
+ self,
+ delay_perfil: float,
+ latency_version: str | None,
+ max_latency: float | None = None,
+ ) -> dict[str, Any]:
+ """Generate delay profile configuration dictionary."""
+ return automatizacion.añadir_configuracion_puerto_delay(delay_perfil, latency_version, max_latency)
+
+ def ipv4(self, source_ip: str | None, destination_ip: str | None, prt: str | None) -> dict[str, Any]:
+ """Generate IPv4 configuration dictionary."""
+ source_hx = hex(int(ipaddress.IPv4Address(source_ip)))[2:] if source_ip else None
+ destination_hx = hex(int(ipaddress.IPv4Address(destination_ip)))[2:] if destination_ip else None
+ protocolo = "4" if prt == "IP" else ("6" if prt else None)
+ return automatizacion.añadir_configuracion_puerto_ipv4(source_hx, destination_hx, protocolo)
+
+ def ipv6(self, source: str | None, destination: str | None) -> dict[str, Any]:
+ """Generate IPv6 configuration dictionary."""
+ src_hx = ipaddress.IPv6Address(source).exploded.replace(":", "") if source else None
+ dst_hx = ipaddress.IPv6Address(destination).exploded.replace(":", "") if destination else None
+ return automatizacion.añadir_configuracion_puerto_ipv6(src_hx, dst_hx)
+
+ def vlan(self, vlan_id: int) -> dict[str, Any]:
+ """Generate VLAN configuration dictionary."""
+ return automatizacion.añadir_configuracion_VLAN(vlan_id)
+
+ def packetDrop(
+ self,
+ drop: int | None = 0,
+ total: int | None = 100,
+ version: str = "PERIODIC",
+ dev: float = 10.0,
+ ) -> dict[str, Any]:
+ """Generate packet drop configuration dictionary."""
+ return automatizacion.añadir_configuración_packetDrop(drop, total, version, dev)
+
+ def policer(self, bandwidth: int) -> dict[str, Any]:
+ """Generate policer configuration dictionary."""
+ return automatizacion.añadir_configuracion_policer(bandwidth)
+
+ def shaper(self, bandwidth: int = 1000) -> dict[str, Any]:
+ """Generate shaper configuration dictionary."""
+ return automatizacion.añadir_configuracion_shaper(bandwidth)
+
+ def packetReorder(
+ self,
+ reorder: Any,
+ packages: Any = None,
+ npackagesreorder: Any = None,
+ maxreord: Any = None,
+ version: Any = None,
+ stev: Any = None,
+ ) -> dict[str, Any]:
+ """Generate packet reorder configuration dictionary."""
+ return automatizacion.añadir_configuracion_reorder(packages, reorder, npackagesreorder, maxreord, version, stev)
+
+ def configuracion_total(self, configuraciones: dict[str, Any], nombre_perfil: str) -> dict[str, Any]:
+ """Combine all individual feature configurations into a single NEII profile."""
+ perfil: dict[str, Any] = {
+ "tag": nombre_perfil,
+ "dramAllocation": {},
+ "rules": [],
+ "ethernetDelay": {"enabled": False},
+ "packetDrop": {"enabled": False},
+ "enabled": True,
}
- if 'ipv4' in configuraciones:
- ipv4_config = configuraciones['ipv4']
- perfil['dramAllocation'] = ipv4_config['profiles'][0]['dramAllocation']
- perfil['rules'].extend(ipv4_config['profiles'][0]['rules'])
- if 'ipv6' in configuraciones:
- ipv6_config = configuraciones['ipv6']
- perfil['rules'].extend(ipv6_config['profiles'][0]['rules'])
- if 'vlan' in configuraciones:
- vlan_config = configuraciones['vlan']
- perfil['rules'].extend(vlan_config['profiles'][0]['rules'])
- if 'ethernetDelay' in configuraciones:
- perfil['ethernetDelay'] = configuraciones['ethernetDelay']['ethernetDelay']
- if 'packetDrop' in configuraciones:
- perfil['packetDrop'] = configuraciones['packetDrop']['packetDrop']
- if 'policer' in configuraciones:
- perfil['policer']=configuraciones['policer']['policer']
- if 'shaper' in configuraciones:
- perfil['shaper']=configuraciones['shaper']['shaper']
- if 'reorder' in configuraciones:
- perfil['reorder']=configuraciones['reorder']['reorder']
+ if "ipv4" in configuraciones:
+ ipv4_config = configuraciones["ipv4"]
+ perfil["dramAllocation"] = ipv4_config["profiles"][0]["dramAllocation"]
+ perfil["rules"].extend(ipv4_config["profiles"][0]["rules"])
+ if "ipv6" in configuraciones:
+ ipv6_config = configuraciones["ipv6"]
+ perfil["rules"].extend(ipv6_config["profiles"][0]["rules"])
+ if "vlan" in configuraciones:
+ vlan_config = configuraciones["vlan"]
+ perfil["rules"].extend(vlan_config["profiles"][0]["rules"])
+ if "ethernetDelay" in configuraciones:
+ perfil["ethernetDelay"] = configuraciones["ethernetDelay"]["ethernetDelay"]
+ if "packetDrop" in configuraciones:
+ perfil["packetDrop"] = configuraciones["packetDrop"]["packetDrop"]
+ if "policer" in configuraciones:
+ perfil["policer"] = configuraciones["policer"]["policer"]
+ if "shaper" in configuraciones:
+ perfil["shaper"] = configuraciones["shaper"]["shaper"]
+ if "reorder" in configuraciones:
+ perfil["reorder"] = configuraciones["reorder"]["reorder"]
return perfil
-
-if __name__=="__main__":
- controller=NEII_controller()
- controller.menu_principal()
\ No newline at end of file
diff --git a/src/realizer/ixia/helpers/automatizacion_ne2v4.py b/src/realizer/ixia/helpers/automatizacion_ne2v4.py
index 65e6a33abcba01cf31221c33a9e3e48a77dae844..1e683c1a327f98e4a32dd5159285a8c0f04f8996 100644
--- a/src/realizer/ixia/helpers/automatizacion_ne2v4.py
+++ b/src/realizer/ixia/helpers/automatizacion_ne2v4.py
@@ -1,11 +1,11 @@
# Copyright 2022-2026 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.
@@ -14,454 +14,314 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
-import requests
-class automatizacion:
- def obtener_informacion_ip(ip):
- '''
- Gets the information IP of the NE2
- Args:
- ip: IP.
- Returns:
- A dictionary with the IP infotmationx.
- '''
- url= "http://"+ip+"/api/actions/ipInfo"
- body= {"ip": ip}
- response = requests.get(url, json=body, auth=('admin', 'admin'))
- if response.status_code==200:
- return response.json()
- else:
- print(f"error al obtener la informacion de la IP; {response.status_code}")
- return None
-
- def obtener_informacion_hardware(ip):
- """
- Obtiene información de una dirección IP.
-
- Args:
- ip: La dirección IP del NE2.
-
- Returns:
- Un diccionario con la información de IP.
- """
- url = "http://"+ip+"/api/actions/hwInfo"
- body = {"ip": ip}
- response = requests.get(url, json=body, auth=('admin', 'admin'))
- if response.status_code == 200:
- return response.json()
- else:
- print(f"Error al obtener la información de la IP: {response.status_code}")
- return None
-
- def obtener_informacion_puerto(ip,puerto):
- """
- Obtiene información de una dirección IP.
-
- Args:
- ip: La dirección IP del NE2.
-
- Returns:
- Un diccionario con la información de IP.
- """
- url = "http://"+ip+"/api/hw/Port/"+puerto
- body = {"ip": ip}
- response = requests.get(url, json=body, auth=('admin', 'admin'))
- if response.status_code == 200:
- return response.json()
- else:
- print(f"Error al obtener la información de la IP: {response.status_code}")
- return None
-
- def añadir_configuracion_puerto_delay(delay, latency_type, max_latency):
- """
- Añade una configuración de puerto según su delay a un NE2.
-
- Args:
- delay (int): Cantidad de delay en la simulación.
-
- Returns:
- La respuesta de la API (texto).
- """
- configuracion=None
- print(f'\n\nTipo de latencia: {latency_type}\n latencia: {delay}\n')
- if latency_type=='1' or latency_type==None:
- configuracion={'ethernetDelay': {'delay': delay, 'delayMax': 15.0, 'isUncorrelated': False, 'maxNegDelta': 0.1, 'pdvMode': 'NONE', 'delayMin': 5.0, 'units': 'MS', 'maxPosDelta': 0.1, 'enabled': True, 'spread': 1.0}}
- if latency_type=='2' or latency_type=='gauss':
- delay_f=float(delay)
- ancho=float(max_latency)
- max_delay=delay_f+ancho
- min_delay=delay_f-ancho
- configuracion={'ethernetDelay': {'delay': delay, 'delayMax': max_delay, 'isUncorrelated': False,'maxNegDelta': ancho/3, 'pdvMode': 'GAUSSIAN', 'delayMin':min_delay, 'units': 'MS', 'maxPosDelta':ancho/3, 'enabled': True, 'spread': 1.58}}
- if latency_type =='3' or latency_type=='internet':
- ancho=float(max_latency)
- max_delay = float(delay)+0.9*float(ancho)
- min_delay = float(delay)-0.1*float(ancho)
- configuracion={'ethernetDelay': {'delay': delay, 'delayMax': max_delay, 'isUncorrelated': False, 'maxNegDelta': 0.4, 'pdvMode': 'INTERNET', 'delayMin': min_delay, 'units': 'MS', 'maxPosDelta': 0.5, 'enabled': True, 'spread': 100.0}}
- print(f"\n\nConf Delay: {configuracion}\n")
- return configuracion
-
- def añadir_configuracion_puerto_ipv4(source_hx,destination_hx,protocolo):
- """
- Añade una configuración de puerto según las IPv4s de origen y destino de la comunicación.
-
- Args:
- source_hx (int): Dirección IPv4 de origen en hexadecimal.
- destination_hx (int): Dirección IPv4 de destino en hexadecimal.
- protocolo (string): Numero asociado al protocolo elegido.
-
- Returns:
- La respuesta de la API (texto).
- """
- reglas = [{"field": "Common::IPv4::Version", "value": "4", "mask": "f"}]
- if source_hx:
- reglas.append({"field": "Common::IPv4::Source Address", "value": source_hx, "mask": "ffffffff"})
- if destination_hx:
- reglas.append({"field": "Common::IPv4::Destination Address", "value": destination_hx, "mask": "ffffffff"})
- if protocolo:
- reglas.append({"field": "Common::IPv4::Protocol", "value": protocolo, "mask": "ff"})
- configuracion = {"profiles": [{"dramAllocation": {"mode": "AUTO","fixedSize": 1700352},"rules": reglas,"tag": "test_api"}],"defaultProfile": {"dramAllocation": {"mode": "AUTO","fixedSize": 1700352},"tag": "defaultProfile"}}
- return configuracion
-
- def añadir_configuracion_puerto_ipv6(source,destination):
- """
- Añade una configuración de puerto según las IPv6s de origen y destino de la comunicación.
-
- Args:
- source (int): Dirección IPv6 de origen en hexadecimal.
- destination (int): Dirección IPv6 de destino en hexadecimal.
-
- Returns:
- La respuesta de la API (texto).
- """
- reglas=[{'field': 'Common::IPv6::Version', 'bitRange': 'L3@0[7]+3', 'value': '6', 'mask': 'f'}]
- if source:
- reglas.append({'field': 'Common::IPv6::Source Address', 'bitRange': 'L3@8[7]+127', 'value': source, 'mask': 'ffffffffffffffffffffffffffffffff'})
- if destination:
- reglas.append({'field': 'Common::IPv6::Destination Address', 'bitRange': 'L3@24[7]+127', 'value': destination, 'mask': 'ffffffffffffffffffffffffffffffff'})
- configuracion = {"profiles": [{"dramAllocation": {"mode": "AUTO","fixedSize": 1700352},"rules": reglas,"tag": "test_api"}],"defaultProfile": {"dramAllocation": {"mode": "AUTO","fixedSize": 1700352},"tag": "defaultProfile"}}
- return configuracion
-
- def añadir_configuracion_VLAN(vlan):
- """
- Añade una configuración de puerto según su VLAN.
-
- Args:
- vlan (int): Número de identificación de la VLAN.
-
- Returns:
- La respuesta de la API (texto).
- """
- vlan=hex(vlan)[2:]
- configuracion={'profiles': [{'dramAllocation': {'mode': 'AUTO', 'fixedSize': 1700352}, 'rules': [{'field': 'Common::Second Tag::VLAN ID', 'bitRange': 'L2@18[3]+11', 'value': vlan, 'mask': 'fff'}]}]}
- return configuracion
-
- def añadir_configuración_packetDrop(drops,total):
- """
- Añade una configuración de puerto según su configuracion de packet Drop un NE2.
-
- Args:
- drops (int): cantidad de paquetes dropeados
- total (int): cantidad total de paquetes
-
- Returns:
- La respuesta de la API (texto).
- """
- configuracion={'packetDrop': {'rdmSel': {'dist': 'PERIODIC', 'burstlen': drops, 'interval': total, 'stddev': 10.0}, 'enabled': True}}
- return configuracion
-
- def añadir_configuracion_policer(bitrate):
- """
- Añade una configuración de puerto según su TX bandwidth a un NE2.
-
- Args:
- bitrate (int): bandwith en Tx
+"""Automation helper for interacting with IXIA NEII emulator API."""
- Returns:
- La respuesta de la API (texto).
- """
- configuracion={'policer':{'excessBitRate': bitrate, 'excessBurstTolerance': 64000, 'commitedBurstTolerance': 64000, 'commitedBitRate': bitrate, 'enabled': True, 'enableRateCoupling': False}}
- return configuracion
-
- def añadir_configuracion_shaper(bitrate):
- """
- Añade una configuración de puerto según su RX bandwidth a un NE2.
+from __future__ import annotations
- Args:
- bitrate (int): bandwith en Rx
+import logging
+from typing import Any
- Returns:
- La respuesta de la API (texto).
- """
- configuracion={'shaper': {'burstTolerance': 64000, 'bitRate': bitrate, 'enabled': True}}
- return configuracion
-
- def añadir_configuracion_reorder(reorder):
- """
- Adds reorder configuration.
-
- Args:
- reorder (int): reorder
-
- Returns:
- response of the API (texto).
- """
- reorder=100-reorder
- print(f"\nReorder:{reorder}\n")
- configuracion={"reorder": {"rdmSel": {"dist": "PERIODIC", "burstlen": reorder, "interval": 100, "stddev": 10.0 }, "reorderByMin": 1, "reorderByMax": 5, "enabled": True}} #Los demas valores los he dejado como defecto por no poder configurarlos
- return configuracion
-
- def envio_peticion(ip,puerto, configuracion):
- """
- Envía una configuración de puerto a un NE2.
+import requests
- Args:
- ip (int): IP del NE2
- puerto: número del puerto a configurar
- configuración: la información que se quiere configurar en el puerto
+logger = logging.getLogger(__name__)
- Returns:
- La respuesta de la API (texto).
- """
-import requests
class automatizacion:
- def obtener_informacion_ip(ip):
- '''
- obtiene informacion ip del NE2
- Args:
- ip: la direccion IP del NE2.
+ """Helper methods for constructing IXIA NEII API payloads and sending HTTP requests."""
- Returns:
- un diccionario con la informacion de IP.
- '''
- url= "http://"+ip+"/api/actions/ipInfo"
- body= {"ip": ip}
- response = requests.get(url, json=body, auth=('admin', 'admin'))
- if response.status_code==200:
- return response.json()
- else:
- print(f"error al obtener la informacion de la IP; {response.status_code}")
+ @staticmethod
+ def obtener_informacion_ip(ip: str) -> dict[str, Any] | None:
+ """Get IP information from the NEII emulator."""
+ url = f"http://{ip}/api/actions/ipInfo"
+ body = {"ip": ip}
+ try:
+ response = requests.get(url, json=body, auth=("admin", "admin"), timeout=30)
+ if response.status_code == 200:
+ return response.json()
+ logger.error("Error retrieving IP info from %s: status %s", ip, response.status_code)
+ return None
+ except Exception as e:
+ logger.error("Failed to get IP info from %s: %s", ip, e)
return None
-
- def obtener_informacion_hardware(ip):
- """
- Obtiene información de una dirección IP.
-
- Args:
- ip: La dirección IP del NE2.
- Returns:
- Un diccionario con la información de IP.
- """
- url = "http://"+ip+"/api/actions/hwInfo"
+ @staticmethod
+ def obtener_informacion_hardware(ip: str) -> dict[str, Any] | None:
+ """Get hardware info from the NEII emulator."""
+ url = f"http://{ip}/api/actions/hwInfo"
body = {"ip": ip}
- response = requests.get(url, json=body, auth=('admin', 'admin'))
- if response.status_code == 200:
- return response.json()
- else:
- print(f"Error al obtener la información de la IP: {response.status_code}")
+ try:
+ response = requests.get(url, json=body, auth=("admin", "admin"), timeout=30)
+ if response.status_code == 200:
+ return response.json()
+ logger.error("Error retrieving HW info from %s: status %s", ip, response.status_code)
+ return None
+ except Exception as e:
+ logger.error("Failed to get HW info from %s: %s", ip, e)
return None
-
- def obtener_informacion_puerto(ip,puerto):
- """
- Obtiene información de una dirección IP.
-
- Args:
- ip: La dirección IP del NE2.
- Returns:
- Un diccionario con la información de IP.
- """
- url = "http://"+ip+"/api/hw/Port/"+puerto
+ @staticmethod
+ def obtener_informacion_puerto(ip: str, puerto: str) -> dict[str, Any] | None:
+ """Get port configuration and status from the NEII emulator."""
+ url = f"http://{ip}/api/hw/Port/{puerto}"
body = {"ip": ip}
- response = requests.get(url, json=body, auth=('admin', 'admin'))
- if response.status_code == 200:
- return response.json()
- else:
- print(f"Error al obtener la información de la IP: {response.status_code}")
+ try:
+ response = requests.get(url, json=body, auth=("admin", "admin"), timeout=30)
+ if response.status_code == 200:
+ return response.json()
+ logger.error("Error retrieving Port %s info from %s: status %s", puerto, ip, response.status_code)
+ return None
+ except Exception as e:
+ logger.error("Failed to get Port info from %s: %s", ip, e)
return None
-
- def añadir_configuracion_puerto_delay(delay, latency_type, max_latency):
- """
- Añade una configuración de puerto según su delay a un NE2.
-
- Args:
- delay (int): Cantidad de delay en la simulación.
-
- Returns:
- La respuesta de la API (texto).
- """
- configuracion=None
- print(f'\n\nTipo de latencia: {latency_type}\n latencia: {delay}\n')
- if latency_type=='1' or latency_type==None:
- configuracion={'ethernetDelay': {'delay': delay, 'delayMax': 15.0, 'isUncorrelated': False, 'maxNegDelta': 0.1, 'pdvMode': 'NONE', 'delayMin': 5.0, 'units': 'MS', 'maxPosDelta': 0.1, 'enabled': True, 'spread': 1.0}}
- if latency_type=='2' or latency_type=='gauss':
- delay_f=float(delay)
- ancho=float(max_latency)
- max_delay=delay_f+ancho
- min_delay=delay_f-ancho
- configuracion={'ethernetDelay': {'delay': delay, 'delayMax': max_delay, 'isUncorrelated': False,'maxNegDelta': ancho/3, 'pdvMode': 'GAUSSIAN', 'delayMin':min_delay, 'units': 'MS', 'maxPosDelta':ancho/3, 'enabled': True, 'spread': 1.58}}
- if latency_type =='3' or latency_type=='internet':
- ancho=float(max_latency)
- max_delay = float(delay)+0.9*float(ancho)
- min_delay = float(delay)-0.1*float(ancho)
- configuracion={'ethernetDelay': {'delay': delay, 'delayMax': max_delay, 'isUncorrelated': False, 'maxNegDelta': 0.4, 'pdvMode': 'INTERNET', 'delayMin': min_delay, 'units': 'MS', 'maxPosDelta': 0.5, 'enabled': True, 'spread': 100.0}}
- print(f"\n\nConf Delay: {configuracion}\n")
- return configuracion
-
- def añadir_configuracion_puerto_ipv4(source_hx,destination_hx,protocolo):
- """
- Añade una configuración de puerto según las IPv4s de origen y destino de la comunicación.
-
- Args:
- source_hx (int): Dirección IPv4 de origen en hexadecimal.
- destination_hx (int): Dirección IPv4 de destino en hexadecimal.
- protocolo (string): Numero asociado al protocolo elegido.
- Returns:
- La respuesta de la API (texto).
- """
- reglas = [{"field": "Common::IPv4::Version", "value": "4", "mask": "f"}]
+ @staticmethod
+ def añadir_configuracion_puerto_delay(
+ delay: float | int | str,
+ latency_type: str | None,
+ max_latency: float | int | str | None = None,
+ ) -> dict[str, Any]:
+ """Generate delay profile configuration dictionary."""
+ delay_val = float(delay)
+ if latency_type in ("2", "gauss") and max_latency is not None:
+ spread = float(max_latency)
+ return {
+ "ethernetDelay": {
+ "delay": delay_val,
+ "delayMax": delay_val + spread,
+ "isUncorrelated": False,
+ "maxNegDelta": spread / 3,
+ "pdvMode": "GAUSSIAN",
+ "delayMin": delay_val - spread,
+ "units": "MS",
+ "maxPosDelta": spread / 3,
+ "enabled": True,
+ "spread": 1.58,
+ }
+ }
+ if latency_type in ("3", "internet") and max_latency is not None:
+ spread = float(max_latency)
+ return {
+ "ethernetDelay": {
+ "delay": delay_val,
+ "delayMax": delay_val + 0.9 * spread,
+ "isUncorrelated": False,
+ "maxNegDelta": 0.4,
+ "pdvMode": "INTERNET",
+ "delayMin": delay_val - 0.1 * spread,
+ "units": "MS",
+ "maxPosDelta": 0.5,
+ "enabled": True,
+ "spread": 100.0,
+ }
+ }
+ return {
+ "ethernetDelay": {
+ "delay": delay_val,
+ "delayMax": 15.0,
+ "isUncorrelated": False,
+ "maxNegDelta": 0.1,
+ "pdvMode": "NONE",
+ "delayMin": 5.0,
+ "units": "MS",
+ "maxPosDelta": 0.1,
+ "enabled": True,
+ "spread": 1.0,
+ }
+ }
+
+ @staticmethod
+ def añadir_configuracion_puerto_ipv4(
+ source_hx: str | None,
+ destination_hx: str | None,
+ protocolo: str | None,
+ ) -> dict[str, Any]:
+ """Generate IPv4 matching rule configuration dictionary."""
+ reglas: list[dict[str, Any]] = [{"field": "Common::IPv4::Version", "value": "4", "mask": "f"}]
if source_hx:
reglas.append({"field": "Common::IPv4::Source Address", "value": source_hx, "mask": "ffffffff"})
if destination_hx:
reglas.append({"field": "Common::IPv4::Destination Address", "value": destination_hx, "mask": "ffffffff"})
if protocolo:
reglas.append({"field": "Common::IPv4::Protocol", "value": protocolo, "mask": "ff"})
- configuracion = {"profiles": [{"dramAllocation": {"mode": "AUTO","fixedSize": 1700352},"rules": reglas,"tag": "test_api"}],"defaultProfile": {"dramAllocation": {"mode": "AUTO","fixedSize": 1700352},"tag": "defaultProfile"}}
- return configuracion
- def añadir_configuracion_puerto_ipv6(source,destination):
- """
- Añade una configuración de puerto según las IPv6s de origen y destino de la comunicación.
-
- Args:
- source (int): Dirección IPv6 de origen en hexadecimal.
- destination (int): Dirección IPv6 de destino en hexadecimal.
-
- Returns:
- La respuesta de la API (texto).
- """
- reglas=[{'field': 'Common::IPv6::Version', 'bitRange': 'L3@0[7]+3', 'value': '6', 'mask': 'f'}]
+ return {
+ "profiles": [
+ {
+ "dramAllocation": {"mode": "AUTO", "fixedSize": 1700352},
+ "rules": reglas,
+ "tag": "test_api",
+ }
+ ],
+ "defaultProfile": {
+ "dramAllocation": {"mode": "AUTO", "fixedSize": 1700352},
+ "tag": "defaultProfile",
+ },
+ }
+
+ @staticmethod
+ def añadir_configuracion_puerto_ipv6(
+ source: str | None,
+ destination: str | None,
+ ) -> dict[str, Any]:
+ """Generate IPv6 matching rule configuration dictionary."""
+ reglas: list[dict[str, Any]] = [
+ {"field": "Common::IPv6::Version", "bitRange": "L3@0[7]+3", "value": "6", "mask": "f"}
+ ]
if source:
- reglas.append({'field': 'Common::IPv6::Source Address', 'bitRange': 'L3@8[7]+127', 'value': source, 'mask': 'ffffffffffffffffffffffffffffffff'})
+ reglas.append(
+ {
+ "field": "Common::IPv6::Source Address",
+ "bitRange": "L3@8[7]+127",
+ "value": source,
+ "mask": "ffffffffffffffffffffffffffffffff",
+ }
+ )
if destination:
- reglas.append({'field': 'Common::IPv6::Destination Address', 'bitRange': 'L3@24[7]+127', 'value': destination, 'mask': 'ffffffffffffffffffffffffffffffff'})
- configuracion = {"profiles": [{"dramAllocation": {"mode": "AUTO","fixedSize": 1700352},"rules": reglas,"tag": "test_api"}],"defaultProfile": {"dramAllocation": {"mode": "AUTO","fixedSize": 1700352},"tag": "defaultProfile"}}
- return configuracion
-
- def añadir_configuracion_VLAN(vlan):
- """
- Añade una configuración de puerto según su VLAN.
-
- Args:
- vlan (int): Número de identificación de la VLAN.
-
- Returns:
- La respuesta de la API (texto).
- """
- vlan=hex(vlan)[2:]
- configuracion={'profiles': [{'dramAllocation': {'mode': 'AUTO', 'fixedSize': 1700352}, 'rules': [{'field': 'Common::Second Tag::VLAN ID', 'bitRange': 'L2@18[3]+11', 'value': vlan, 'mask': 'fff'}]}]}
- return configuracion
-
- def añadir_configuración_packetDrop(drops,total,version,desv):
- """
- Añade una configuración de puerto según su configuracion de packet Drop un NE2.
-
- Args:
- drops (int): cantidad de paquetes dropeados
- total (int): cantidad total de paquetes
- version (string): version of the probability
-
- Returns:
- La respuesta de la API (texto).
- """
- if version != "GAUSSIAN" and version !="POISSON":
- configuracion={'packetDrop': {'rdmSel': {'dist': version, 'burstlen': drops, 'interval': total, 'stddev': 10.0}, 'enabled': True}}
- else:
- configuracion={'packetDrop': {'rdmSel': {'dist': version, 'burstlen': drops, 'interval': total, 'stddev': desv}, 'enabled': True}}
- return configuracion
-
- def añadir_configuracion_policer(bitrate):
- """
- Añade una configuración de puerto según su TX bandwidth a un NE2.
-
- Args:
- bitrate (int): bandwith en Tx
-
- Returns:
- La respuesta de la API (texto).
- """
- configuracion={'policer':{'excessBitRate': bitrate, 'excessBurstTolerance': 64000, 'commitedBurstTolerance': 64000, 'commitedBitRate': bitrate, 'enabled': True, 'enableRateCoupling': False}}
- return configuracion
-
- def añadir_configuracion_shaper(bitrate):
- """
- Añade una configuración de puerto según su RX bandwidth a un NE2.
-
- Args:
- bitrate (int): bandwith en Rx
-
- Returns:
- La respuesta de la API (texto).
- """
- configuracion={'shaper': {'burstTolerance': 64000, 'bitRate': bitrate, 'enabled': True}}
- return configuracion
-
- def añadir_configuracion_reorder(packages,reorder,npackagesreorder, maxreord, version, stev):
- """
- Adds reorder configuration.
-
- Args:
- packages: number of packages total
- reorder: number of packages to reorder
- npackagesreorder: number of packages of reorder
- maxreord: total number of packages of reorder
- version: version of reorder
- stev: desviation
-
- Returns:
- response of the API (text).
- """
- if packages is not None and npackagesreorder is not None and maxreord is not None:
- reorderByMin = min(npackagesreorder, maxreord)
- reorderByMax = max(npackagesreorder, maxreord)
- if version != 'GAUSSIAN':
- configuracion={"reorder": {"rdmSel": {"dist": version, "burstlen": reorder, "interval": packages, "stddev": 10.0 }, "reorderByMin": reorderByMin, "reorderByMax": reorderByMax, "enabled": True}}
- else:
- configuracion={"reorder": {"rdmSel": {"dist": "GAUSSIAN", "burstlen": reorder, "interval": packages, "stddev": stev }, "reorderByMin": reorderByMin, "reorderByMax": reorderByMax, "enabled": True}}
- if int(reorder) <= 0:
- configuracion["filterWarning"] = "El valor de reorder es inválido, está fuera del rango permitido."
- else:
- reorder=10000-reorder
- configuracion={"reorder": {"rdmSel": {"dist": "PERIODIC", "burstlen": int(reorder/100), "interval": 100, "stddev": 10.0 }, "reorderByMin": 1, "reorderByMax": 5, "enabled": True}}
- return configuracion
-
- def envio_peticion(ip,puerto, configuracion):
- """
- Envía una configuración de puerto a un NE2.
-
- Args:
- ip (int): IP del NE2
- puerto: número del puerto a configurar
- configuración: la información que se quiere configurar en el puerto
-
- Returns:
- La respuesta de la API (texto).
- """
- print(f'\nCONFIGURACION\n{configuracion}')
+ reglas.append(
+ {
+ "field": "Common::IPv6::Destination Address",
+ "bitRange": "L3@24[7]+127",
+ "value": destination,
+ "mask": "ffffffffffffffffffffffffffffffff",
+ }
+ )
+
+ return {
+ "profiles": [
+ {
+ "dramAllocation": {"mode": "AUTO", "fixedSize": 1700352},
+ "rules": reglas,
+ "tag": "test_api",
+ }
+ ],
+ "defaultProfile": {
+ "dramAllocation": {"mode": "AUTO", "fixedSize": 1700352},
+ "tag": "defaultProfile",
+ },
+ }
+
+ @staticmethod
+ def añadir_configuracion_VLAN(vlan: int) -> dict[str, Any]:
+ """Generate VLAN matching configuration dictionary."""
+ vlan_hex = hex(vlan)[2:]
+ return {
+ "profiles": [
+ {
+ "dramAllocation": {"mode": "AUTO", "fixedSize": 1700352},
+ "rules": [
+ {
+ "field": "Common::Second Tag::VLAN ID",
+ "bitRange": "L2@18[3]+11",
+ "value": vlan_hex,
+ "mask": "fff",
+ }
+ ],
+ }
+ ]
+ }
+
+ @staticmethod
+ def añadir_configuración_packetDrop(
+ drops: int | None = 0,
+ total: int | None = 100,
+ version: str = "PERIODIC",
+ desv: float = 10.0,
+ ) -> dict[str, Any]:
+ """Generate packet drop configuration dictionary."""
+ stddev = desv if version in ("GAUSSIAN", "POISSON") else 10.0
+ return {
+ "packetDrop": {
+ "rdmSel": {
+ "dist": version,
+ "burstlen": drops,
+ "interval": total,
+ "stddev": stddev,
+ },
+ "enabled": True,
+ }
+ }
+
+ @staticmethod
+ def añadir_configuracion_policer(bitrate: int) -> dict[str, Any]:
+ """Generate TX policer configuration dictionary."""
+ return {
+ "policer": {
+ "excessBitRate": bitrate,
+ "excessBurstTolerance": 64000,
+ "commitedBurstTolerance": 64000,
+ "commitedBitRate": bitrate,
+ "enabled": True,
+ "enableRateCoupling": False,
+ }
+ }
+
+ @staticmethod
+ def añadir_configuracion_shaper(bitrate: int) -> dict[str, Any]:
+ """Generate RX shaper configuration dictionary."""
+ return {
+ "shaper": {
+ "burstTolerance": 64000,
+ "bitRate": bitrate,
+ "enabled": True,
+ }
+ }
+
+ @staticmethod
+ def añadir_configuracion_reorder(
+ packages: int | None = None,
+ reorder: int | None = 0,
+ npackagesreorder: int | None = None,
+ maxreord: int | None = None,
+ version: str = "PERIODIC",
+ stev: float = 10.0,
+ ) -> dict[str, Any]:
+ """Generate packet reorder configuration dictionary."""
+ if packages is not None and npackagesreorder is not None and maxreord is not None:
+ reorder_by_min = min(npackagesreorder, maxreord)
+ reorder_by_max = max(npackagesreorder, maxreord)
+ stddev = stev if version == "GAUSSIAN" else 10.0
+ config: dict[str, Any] = {
+ "reorder": {
+ "rdmSel": {
+ "dist": version,
+ "burstlen": reorder,
+ "interval": packages,
+ "stddev": stddev,
+ },
+ "reorderByMin": reorder_by_min,
+ "reorderByMax": reorder_by_max,
+ "enabled": True,
+ }
+ }
+ if (reorder or 0) <= 0:
+ config["filterWarning"] = "El valor de reorder es inválido, está fuera del rango permitido."
+ return config
+
+ reorder_val = (10000 - (reorder or 0)) // 100
+ return {
+ "reorder": {
+ "rdmSel": {
+ "dist": "PERIODIC",
+ "burstlen": int(reorder_val),
+ "interval": 100,
+ "stddev": 10.0,
+ },
+ "reorderByMin": 1,
+ "reorderByMax": 5,
+ "enabled": True,
+ }
+ }
+
+ @staticmethod
+ def envio_peticion(ip: str, puerto: str, configuracion: dict[str, Any]) -> str | None:
+ """Send port configuration payload to NEII emulator."""
url = f"http://{ip}/api/hw/Port/{puerto}"
- response = requests.put(url, json=configuracion, auth=('admin', 'admin'))
-
- if response.status_code == 200:
- print(f'\n{configuracion}')
- return response.text
- else:
- try:
- error_info = response.json()
- except ValueError:
- error_info = response.text
-
- print(f"\n\nError al añadir configuración de puerto: {response.status_code}")
- print(f"Mensaje de error: {error_info}")
- print(f"Configuración enviada: {configuracion}\n\n")
- return None
\ No newline at end of file
+ try:
+ response = requests.put(url, json=configuracion, auth=("admin", "admin"), timeout=30)
+ if response.status_code == 200:
+ return response.text
+ logger.error("Error setting port %s on %s: status %s", puerto, ip, response.status_code)
+ return None
+ except Exception as e:
+ logger.error("Failed to send port configuration to %s: %s", ip, e)
+ return None
diff --git a/src/realizer/ixia/ixia_connect.py b/src/realizer/ixia/ixia_connect.py
index 107d9859376d7a3b098eab1195c7b5cc25cd4de9..c24459bfe82c8a2414a7b287d49badec36767fe6 100644
--- a/src/realizer/ixia/ixia_connect.py
+++ b/src/realizer/ixia/ixia_connect.py
@@ -1,11 +1,11 @@
# Copyright 2022-2026 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.
@@ -14,22 +14,27 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
+"""Connector sending configuration requests to IXIA NEII network emulator."""
+
+from __future__ import annotations
+
+from typing import Any
+
from .helpers.NEII_V4 import NEII_controller
-def ixia_connect(requests, ixia_ip):
- """
- Connect to the IXIA NEII controller and send the requests.
-
+
+def ixia_connect(requests: dict[str, Any], ixia_ip: str) -> Any:
+ """Connect to the IXIA NEII controller and send slice intents.
+
Args:
- requests (dict): IXIA NEII requests
- ixia_ip (str): IXIA NEII controller IP address
-
+ requests: Dictionary containing list of service intents under 'services'.
+ ixia_ip: IXIA NEII controller IP address.
+
Returns:
- response (requests.Response): Response from the IXIA NEII controller
+ Response from the IXIA NEII controller.
"""
response = None
neii_controller = NEII_controller(ixia_ip)
- for intent in requests["services"]:
- # Send each separate IXIA request
+ for intent in requests.get("services", []):
response = neii_controller.nscNEII(intent)
- return response
\ No newline at end of file
+ return response
diff --git a/src/realizer/ixia/main.py b/src/realizer/ixia/main.py
index ff3c890354c8f416ed5a8b9971be9951447c68dc..aae7c2939c94f54819b880e385570f45fdeb015c 100644
--- a/src/realizer/ixia/main.py
+++ b/src/realizer/ixia/main.py
@@ -1,11 +1,11 @@
# Copyright 2022-2026 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.
@@ -14,81 +14,85 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
+"""IXIA network emulator slice intent realizer."""
+
+from __future__ import annotations
+
import logging
+from typing import Any
-def ixia(ietf_intent):
- """
- Prepare an Ixia service request based on the IETF intent.
+from src.utils.safe_get import safe_get
- This method configures an Ixia service request by:
- 1. Defining endpoint routers
- 2. Loading a service template
- 3. Generating a unique service UUID
- 4. Configuring service endpoints
- 5. Adding QoS constraints
+logger = logging.getLogger(__name__)
- Args:
- ietf_intent (dict): IETF-formatted network slice intent.
+_METRIC_BOUND_MAP = {
+ "one-way-bandwidth": "bandwidth",
+ "one-way-delay-maximum": "latency",
+ "one-way-delay-variation-maximum": "tolerance",
+}
- Returns:
- dict: An Ixia service request for configuration.
- """
- metric_bounds = ietf_intent.get("ietf-network-slice-service:network-slice-services", {}) \
- .get("slo-sle-templates", {}) \
- .get("slo-sle-template", [{}])[0] \
- .get("slo-policy", {}) \
- .get("metric-bound", [])
-
- # Inicializar valores
- bandwidth = None
- latency = None
- tolerance = None
-
- # Assign values according to metric type
+
+def _extract_metrics(metric_bounds: list[dict[str, Any]]) -> dict[str, Any]:
+ """Extract bandwidth, latency, and tolerance metrics from template metric bounds."""
+ extracted: dict[str, Any] = {
+ "bandwidth": None,
+ "latency": None,
+ "tolerance": None,
+ }
for metric in metric_bounds:
metric_type = metric.get("metric-type")
- bound = metric.get("bound")
+ target_key = _METRIC_BOUND_MAP.get(metric_type or "")
+ if target_key:
+ extracted[target_key] = metric.get("bound")
+ return extracted
+
+
+def ixia(ietf_intent: dict[str, Any]) -> dict[str, Any]:
+ """Prepare an Ixia service request based on the IETF slice intent.
- if metric_type == "one-way-bandwidth":
- bandwidth = bound
- elif metric_type == "one-way-delay-maximum":
- latency = bound
- elif metric_type == "one-way-delay-variation-maximum":
- tolerance = bound
+ Args:
+ ietf_intent: IETF formatted network slice intent dictionary.
+
+ Returns:
+ Structured intent configuration for IXIA NEII.
+ """
+ root_services = ietf_intent.get("ietf-network-slice-service:network-slice-services", {})
+ templates = root_services.get("slo-sle-templates", {}).get("slo-sle-template", [{}])
+ first_template = templates[0] if templates else {}
+
+ metric_bounds = first_template.get("slo-policy", {}).get("metric-bound", [])
+ metrics = _extract_metrics(metric_bounds)
+
+ slice_services = root_services.get("slice-service", [{}])
+ first_slice = slice_services[0] if slice_services else {}
+ sdps = first_slice.get("sdps", {}).get("sdp", [{}, {}])
+
+ sdp_src = sdps[0] if len(sdps) > 0 else {}
+ sdp_dst = sdps[1] if len(sdps) > 1 else {}
+
+ src_node_ip = safe_get(
+ sdp_src,
+ ["attachment-circuits", "attachment-circuit", 0, "sdp-peering", "peer-sap-id"],
+ )
+ dst_node_ip = safe_get(
+ sdp_dst,
+ ["attachment-circuits", "attachment-circuit", 0, "sdp-peering", "peer-sap-id"],
+ )
+ vlan_id = safe_get(
+ sdp_src,
+ ["service-match-criteria", "match-criterion", 0, "match-type", 0, "vlan", 0],
+ )
- # Construction of the intent dictionary
intent = {
- "src_node_ip": ietf_intent.get("ietf-network-slice-service:network-slice-services", {})
- .get("slice-service", [{}])[0]
- .get("sdps", {}).get("sdp", [{}])[0]
- .get("attachment-circuits", {}).get("attachment-circuit", [{}])[0]
- .get("sdp-peering", {}).get("peer-sap-id"),
-
- "dst_node_ip": ietf_intent.get("ietf-network-slice-service:network-slice-services", {})
- .get("slice-service", [{}])[0]
- .get("sdps", {}).get("sdp", [{}, {}])[1]
- .get("attachment-circuits", {}).get("attachment-circuit", [{}])[0]
- .get("sdp-peering", {}).get("peer-sap-id"),
-
- "vlan_id": ietf_intent.get("ietf-network-slice-service:network-slice-services", {})
- .get("slice-service", [{}])[0]
- .get("sdps", {}).get("sdp", [{}])[0]
- .get("service-match-criteria", {}).get("match-criterion", [{}])[0]
- .get("match-type", [{}])[0]
- .get("vlan", [None])[0],
-
- "bandwidth": bandwidth,
- "latency": latency,
- "tolerance": tolerance,
-
- "latency_version": ietf_intent.get("ietf-network-slice-service:network-slice-services", {})
- .get("slo-sle-templates", {}).get("slo-sle-template", [{}])[0]
- .get("description"),
-
- "reliability": ietf_intent.get("ietf-network-slice-service:network-slice-services", {})
- .get("slo-sle-templates", {}).get("slo-sle-template", [{}])[0]
- .get("sle-policy", {}).get("reliability"),
+ "src_node_ip": src_node_ip,
+ "dst_node_ip": dst_node_ip,
+ "vlan_id": vlan_id,
+ "bandwidth": metrics["bandwidth"],
+ "latency": metrics["latency"],
+ "tolerance": metrics["tolerance"],
+ "latency_version": first_template.get("description"),
+ "reliability": first_template.get("sle-policy", {}).get("reliability"),
}
- logging.info(f"IXIA Intent realized\n")
- return intent
\ No newline at end of file
+ logger.info("IXIA Intent realized\n")
+ return intent
diff --git a/src/realizer/main.py b/src/realizer/main.py
index 582cb81a8824331d658a4855d3e41f113f064429..13bf666b961ef2116ee15c6793ec0cb7f684e5a6 100644
--- a/src/realizer/main.py
+++ b/src/realizer/main.py
@@ -14,90 +14,182 @@
# This file includes original contributions from Telefonica Innovación Digital S.L.
+"""Main realizer entry point orchestrating slice creation, monitoring, and reconfiguration."""
+
+from __future__ import annotations
+
import logging
-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 typing import Any
+
+from flask import current_app
+
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
+from src.utils.safe_get import safe_get
-def realizer(payload, need_nrp=False, order=None, nrp=None, controller_type=None, response=None, rules = None, action="CREATE"):
- """
- Manage the slice creation workflow.
+from .get_metrics import get_metrics
+from .nrp_handler import nrp_handler
+from .select_way import select_way
+
+logger = logging.getLogger(__name__)
+
+
+def _determine_e2e_way(rules: Any) -> str | None:
+ """Determine the realization way for E2E controller based on rule actions."""
+ if isinstance(rules, list) and rules:
+ rules = rules[0]
+ actions = rules.get("actions", []) if isinstance(rules, dict) else []
+
+ action_types = {a.get("type", "") for a in actions if isinstance(a, dict)}
+
+ has_transceiver = any(t.startswith("XR_AGENT_ACTIVATE_TRANSCEIVER") for t in action_types)
+ has_optical = any(t.startswith("PROVISION_MEDIA_CHANNEL") for t in action_types)
+ has_l3 = any(t.startswith("CONFIG_VPNL3") for t in action_types)
+ has_l2 = any(t.startswith("CONFIG_VPNL2") for t in action_types)
- This method handles two primary scenarios:
- 1. Interact with network controllers for NRP (Network Resource Partition) operations when need_nrp is True
- 2. Slice service selection when need_nrp is False
+ del_transceiver = any(t.startswith("DEACTIVATE_XR_AGENT_TRANSCEIVER") for t in action_types)
+ del_optical = any(t.startswith("DEPROVISION_OPTICAL_RESOURCE") for t in action_types)
+ del_l3 = any(t.startswith("REMOVE_VPNL3") for t in action_types)
+ del_l2 = any(t.startswith("REMOVE_VPNL2") for t in action_types)
+
+ # Creation mappings
+ if has_transceiver or (has_optical and has_l3):
+ return "L3oWDM"
+ if has_optical and has_l2:
+ return "L2oWDM"
+ if has_optical:
+ return "OPTIC"
+ if has_l3:
+ return "L3VPN"
+ if has_l2:
+ return "L2VPN"
+
+ # Deletion mappings
+ if del_transceiver or (del_optical and del_l3):
+ return "DEL_L3oWDM"
+ if del_optical and del_l2:
+ return "DEL_L2oWDM"
+ if del_optical:
+ return "DEL_OPTIC"
+ if del_l3:
+ return "DEL_L3VPN"
+ if del_l2:
+ return "DEL_L2VPN"
+
+ logger.warning("Cannot determine the realization way from rules. Skipping request.")
+ return None
+
+
+def _realize_create(
+ payload: Any,
+ need_nrp: bool = False,
+ order: str | None = None,
+ nrp: dict[str, Any] | None = None,
+ controller_type: str | None = None,
+ response: Any = None,
+ rules: Any = None,
+) -> Any:
+ """Handle slice creation or NRP configuration workflow."""
+ if need_nrp:
+ return nrp_handler(order, nrp)
+
+ service = payload
+ if controller_type == "E2E":
+ way = _determine_e2e_way(rules)
+ if not way:
+ return None
+ else:
+ way = service.get("way") if isinstance(service, dict) else None
+ if not way and isinstance(service, dict):
+ way = safe_get(service, ["ietf-network-slice-service:network-slice-services","slice-service",0,"service-tags","tag-type",0,"tag-type-value",0,])
+
+ logger.info("Selected way: %s", way)
+ return select_way(controller=controller_type, way=way, ietf_intent=service, response=response, rules=rules)
+
+
+def _realize_monitor(payload: dict[str, Any], controller_type: str | None) -> None:
+ """Handle slice monitoring metric gathering."""
+ logger.debug("Realizer action: MONITOR")
+ slice_id = payload.get("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"]
+
+ restconf_ip = current_app.config["RESTCONF_IP"]
+ path, response_code = tfs_connector().get_service_path(restconf_ip, service_id)
+ if response_code == 200:
+ logger.debug("Retrieved service path for slice '%s' (service '%s'): %s", slice_id, service_id, path)
+ get_metrics(path, slice_id, controller_type)
+ else:
+ raise RuntimeError("Error: Service path not retrieved")
+
+
+def _realize_reconfig(payload: Any) -> dict[str, Any]:
+ """Handle slice reconfiguration path and topology retrieval."""
+ logger.debug("Realizer action: RECONFIG")
+ slice_id = payload.get("slice_id") if isinstance(payload, dict) else payload
+ service_data = get_data_by_slice_id(slice_id)
+ logger.debug("DEBUG: Slice data found for slice '%s': %s", slice_id, service_data)
+ if not service_data:
+ raise ValueError(f"No services found for slice '{slice_id}'")
+
+ service_id = service_data[0]["service_id"]
+ logger.debug("DEBUG: Service ID for slice '%s': %s", slice_id, service_id)
+
+ tfs_ip = current_app.config.get("RESTCONF_IP", "127.0.0.1")
+ logger.debug("DEBUG: TFS IP: %s", tfs_ip)
+ conn = tfs_connector()
+
+ path, path_code = conn.get_service_path(tfs_ip, service_id)
+ logger.debug("DEBUG: Path: %s", path)
+ if path_code != 200 or not path:
+ raise RuntimeError(f"Could not retrieve service path for service '{service_id}'")
+
+ network, topo_code = conn.get_network_topology(tfs_ip, slice_id)
+ logger.debug("DEBUG: Network topology: %s", network)
+ if topo_code != 200 or not network:
+ raise RuntimeError(f"Could not retrieve network topology for slice '{slice_id}'")
+
+ return {
+ "slice_id": slice_id,
+ "service_path": path,
+ "network_topology": network,
+ }
+
+
+def realizer(
+ payload: Any,
+ need_nrp: bool = False,
+ order: str | None = None,
+ nrp: dict[str, Any] | None = None,
+ controller_type: str | None = None,
+ response: Any = None,
+ rules: Any = None,
+ action: str = "CREATE",
+) -> Any:
+ """Manage the slice realization workflow dispatching based on action.
Args:
- ietf_intent (dict): IETF-formatted network slice intent.
- need_nrp (bool, optional): Flag to indicate if NRP operations are needed. Defaults to False.
- order (str, optional): Type of NRP operation (READ, UPDATE, CREATE). Defaults to None.
- nrp (dict, optional): Specific Network Resource Partition to operate on. Defaults to None.
- controller_type (str, optional): Type of controller (TFS, IXIA, E2E). Defaults to None.
- response (dict, optional): Response built for user feedback. Defaults to None.
- rules (dict, optional): Specific rules for slice realization. Defaults to None.
-
+ payload: Intent, service, or slice data payload.
+ need_nrp: Whether NRP handling is needed. Defaults to False.
+ order: NRP operation (READ, UPDATE, CREATE). Defaults to None.
+ nrp: Network Resource Partition data. Defaults to None.
+ controller_type: Target SDN controller type. Defaults to None.
+ response: Outgoing response object. Defaults to None.
+ rules: Dynamic realization rules. Defaults to None.
+ action: Action type ('CREATE', 'MONITOR', 'RECONFIG'). Defaults to 'CREATE'.
+
Returns:
- dict: A realization request for the specified network slice type.
+ Response from downstream controller realization.
"""
- if action == "CREATE":
- service = payload
- if need_nrp:
- # Perform NRP-related operations
- nrp_view = nrp_handler(order, nrp)
- return nrp_view
- else:
- # Select slice service method
- if controller_type == "E2E":
- if isinstance(rules, list) and len(rules) > 0: rules = rules[0]
- actions = rules.get("actions", []) if (rules and not type(rules)== str) else []
-
- has_transceiver = any(a.get("type", "").startswith("XR_AGENT_ACTIVATE_TRANSCEIVER") for a in actions)
- has_optical = any(a.get("type", "").startswith("PROVISION_MEDIA_CHANNEL") for a in actions)
- has_l3 = any(a.get("type", "").startswith("CONFIG_VPNL3") for a in actions)
- has_l2 = any(a.get("type", "").startswith("CONFIG_VPNL2") for a in actions)
-
- del_transceiver = any(a.get("type", "").startswith("DEACTIVATE_XR_AGENT_TRANSCEIVER") for a in actions)
- del_optical = any(a.get("type", "").startswith("DEPROVISION_OPTICAL_RESOURCE") for a in actions)
- del_l3 = any(a.get("type", "").startswith("REMOVE_VPNL3") for a in actions)
- del_l2 = any(a.get("type", "").startswith("REMOVE_VPNL2") for a in actions)
-
- if has_transceiver: selected_way = "L3oWDM"
- elif has_optical and has_l3: selected_way = "L3oWDM"
- elif has_optical and has_l2: selected_way = "L2oWDM"
- elif has_optical: selected_way = "OPTIC"
- elif has_l3: selected_way = "L3VPN"
- elif has_l2: selected_way = "L2VPN"
-
- elif del_transceiver: selected_way = "DEL_L3oWDM"
- elif del_optical and del_l3: selected_way = "DEL_L3oWDM"
- elif del_optical and del_l2: selected_way = "DEL_L2oWDM"
- elif del_optical: selected_way = "DEL_OPTIC"
- elif del_l3: selected_way = "DEL_L3VPN"
- elif del_l2: selected_way = "DEL_L2VPN"
- else:
- logging.warning("Cannot determine the realization way from rules. Skipping request.")
- return None
- way = selected_way
- else:
- way = service.get("way", None) or safe_get(service, ['ietf-network-slice-service:network-slice-services', 'slice-service', 0, 'service-tags', 'tag-type', 0, 'tag-type-value', 0])
- logging.info(f"Selected way: {way}")
- request = select_way(controller=controller_type, way=way, ietf_intent=service, response=response, rules = rules)
- return request
- elif action == "MONITOR":
- logging.debug("Realizer action: MONITOR")
- slice_id = payload.get("slice_id", None)
- 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 service path for slice '{slice_id}' (service '{service_id}'): {path}")
- get_metrics(path, slice_id, controller_type)
- else:
- raise Exception("Error: Service path not retrieved")
\ No newline at end of file
+ match action:
+ case "CREATE":
+ return _realize_create(payload, need_nrp, order, nrp, controller_type, response, rules)
+ case "MONITOR":
+ return _realize_monitor(payload, controller_type)
+ case "RECONFIG":
+ return _realize_reconfig(payload)
+ case _:
+ logger.warning("Unknown realizer action: %s", action)
+ return None
diff --git a/src/realizer/nrp_handler.py b/src/realizer/nrp_handler.py
index f17f2c9bcbe23f38b0e3f8830db546590a90d71b..cd666ed0e3a502bb698d6ab2fa23a7ac6909d116 100644
--- a/src/realizer/nrp_handler.py
+++ b/src/realizer/nrp_handler.py
@@ -1,11 +1,11 @@
# Copyright 2022-2026 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
-
+#
+# 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.
@@ -14,59 +14,55 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
-import logging, os, json
+"""Network Resource Partition (NRP) CRUD handler and static datastore."""
+
+from __future__ import annotations
+
+import json
+import logging
+from pathlib import Path
+from typing import Any
+
from src.config.constants import DATABASE_PATH
-def nrp_handler(request, nrp):
- """
- Manage Network Resource Partition (NRP) operations.
+logger = logging.getLogger(__name__)
- This method handles CRUD operations for Network Resource Partitions,
- interacting with Network Controllers (currently done statically via a JSON-based database file).
- Args:
- request (str): The type of operation to perform.
- Supported values:
- - "CREATE": Add a new NRP to the database
- - "READ": Retrieve the current NRP view
- - "UPDATE": Update an existing NRP (currently a placeholder)
+def _read_nrp_database() -> list[dict[str, Any]]:
+ """Read the existing NRP database records from disk."""
+ db_file = Path(DATABASE_PATH) / "nrp_ddbb.json"
+ if not db_file.exists():
+ return []
+ with db_file.open("r", encoding="utf-8") as f:
+ return json.load(f)
- nrp (dict): The Network Resource Partition details to create or update.
- Returns:
- None or answer:
- - For "CREATE": Returns the response from the controller (currently using a static JSON)
- - For "READ": Gets the NRP view from the controller (currently using a static JSON)
- - For "UPDATE": Placeholder for update functionality
+def nrp_handler(request: str | None, nrp: dict[str, Any] | None) -> list[dict[str, Any]] | None | str:
+ """Manage Network Resource Partition (NRP) operations.
- Notes:
- - Uses a local JSON file "nrp_ddbb.json" to store NRP information as controller operation is not yet defined
- """
- if request == "CREATE":
- # TODO: Implement actual request to Controller to create an NRP
- logging.debug("Creating NRP")
+ Args:
+ request: Operation type ('CREATE', 'READ', 'UPDATE').
+ nrp: Network Resource Partition data dictionary.
- # Load existing NRP database
- with open(os.path.join(DATABASE_PATH, "nrp_ddbb.json"), "r") as archivo:
- nrp_view = json.load(archivo)
+ Returns:
+ NRP records for 'READ', placeholder string for 'UPDATE', or None for 'CREATE'.
+ """
+ match request:
+ case "CREATE":
+ logger.debug("Creating NRP")
+ nrp_view = _read_nrp_database()
+ if nrp is not None:
+ nrp_view.append(nrp)
+ return None
- # Append new NRP to the view
- nrp_view.append(nrp)
+ case "READ":
+ logger.debug("Reading Topology and NRP view")
+ return _read_nrp_database()
- # Placeholder for controller POST request
- answer = None
- return answer
- elif request == "READ":
- # TODO: Request to Controller to get topology and current NRP view
- logging.debug("Reading Topology")
+ case "UPDATE":
+ logger.debug("Updating NRP")
+ return ""
- # Load NRP database
- with open(os.path.join(DATABASE_PATH, "nrp_ddbb.json"), "r") as archivo:
- # self.__nrp_view = json.load(archivo)
- nrp_view = json.load(archivo)
- return nrp_view
-
- elif request == "UPDATE":
- # TODO: Implement request to Controller to update NRP
- logging.debug("Updating NRP")
- answer = ""
\ No newline at end of file
+ case _:
+ logger.warning("Unsupported NRP request type: %s", request)
+ return None
diff --git a/src/realizer/restconf/connectors/cisco_connector.py b/src/realizer/restconf/connectors/cisco_connector.py
index 9136b16677bd7e3f590316ed99fc0f1d06ec5714..ba8623e092f8d2dadd7cc34372dac557fff47aec 100644
--- a/src/realizer/restconf/connectors/cisco_connector.py
+++ b/src/realizer/restconf/connectors/cisco_connector.py
@@ -1,11 +1,11 @@
# Copyright 2022-2026 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.
@@ -14,108 +14,92 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
+"""SSH Netmiko connector for Cisco XR devices."""
+
+from __future__ import annotations
+
import logging
+from typing import Any
+
from netmiko import ConnectHandler
-class cisco_connector():
- """Class to interact with Cisco devices via SSH using Netmiko."""
- def __init__(self, address, configs=None):
- self.address=address
- self.configs=configs
+logger = logging.getLogger(__name__)
- def execute_commands(self, commands):
- """
- Execute a list of commands on the Cisco device.
- Args:
- commands (list): List of commands to execute on the device.
- """
- try:
- # Device configuration
- device = {
- 'device_type': 'cisco_xr', # This depends on the Cisco device type
- 'host': self.address,
- 'username': 'cisco',
- 'password': 'cisco12345',
- }
- # SSH connection
- connection = ConnectHandler(**device)
+class cisco_connector:
+ """Class to interact with Cisco XR devices via SSH using Netmiko."""
- # Send commands
- output = connection.send_config_set(commands)
- logging.debug(output)
+ def __init__(self, address: str, configs: list[dict[str, Any]] | None = None) -> None:
+ self.address = address
+ self.configs = configs or []
- # Close connection
- connection.disconnect()
+ def execute_commands(self, commands: list[str]) -> None:
+ """Execute a list of configuration commands on the Cisco device.
+ Args:
+ commands: List of commands to execute.
+ """
+ device: dict[str, Any] = {
+ "device_type": "cisco_xr",
+ "host": self.address,
+ "username": "cisco",
+ "password": "cisco12345",
+ }
+ try:
+ with ConnectHandler(**device) as connection:
+ output = connection.send_config_set(commands)
+ logger.debug(output)
except Exception as e:
- logging.error(f"Failed to execute commands on {self.address}: {str(e)}")
+ logger.error("Failed to execute commands on %s: %s", self.address, e)
- def create_command_template(self, config):
- """
- Create command template for configuring a Cisco device.
+ def create_command_template(self, config: dict[str, Any]) -> list[str]:
+ """Create command template for configuring an L2VPN profile on a Cisco device.
Args:
- config (dict): Configuration parameters for the device.
-
+ config: Configuration dictionary for the device interface and VLAN.
+
Returns:
- list: List of commands to configure the device.
+ List of Cisco XR CLI commands.
"""
- commands = [
+ number = config.get("number")
+ ni_name = config.get("ni_name")
+ interface = config.get("interface")
+ vlan = config.get("vlan")
+ remote_router = config.get("remote_router")
+
+ return [
"l2vpn",
- f"pw-class l2vpn_vpws_profile_example_{config['number']}",
- "encapsulation mpls"
- ]
-
- commands.extend([
+ f"pw-class l2vpn_vpws_profile_example_{number}",
+ "encapsulation mpls",
"transport-mode vlan passthrough",
- "control-word"
- ])
-
- commands.extend([
- f"preferred-path interface tunnel-te {config['number']}",
+ "control-word",
+ f"preferred-path interface tunnel-te {number}",
+ "exit",
"exit",
- "exit"
- ])
-
- commands.extend([
"xconnect group l2vpn_vpws_group_example",
- f"p2p {config['ni_name']}",
- f"interface {config['interface']}.{config['vlan']}",
- f"neighbor ipv4 {config['remote_router']} pw-id {config['vlan']}",
+ f"p2p {ni_name}",
+ f"interface {interface}.{vlan}",
+ f"neighbor ipv4 {remote_router} pw-id {vlan}",
"no pw-class l2vpn_vpws_profile_example",
- f"pw-class l2vpn_vpws_profile_example_{config['number']}"
- ])
-
+ f"pw-class l2vpn_vpws_profile_example_{number}",
+ ]
+
+ def full_create_command_template(self) -> list[str]:
+ """Create full command template for configuring all stored device configurations.
- return commands
-
- def full_create_command_template(self):
- """
- Create full command template for configuring a Cisco device based on the provided configurations.
-
Returns:
- list: List of commands to configure the device.
+ Aggregated list of Cisco XR CLI commands ending with commit.
"""
- commands =[]
+ commands: list[str] = []
for config in self.configs:
- commands_temp = self.create_command_template(config)
- commands.extend(commands_temp)
- commands.append("commit")
- commands.append("end")
+ commands.extend(self.create_command_template(config))
+ commands.extend(["commit", "end"])
return commands
- def create_command_template_delete(self):
- """
- Create command template for deleting L2VPN configuration on a Cisco device.
+ def create_command_template_delete(self) -> list[str]:
+ """Create command template for deleting L2VPN configuration on a Cisco device.
+
Returns:
- list: List of commands to delete the L2VPN configuration.
+ List of commands to delete the L2VPN configuration.
"""
- commands = [
- "no l2vpn",
- ]
-
- commands.append("commit")
- commands.append("end")
-
- return commands
\ No newline at end of file
+ return ["no l2vpn", "commit", "end"]
diff --git a/src/realizer/restconf/connectors/frr_connector.py b/src/realizer/restconf/connectors/frr_connector.py
index cebb3ee1fb131a4e205fe96c1844850bd9017f45..85030cdd959e2a10e8ba2f995ae62cec5a6e8aa2 100644
--- a/src/realizer/restconf/connectors/frr_connector.py
+++ b/src/realizer/restconf/connectors/frr_connector.py
@@ -14,84 +14,108 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
+"""SSH Netmiko connector for Free Range Routing (FRR) Linux devices."""
+
+from __future__ import annotations
+
import logging
+from typing import Any
+
from netmiko import ConnectHandler
-class frr_connector():
+logger = logging.getLogger(__name__)
+
+
+class frr_connector:
"""Class to interact with FRR devices via SSH using Netmiko."""
- def __init__(self, address):
+ def __init__(self, address: str) -> None:
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()
+ def execute_commands(self, commands: list[str]) -> None:
+ """Execute a list of vtysh configuration commands on the FRR host.
+ Args:
+ commands: List of shell/vtysh command lines.
+ """
+ device: dict[str, Any] = {
+ "device_type": "linux",
+ "host": self.address,
+ "username": "root",
+ "password": "root",
+ }
+ try:
+ with ConnectHandler(**device) as connection:
+ output = connection.send_config_set(commands)
+ logger.debug(output)
except Exception as e:
- logging.error(f"Failed to execute commands on {self.address}: {str(e)}")
+ logger.error("Failed to execute commands on %s: %s", self.address, e)
raise
- def setup_slice(self, config: dict, assignments: dict[int, int]) -> list[str]:
- """
- Build PBR commands dynamically based on active slot assignments.
+ def setup_slice(self, config: dict[str, Any], assignments: dict[int, int]) -> list[str]:
+ """Build PBR commands dynamically based on active slot assignments.
Args:
- config: Device config (interfaces, addresses, etc.)
+ config: Device config (interfaces, addresses, output_interfaces).
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",
- ]
+ # Nexthop groups - one per non-default slice
+ for i, iface in enumerate(config["output_interfaces"], start=1):
+ commands.extend(
+ [
+ f"nexthop-group SLICE-{i}",
+ f"nexthop {iface}",
+ "exit",
+ ]
+ )
- # PBR rules - one per active slot, ordered by seq
+ # PBR rules - one per active slot, ordered by sequence
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",
- ]
+ commands.extend(
+ [
+ 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",
- ]
+ commands.extend(
+ [
+ "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",
- ]
+ commands.extend(
+ [
+ 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]:
+ """Build commands to remove all slice PBR configurations.
+
+ Returns:
+ List of FRR/vtysh commands.
+ """
return [
"vtysh",
"conf te",
@@ -100,4 +124,4 @@ class frr_connector():
"no nexthop-group SLICE-2",
"no nexthop-group DEFAULT",
"end",
- ]
\ 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 9c3d9de32794b4007ad95a2e35b1e26828fd1eef..a6340d907890fadecdeed24a08631189dbfb87f1 100644
--- a/src/realizer/restconf/connectors/tfs_connector.py
+++ b/src/realizer/restconf/connectors/tfs_connector.py
@@ -1,11 +1,11 @@
# Copyright 2022-2026 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.
@@ -14,26 +14,44 @@
# This file includes original contributions from Telefonica Innovación Digital S.L.
+"""TeraFlowSDN RESTCONF and NBI Connector for topology, paths, and telemetry streaming."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import threading
+from typing import Any
+
+import aiohttp
+import requests
+
+from src.config.constants import (
+ NBI_IETF_NETWORKS_PATH,
+ NBI_L2_PATH,
+ NBI_L3_PATH,
+ NBI_SIMAP_SUSCRIPTION_PATH,
+)
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
-SDN_SUBSCRIPTION_PERIOD = 10 # seconds
-SDN_SUBS_INACTIVITY_THRESHOLD = 30 # seconds
+logger = logging.getLogger(__name__)
-class tfs_connector():
- """
- Helper class to interact with TeraFlowSDN Northbound Interface (NBI) and WebUI.
- """
- _loop = None
- _thread = None
- _session = None
+SDN_SUBSCRIPTION_PERIOD = 10
+SDN_SUBS_INACTIVITY_THRESHOLD = 30
+
+
+class tfs_connector:
+ """Helper class to interact with TeraFlowSDN Northbound Interface (NBI) and WebUI."""
+
+ _loop: asyncio.AbstractEventLoop | None = None
+ _thread: threading.Thread | None = None
+ _session: aiohttp.ClientSession | None = None
@classmethod
- def get_background_loop(cls):
+ def get_background_loop(cls) -> asyncio.AbstractEventLoop:
+ """Get or initialize the shared background asyncio event loop."""
if cls._loop is None:
cls._loop = asyncio.new_event_loop()
cls._thread = threading.Thread(target=cls._run_loop, args=(cls._loop,), daemon=True)
@@ -41,337 +59,240 @@ class tfs_connector():
return cls._loop
@staticmethod
- def _run_loop(loop):
+ def _run_loop(loop: asyncio.AbstractEventLoop) -> None:
asyncio.set_event_loop(loop)
loop.run_forever()
- def __init__(self):
- pass
-
- def webui_post(self, tfs_ip, service):
- """
- Post service descriptor to TFS WebUI.
-
- Args:
- tfs_ip (str): IP address of the TFS instance
- service (dict): Service descriptor to be posted
-
- Returns:
- requests.Response: Response object from the POST request
- """
- user="admin"
- password="admin"
- token=""
+ def webui_post(self, tfs_ip: str, service: dict[str, Any]) -> requests.Response:
+ """Post service descriptor to TFS WebUI."""
session = requests.Session()
- session.auth = (user, password)
- url=f'http://{tfs_ip}/webui'
- response=session.get(url=url)
- for item in response.iter_lines():
- if("csrf_token" in str(item)):
- string=str(item).split(' requests.Response:
+ """Post service descriptor to TFS NBI."""
session = requests.Session()
- session.auth = (user, password)
- url = f'http://{tfs_ip}/{path}'
- headers = {'Content-Type': 'application/json'}
+ session.auth = ("admin", "admin")
+ url = f"http://{tfs_ip}/{path}"
+ headers = {"Content-Type": "application/json"}
data = json.dumps(service)
- logging.debug("Posting to TFS NBI: %s",data)
- token={'csrf_token':token}
- response = session.post(url,headers=headers,data=data,timeout=60)
+ logger.debug("Posting to TFS NBI: %s", data)
+ response = session.post(url, headers=headers, data=data, timeout=60)
response.raise_for_status()
- logging.debug("Http response: %s",response.text)
+ logger.debug("Http response: %s", response.text)
return response
-
- def nbi_delete(self, tfs_ip: str, service_type: str , service_id: str) -> requests.Response:
- """
- Delete service from TFS NBI.
- Args:
- tfs_ip (str): IP address of the TFS instance
- service_type (str): Type of the service ('L2' or 'L3')
- service_id (str): Unique identifier of the service to delete
- Returns:
- requests.Response: Response object from the DELETE request
- """
- user="admin"
- password="admin"
- url = f'http://{user}:{password}@{tfs_ip}'
- if service_type == 'L2':
- url = url + f'/{NBI_L2_PATH}/vpn-service={service_id}'
- elif service_type == 'L3':
- url = url + f'/{NBI_L3_PATH}/vpn-service={service_id}'
- else:
- raise ValueError("Invalid service type. Use 'L2' or 'L3'.")
+
+ def nbi_delete(self, tfs_ip: str, service_type: str, service_id: str) -> requests.Response:
+ """Delete service from TFS NBI."""
+ base_url = f"http://admin:admin@{tfs_ip}"
+ match service_type:
+ case "L2":
+ url = f"{base_url}/{NBI_L2_PATH}/vpn-service={service_id}"
+ case "L3":
+ url = f"{base_url}/{NBI_L3_PATH}/vpn-service={service_id}"
+ case _:
+ raise ValueError("Invalid service type. Use 'L2' or 'L3'.")
+
response = requests.delete(url, timeout=60)
response.raise_for_status()
- logging.debug('Service deleted successfully')
- logging.debug("Http response: %s",response.text)
+ logger.debug("Service deleted successfully: %s", response.text)
return response
-
- def get_network_topology(self, tfs_ip: str, slice_id: str) -> Tuple[Dict[str, any], int]:
- user="admin"
- password="admin"
- url = f'http://{user}:{password}@{tfs_ip}'
- url = url + f'/{NBI_IETF_NETWORKS_PATH}'
- # MOCKED TOPOLOGY
+
+ def get_network_topology(self, tfs_ip: str, slice_id: str) -> tuple[dict[str, Any] | None, int]:
+ """Retrieve physical network topology from TFS."""
+ url = f"http://admin:admin@{tfs_ip}/{NBI_IETF_NETWORKS_PATH}"
response = requests.get(url, timeout=60)
response.raise_for_status()
network_raw = response.json()
- network = next((n["ietf-network:networks"]["network"][0] for n in network_raw if n["ietf-network:networks"]["network"][0]["network-id"] == "urn:tfs:network:admin"), None)
- logging.debug(f"Retrieved topology for slice '{slice_id}': {network}")
-
+ network = next(
+ (
+ n["ietf-network:networks"]["network"][0]
+ for n in network_raw
+ if n["ietf-network:networks"]["network"][0]["network-id"] == "urn:tfs:network:admin"
+ ),
+ None,
+ )
+ logger.debug("Retrieved topology for slice '%s': %s", slice_id, network)
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
- """
+ """Get human-readable device name for a device UUID from TFS."""
url = f"http://{tfs_ip}/tfs-api/device/{device_uuid}"
- headers = {'accept': 'application/json'}
+ 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'}
+ return str(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."""
+ 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", [])
- if not connections:
- return [], 200
-
- # Extract device sequences from connections
- conn_sequences = []
- for conn in connections:
- 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 = []
- 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 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}")
+ if response.status_code != 200:
+ logger.error(
+ "Failed to retrieve service path for service '%s': status %s", service_id, response.status_code
+ )
return [], response.status_code
+
+ data = response.json()
+ connections = data.get("connections", [])
+ if not connections:
+ return [], 200
+
+ conn_sequences: list[list[str]] = []
+ for conn in connections:
+ hops = conn.get("path_hops_endpoint_ids", [])
+ seq: list[str] = []
+ for hop in hops:
+ dev_id = safe_get(hop, ["device_id", "device_uuid", "uuid"])
+ if dev_id and (not seq or seq[-1] != dev_id):
+ seq.append(dev_id)
+ if seq:
+ conn_sequences.append(seq)
+
+ if not conn_sequences:
+ return [], 200
+
+ 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:
+ current_chain.extend(remaining.pop(matched_next))
+ else:
+ current_chain.extend(remaining.pop(0))
+
+ raw_uuids: list[str] = []
+ seen_uuids: set[str] = 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: dict[str, str] = {}
+ path: list[str] = []
+ 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:
+ logger.warning("Could not retrieve device name for '%s': %s", dev_uuid, e)
+ name_cache[dev_uuid] = dev_uuid
+ path.append(name_cache[dev_uuid])
+
+ logger.debug("Retrieved ordered service path for service '%s': %s", service_id, path)
+ return path, 200
except Exception as e:
- logging.exception(f"Error retrieving service path for service '{service_id}': {e}")
+ logger.exception("Error retrieving service path for service '%s': %s", 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 = {}
+ 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."""
+ intent: dict[str, Any] = {}
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 = {}
+ slice_svc: dict[str, Any] = {}
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')
+ 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 = 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), {})
+ 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'
+ 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 = []
+ raw_sdps = safe_get(slice_svc, ["sdps", "sdp"]) or []
+ sdps = [raw_sdps] if isinstance(raw_sdps, dict) else (list(raw_sdps) if hasattr(raw_sdps, "__iter__") else [])
- sdp_nodes = {}
+ sdp_nodes: dict[str, Any] = {}
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_id = sdp.get("id", "")
+ raw_acs = safe_get(sdp, ["attachment-circuits", "attachment-circuit"]) or []
+ first_ac = (
+ raw_acs if isinstance(raw_acs, dict) else (raw_acs[0] if isinstance(raw_acs, list) and raw_acs else {})
+ )
+ 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 = []
+ raw_cgs = safe_get(slice_svc, ["connection-groups", "connection-group"]) or []
+ cg_list = [raw_cgs] if isinstance(raw_cgs, dict) else (list(raw_cgs) if hasattr(raw_cgs, "__iter__") else [])
- cg_map = {}
+ cg_map: dict[str, Any] = {}
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', [])
+ 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 = []
+ elif not isinstance(constructs, list):
+ constructs = list(constructs) if hasattr(constructs, "__iter__") else []
cg_map[cg_id] = {
- 'template': cg_template,
- 'constructs': constructs if isinstance(constructs, list) else []
+ "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}")
+ logger.warning("Could not retrieve underlay topology from TFS: %s", e)
- # 2. Retrieve service path for this slice from TFS
- service_path_nodes = []
+ service_path_nodes: list[str] = []
try:
service_data = get_data_by_slice_id(slice_id)
if len(service_data) > 1:
@@ -381,23 +302,22 @@ class tfs_connector():
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}")
+ logger.warning("Could not retrieve service path from TFS for '%s': %s", 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 []
+ underlay_links = (
+ underlay_network.get("ietf-network-topology:link", []) if isinstance(underlay_network, dict) else []
+ )
- def node_matches_path(node_dict: dict) -> bool:
+ def node_matches_path(node_dict: dict[str, Any]) -> 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):
+ if p_str in (n_id, n_name) or p_str in n_id or n_id 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)
@@ -405,31 +325,22 @@ class tfs_connector():
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", "")))
+ filtered_underlay_nodes = [n for n in underlay_nodes if isinstance(n, dict) and node_matches_path(n)]
+ matched_node_ids = {str(n.get("node-id", "")) for n in filtered_underlay_nodes}
- def get_node_path_index(node_dict: dict) -> int:
+ def get_node_path_index(node_dict: dict[str, Any]) -> 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):
+ if p_str in (n_id, n_name) or p_str in n_id or n_id 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 = []
+ filtered_links: list[dict[str, Any]] = []
if underlay_links and filtered_underlay_nodes:
for link in underlay_links:
if not isinstance(link, dict):
@@ -437,29 +348,29 @@ class tfs_connector():
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():
+ for _, 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'
+ 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()
+ if cg_info["template"] == "silver":
+ template_assigned = "silver"
+ elif 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,
+ }
+ )
+
+ active_tps: set[str] = set()
for fl in filtered_links:
src_tp = safe_get(fl, ["source", "source-tp"])
dst_tp = safe_get(fl, ["destination", "dest-tp"])
@@ -468,95 +379,99 @@ class tfs_connector():
if dst_tp:
active_tps.add(str(dst_tp))
- nodes_result = []
+ nodes_result: list[dict[str, Any]] = []
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]
-
+
+ filtered_tps = (
+ [tp for tp in tps if isinstance(tp, dict) and str(tp.get("tp-id", "")) in active_tps]
+ if active_tps and isinstance(tps, list)
+ else (tps if isinstance(tps, list) else [])
+ )
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
- })
+ 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"}]
- })
+ 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', []):
+ for _, 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 = 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
- })
+ 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": {}
- },
+ "network-types": {"ietf-ns-topo:network-slice": {}},
"ietf-ns-topo:slo-sle-template": slice_template,
"node": nodes_result,
- "ietf-network-topology:link": links_result
+ "ietf-network-topology:link": links_result,
}
]
}
}
-
return slice_topology, 200
- # --- SDN STREAMS ---
+ # --- Telemetry and Streaming ---
- async def get_session(self):
- # Crea la sesión si no existe en el loop de background
+ async def get_session(self) -> aiohttp.ClientSession:
+ """Get or initialize the shared aiohttp ClientSession."""
if tfs_connector._session is None or tfs_connector._session.closed:
tfs_connector._session = aiohttp.ClientSession()
return tfs_connector._session
-
- async def close_session(self):
- # Llama a esto al apagar tu app
- if tfs_connector._session:
+
+ async def close_session(self) -> None:
+ """Close the shared aiohttp ClientSession."""
+ if tfs_connector._session and not tfs_connector._session.closed:
await tfs_connector._session.close()
- def _extract_stream_uri(self, payload, base_url):
+ def _extract_stream_uri(self, payload: Any, base_url: str) -> str | None:
+ """Recursively extract stream URI from SSE subscription response payload."""
if not isinstance(payload, dict):
return None
@@ -564,11 +479,14 @@ class tfs_connector():
uri = payload["uri"].strip()
if uri.startswith(("http://", "https://")):
return uri
- if base_url:
- return f"{base_url.rstrip('/')}/{uri.lstrip('/')}"
- return uri
-
- for key in ("ietf-subscribed-notifications:subscription-result", "subscription-result", "stream", "ietf-subscribed-notifications:stream"):
+ return f"{base_url.rstrip('/')}/{uri.lstrip('/')}" if base_url else uri
+
+ for key in (
+ "ietf-subscribed-notifications:subscription-result",
+ "subscription-result",
+ "stream",
+ "ietf-subscribed-notifications:stream",
+ ):
if key in payload:
uri = self._extract_stream_uri(payload[key], base_url)
if uri:
@@ -582,86 +500,96 @@ class tfs_connector():
return None
- async def listenStream(self, session, link_id, slice_id, stream_url, telemetry_cache):
- logging.debug(f"Listening to stream for link {link_id} in slice {slice_id} at {stream_url}")
- try:
+ async def listenStream(
+ self,
+ session: aiohttp.ClientSession,
+ link_id: str,
+ slice_id: str,
+ stream_url: str,
+ telemetry_cache: dict[str, Any],
+ ) -> None:
+ """Listen to an SSE telemetry stream for a specific link and update the cache."""
+ logger.debug("Listening to stream for link %s in slice %s at %s", link_id, slice_id, stream_url)
+ try:
async with session.get(
stream_url,
- auth=aiohttp.BasicAuth("admin", "admin"),
+ auth=aiohttp.BasicAuth("admin", "admin"),
timeout=None,
- headers = {
- "Accept": "text/event-stream",
- "Cache-Control": "no-cache"
- }
+ headers={"Accept": "text/event-stream", "Cache-Control": "no-cache"},
) as resp:
- logging.debug(f"Connection established for link {link_id}")
+ logger.debug("Connection established for link %s", link_id)
buffer = ""
async for chunk in resp.content.iter_any():
buffer += chunk.decode()
-
+
while "\n\n" in buffer:
event_block, buffer = buffer.split("\n\n", 1)
-
+
for line in event_block.split("\n"):
if line.startswith("data:"):
json_str = line.replace("data:", "").strip()
-
try:
data = json.loads(json_str)
- logging.debug(f"Received data for link {link_id}: {data}")
-
- telemetry = data["notification"]["push-update"]["datastore-contents"]["simap-telemetry:simap-telemetry"]
+ logger.debug("Received data for link %s: %s", link_id, data)
+ telemetry = data["notification"]["push-update"]["datastore-contents"][
+ "simap-telemetry:simap-telemetry"
+ ]
bw = telemetry.get("bandwidth-utilization", "N/A")
latency = telemetry.get("latency", "N/A")
telemetry_cache.setdefault(slice_id, {})[link_id] = {
"timestamp": data["notification"]["eventTime"],
"bandwidth": float(bw) if bw != "N/A" else bw,
"latency": float(latency) if latency != "N/A" else latency,
- "services": telemetry.get("related-service-ids", [])
+ "services": telemetry.get("related-service-ids", []),
}
- except json.JSONDecodeError:
- logging.error(f"Error parsing JSON for link {link_id}: {json_str}")
- except Exception as e:
- logging.error(f"Error processing data for link {link_id}: {e}")
- except aiohttp.ClientError as e:
- logging.error(f"Connection error with stream for link {link_id}: {e}")
+ except json.JSONDecodeError:
+ logger.error("Error parsing JSON for link %s: %s", link_id, json_str)
+ except Exception as e:
+ logger.error("Error processing data for link %s: %s", link_id, e)
+ except aiohttp.ClientError as e:
+ logger.error("Connection error with stream for link %s: %s", link_id, e)
except Exception as e:
- logging.error(f"Unexpected error in listenStream for link {link_id}: {e}")
-
- logging.info(f"Stopped listening to stream for link {link_id} in slice {slice_id}")
-
- async def startStreams(self, tfs_ip, suscription_period, links, slice_id, telemetry_cache):
- #async with self.get_session() as session: #timeout = aiohttp.ClientTimeout(total=10)
+ logger.error("Unexpected error in listenStream for link %s: %s", link_id, e)
+
+ logger.info("Stopped listening to stream for link %s in slice %s", link_id, slice_id)
+
+ async def startStreams(
+ self,
+ tfs_ip: str,
+ suscription_period: int,
+ links: list[str],
+ slice_id: str,
+ telemetry_cache: dict[str, Any],
+ ) -> None:
+ """Start asynchronous SSE telemetry subscriptions for a set of links."""
session = await self.get_session()
tasks = []
- base_url = f'http://{tfs_ip}'
- url = f'{base_url}{NBI_SIMAP_SUSCRIPTION_PATH}'
+ base_url = f"http://{tfs_ip}"
+ url = f"{base_url}{NBI_SIMAP_SUSCRIPTION_PATH}"
for link in links:
- sub_resp = await session.post(url, json={
- "ietf-subscribed-notifications:input": {
- "datastore": "operational",
- "ietf-yang-push:datastore-xpath-filter": f"/ietf-network:networks/network=admin/ietf-network-topology:link={link}/simap-telemetry:simap-telemetry",
- "ietf-yang-push:periodic": {
- "ietf-yang-push:period": suscription_period
+ sub_resp = await session.post(
+ url,
+ json={
+ "ietf-subscribed-notifications:input": {
+ "datastore": "operational",
+ "ietf-yang-push:datastore-xpath-filter": f"/ietf-network:networks/network=admin/ietf-network-topology:link={link}/simap-telemetry:simap-telemetry",
+ "ietf-yang-push:periodic": {"ietf-yang-push:period": suscription_period},
}
- }
- },
- auth=aiohttp.BasicAuth("admin", "admin")
- )
+ },
+ auth=aiohttp.BasicAuth("admin", "admin"),
+ )
sub_resp.raise_for_status()
sub_data = await sub_resp.json(content_type=None)
stream_url = self._extract_stream_uri(sub_data, base_url)
if stream_url:
tasks.append(
- asyncio.create_task(
- self.listenStream(session, link, slice_id, stream_url, telemetry_cache)
- )
+ asyncio.create_task(self.listenStream(session, link, slice_id, stream_url, telemetry_cache))
)
- logging.debug("Created telemetry listener for link '%s' in slice '%s' at %s", link, slice_id, stream_url)
+ logger.debug("Created telemetry listener for link '%s' in slice '%s' at %s", link, slice_id, stream_url)
else:
- logging.warning("Subscription for link '%s' did not return a stream URI. Response: %s", link, sub_data)
+ logger.warning("Subscription for link '%s' did not return a stream URI. Response: %s", link, sub_data)
- logging.debug(f"Telemetry cache for slice '{slice_id}': {telemetry_cache.get(slice_id)}")
+ logger.debug("Telemetry cache for slice '%s': %s", slice_id, telemetry_cache.get(slice_id))
diff --git a/src/realizer/restconf/main.py b/src/realizer/restconf/main.py
index ac9dad9cf12c51da98a95f676a436326931fb3a6..776aa6289f35083630c875db9c7e7d2e6506c723 100644
--- a/src/realizer/restconf/main.py
+++ b/src/realizer/restconf/main.py
@@ -1,11 +1,11 @@
# Copyright 2022-2026 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.
@@ -14,27 +14,39 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
+"""RESTCONF slice intent realization entry point."""
+
+from __future__ import annotations
+
import logging
+from typing import Any
+
from .service_types.l2vpn import l2vpn
from .service_types.l3vpn import l3vpn
-def restconf(ietf_intent, way=None, response=None):
- """
- Generates a TFS realizing request based on the specified way (L2 or L3).
+logger = logging.getLogger(__name__)
+
+
+def restconf(
+ ietf_intent: dict[str, Any],
+ way: str | None = None,
+ response: Any = None,
+) -> dict[str, Any] | None:
+ """Generate a RESTCONF/TFS realization request based on specified service way (L2 or L3).
Args:
- ietf_intent (dict): The IETF intent to be realized. Defaults to None.
- way (str): The type of service to realize ("L2" or "L3"). Defaults to None.
- response (dict): Response built for user feedback. Defaults to None.
-
+ ietf_intent: The IETF slice intent dictionary to be realized.
+ way: Service layer type ('L2' or 'L3'). Defaults to None.
+ response: Response built for user feedback. Defaults to None.
+
Returns:
- dict: A realization request for the specified network slice type.
+ Realization request dictionary or None if SDPs are missing.
"""
- if way == "L2":
- realizing_request = l2vpn(ietf_intent)
- elif way == "L3":
- realizing_request = l3vpn(ietf_intent)
- else:
- logging.warning(f"Unsupported way: {way}. Defaulting to L2 realization.")
- realizing_request = l2vpn(ietf_intent)
- return realizing_request
\ No newline at end of file
+ match way:
+ case "L2":
+ return l2vpn(ietf_intent)
+ case "L3":
+ return l3vpn(ietf_intent)
+ case _:
+ logger.warning("Unsupported way: %s. Defaulting to L2 realization.", way)
+ return l2vpn(ietf_intent)
diff --git a/src/realizer/restconf/restconf_connect.py b/src/realizer/restconf/restconf_connect.py
index e9d880f10ea018a0a06d40659bf61c9c6826f458..e1b13b45507aabd6dbf817b154a0ecd109a5fcaf 100644
--- a/src/realizer/restconf/restconf_connect.py
+++ b/src/realizer/restconf/restconf_connect.py
@@ -14,17 +14,26 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
-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
+"""RESTCONF connector orchestrating TFS NBI dispatching and FRR dataplane provisioning."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
from flask import current_app
+
from src.config.constants import NBI_L2_PATH, NBI_L3_PATH
-import logging
+from src.utils.safe_get import safe_get
+from src.utils.send_response import send_response
+from src.utils.slice_manager import SliceManager
+
+from .connectors.frr_connector import frr_connector
+from .connectors.tfs_connector import tfs_connector
+logger = logging.getLogger(__name__)
-FRR_DEVICES = [
+FRR_DEVICES: list[dict[str, Any]] = [
{
"management_address": "10.60.125.20",
"input_address": "192.168.24.2",
@@ -41,65 +50,75 @@ FRR_DEVICES = [
_slice_manager = SliceManager()
-def restconf_connect(requests, restconf_ip):
- """
- Connect to controller and upload services.
-
+
+def _handle_frr_dataplane(dscp: int) -> tuple[bool, Any]:
+ """Assign DSCP slot and configure FRR devices for dataplane support."""
+ if _slice_manager.is_full():
+ return False, 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 False, send_response(False, code=429, message=f"Could not assign slot for DSCP {dscp}")
+
+ logger.info("Assigned DSCP %s to SLICE-%s", dscp, 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 False, send_response(
+ False,
+ code=500,
+ message=f"FRR config failed on {device_config['management_address']}: {e!s}",
+ )
+
+ return True, None
+
+
+def restconf_connect(requests: dict[str, Any], restconf_ip: str) -> Any:
+ """Connect to controller and upload RESTCONF/TFS services.
+
Args:
- requests (dict): Dictionary containing services to upload
- tfs_ip (str): IP address of the TFS controller
-
+ requests: Dictionary containing service intents under 'services'.
+ restconf_ip: IP address of the RESTCONF/TFS controller.
+
Returns:
- response (requests.Response): Response from TFS controller
- """
+ Response from controller or API error response.
+ """
response = None
- for intent in requests["services"]:
- if current_app.config["SDN_CONTROLLER_TYPE"] == "TFS":
- key = next(iter(intent))
- if key == "ietf-l2vpn-svc:l2vpn-svc":
- path = NBI_L2_PATH
-
- 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}")
-
- response = tfs_connector().nbi_post(restconf_ip, intent, path)
- if not response.ok:
- return send_response(False, code=response.status_code,
- message=f"Controller upload failed. Response: {response.text}")
+ for intent in requests.get("services", []):
+ controller_type = current_app.config.get("SDN_CONTROLLER_TYPE")
+ if controller_type != "TFS":
+ return send_response(False, code=400, message=f"Unsupported SDN controller type: {controller_type}")
+
+ key = next(iter(intent), "")
+ if key == "ietf-l2vpn-svc:l2vpn-svc":
+ path = NBI_L2_PATH
+ 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.get("DATAPLANE_SUPPORT") == "FRR":
+ success, error_response = _handle_frr_dataplane(dscp)
+ if not success:
+ return error_response
else:
- return send_response(False, code=400, message=f"Unsupported SDN controller type: {current_app.config['SDN_CONTROLLER_TYPE']}")
+ return send_response(False, code=400, message=f"Unsupported service type: {key}")
+
+ response = tfs_connector().nbi_post(restconf_ip, intent, path)
+ if not response.ok:
+ return send_response(
+ False,
+ code=response.status_code,
+ message=f"Controller upload failed. Response: {response.text}",
+ )
if response is None:
return send_response(True, code=200, message="No services processed")
- return response
\ No newline at end of file
+ return response
diff --git a/src/realizer/restconf/service_types/builders/apply_metric_constraint.py b/src/realizer/restconf/service_types/builders/apply_metric_constraint.py
index f98ec941e498367980e4528b6e2c057ce292d9c1..967222335c3fe81423928741d69e6b50dc668963 100644
--- a/src/realizer/restconf/service_types/builders/apply_metric_constraint.py
+++ b/src/realizer/restconf/service_types/builders/apply_metric_constraint.py
@@ -14,51 +14,91 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
-def apply_metric_constraint(service, qos_class, constraint, vpn_id, layer_type):
- """Aplica una restricción de métrica específica."""
+"""Metric constraint applicability builders for QoS and SLO profiles."""
+
+from __future__ import annotations
+
+from typing import Any
+
+_BANDWIDTH_MULTIPLIER = {
+ "bps": 1,
+ "kbps": 1_000,
+ "Mbps": 1_000_000,
+ "Gbps": 1_000_000_000,
+}
+
+
+def _apply_bandwidth(
+ service: dict[str, Any],
+ constraint: dict[str, Any],
+ metric_value: float,
+ vpn_id: str,
+ layer_type: str,
+) -> None:
+ """Apply bandwidth constraints according to layer type."""
+ unit = constraint.get("metric-unit", "bps")
+ multiplier = _BANDWIDTH_MULTIPLIER.get(unit, 1)
+ bandwidth = int(metric_value * multiplier)
+
+ if layer_type == "l2":
+ service["svc-bandwidth"] = {
+ "bandwidth": [
+ {
+ "type": "bw-per-svc",
+ "direction": "input-bw",
+ "vpn-id": vpn_id,
+ "cir": bandwidth,
+ "cbs": int(bandwidth * 0.05),
+ },
+ {
+ "type": "bw-per-svc",
+ "direction": "output-bw",
+ "vpn-id": vpn_id,
+ "cir": bandwidth,
+ "cbs": int(bandwidth * 0.05),
+ },
+ ]
+ }
+ elif layer_type == "l3":
+ service["svc-input-bandwidth"] = bandwidth
+ service["svc-output-bandwidth"] = bandwidth
+
+
+def apply_metric_constraint(
+ service: dict[str, Any],
+ qos_class: dict[str, Any],
+ constraint: dict[str, Any],
+ vpn_id: str,
+ layer_type: str,
+) -> None:
+ """Apply a specific SLO metric constraint to the service or QoS class.
+
+ Args:
+ service: Target service dictionary to configure.
+ qos_class: QoS class sub-dictionary to configure.
+ constraint: Metric constraint definition dictionary.
+ vpn_id: VPN identifier.
+ layer_type: Network layer ('l2' or 'l3').
+ """
metric_type = constraint.get("metric-type")
metric_value = float(constraint.get("bound", 0))
-
- if metric_type == "two-way-bandwidth":
- unit = constraint.get("metric-unit", "bps")
- multiplier = {"bps": 1, "kbps": 1_000, "Mbps": 1_000_000, "Gbps": 1_000_000_000}.get(unit, 1)
- bandwidth = int(metric_value * multiplier)
- if layer_type == "l2":
- service["svc-bandwidth"] = {
- "bandwidth":[
- {
- "type": "bw-per-svc",
- "direction": "input-bw",
- "vpn-id": vpn_id,
- "cir": bandwidth,
- "cbs": int(bandwidth*0.05)
- },
- {
- "type": "bw-per-svc",
- "direction": "output-bw",
- "vpn-id": vpn_id,
- "cir": bandwidth,
- "cbs": int(bandwidth*0.05)
- },
- ]
- }
- elif layer_type == "l3":
- service["svc-input-bandwidth"] = bandwidth
- service["svc-output-bandwidth"] = bandwidth
-
- elif metric_type == "two-way-delay-maximum":
- if layer_type == "l2":
- qos_class.setdefault("frame-delay", {})["delay-bound"] = int(metric_value)
- elif layer_type == "l3":
- qos_class.setdefault("latency", {})["latency-boundary"] = int(metric_value)
-
- elif metric_type == "two-way-delay-variation-maximum":
- if layer_type == "l2":
- qos_class.setdefault("frame-jitter", {})["delay-bound"] = int(metric_value)
- elif layer_type == "l3":
- qos_class.setdefault("jitter", {})["latency-boundary"] = int(metric_value)
-
- elif metric_type == "two-way-packet-loss":
- if layer_type == "l2":
- qos_class.setdefault("frame-loss", {})["loss-bound"] = metric_value
+ match metric_type:
+ case "two-way-bandwidth":
+ _apply_bandwidth(service, constraint, metric_value, vpn_id, layer_type)
+
+ case "two-way-delay-maximum":
+ if layer_type == "l2":
+ qos_class.setdefault("frame-delay", {})["delay-bound"] = int(metric_value)
+ elif layer_type == "l3":
+ qos_class.setdefault("latency", {})["latency-boundary"] = int(metric_value)
+
+ case "two-way-delay-variation-maximum":
+ if layer_type == "l2":
+ qos_class.setdefault("frame-jitter", {})["delay-bound"] = int(metric_value)
+ elif layer_type == "l3":
+ qos_class.setdefault("jitter", {})["latency-boundary"] = int(metric_value)
+
+ case "two-way-packet-loss":
+ if layer_type == "l2":
+ qos_class.setdefault("frame-loss", {})["loss-bound"] = metric_value
diff --git a/src/realizer/restconf/service_types/builders/configure_match_criteria.py b/src/realizer/restconf/service_types/builders/configure_match_criteria.py
index 648218f99e867c1ab0c4be0df9a36e118e82d53d..b543c0db96812322a9dc97c76975ded5e99614f9 100644
--- a/src/realizer/restconf/service_types/builders/configure_match_criteria.py
+++ b/src/realizer/restconf/service_types/builders/configure_match_criteria.py
@@ -14,40 +14,46 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
+"""Match criteria configuration builders for network access and site objects."""
+
+from __future__ import annotations
+
import logging
+from typing import Any
+
from src.utils.safe_get import safe_get
+logger = logging.getLogger(__name__)
-def configure_match_criteria(network_access, site, sdp, layer_type):
- """Configura los criterios de coincidencia en el acceso a la red."""
- MATCH_TYPE_MAPPING = {
- "dscp": "dscp",
- "vlan": "dot1q",
- "any": "any"
- }
+_BASE_MATCH_TYPE_MAPPING: dict[str, str] = {
+ "dscp": "dscp",
+ "vlan": "dot1q",
+ "any": "any",
+}
+
+
+def _get_match_mapping(layer_type: str) -> dict[str, str]:
+ """Retrieve match mapping dictionary according to network layer."""
+ mapping = dict(_BASE_MATCH_TYPE_MAPPING)
if layer_type == "l3":
- MATCH_TYPE_MAPPING["source-ip-prefix"] = "ipv4-src-prefix"
- MATCH_TYPE_MAPPING["destination-ip-prefix"] = "ipv4-dst-prefix"
-
- match_criteria = sdp.get("match_criteria")
- if not match_criteria:
- return
-
- match_type = safe_get(sdp, ["match_criteria", "match-type", 0, "type"])
- index = safe_get(sdp, ["match_criteria", "index"])
- value = safe_get(sdp, ["match_criteria", "match-type", 0, match_type, 0])
-
- logging.debug(f"Configuring match criteria for SDP: {safe_get(sdp, ['sdp', 'id'])} with match type: {match_type} and value: {value}")
-
- if match_type not in MATCH_TYPE_MAPPING:
- logging.warning(f"Unknown match type: {match_type}")
- return
-
+ mapping["source-ip-prefix"] = "ipv4-src-prefix"
+ mapping["destination-ip-prefix"] = "ipv4-dst-prefix"
+ return mapping
+
+
+def _configure_vlan_match(
+ network_access: dict[str, Any],
+ site: dict[str, Any],
+ sdp: dict[str, Any],
+ value: Any,
+ layer_type: str,
+) -> None:
+ """Configure VLAN matching logic for L2 or L3 sites."""
provider_address = safe_get(sdp, ["sdp", "attachment-circuits", "attachment-circuit", 0, "ac-ipv4-address"])
prefix_length = safe_get(sdp, ["sdp", "attachment-circuits", "attachment-circuit", 0, "ac-ipv4-prefix-length"])
lan = f"{provider_address}/{prefix_length}" if provider_address and prefix_length else None
- if layer_type == "l3" and match_type == "vlan":
+ if layer_type == "l3":
site["routing-protocols"] = {"routing-protocol": []}
routing_protocol = {
"type": "static",
@@ -57,27 +63,61 @@ def configure_match_criteria(network_access, site, sdp, layer_type):
{
"lan": lan,
"lan-tag": value,
- "next-hop": provider_address # This is not correct, should be the management ip of the provider router, but we don't have that info in the SDP. Need to check how to handle this.
+ "next-hop": provider_address,
}
]
}
- }
+ },
}
-
site["routing-protocols"]["routing-protocol"].append(routing_protocol)
- return
- elif layer_type == "l2" and match_type == "vlan":
+ elif layer_type == "l2":
network_access["connection"]["tagged-interface"]["dot1q-vlan-tagged"]["cvlan-id"] = value
- # Do not add rule when match type is any
+
+def configure_match_criteria(
+ network_access: dict[str, Any],
+ site: dict[str, Any],
+ sdp: dict[str, Any],
+ layer_type: str,
+) -> None:
+ """Configure traffic match criteria rules in the network access policy.
+
+ Args:
+ network_access: Network access dictionary to update.
+ site: Parent site dictionary.
+ sdp: Service Delivery Point specification.
+ layer_type: Network layer ('l2' or 'l3').
+ """
+ match_criteria = sdp.get("match_criteria")
+ if not match_criteria:
+ return
+
+ match_type = safe_get(sdp, ["match_criteria", "match-type", 0, "type"])
+ index = safe_get(sdp, ["match_criteria", "index"])
+ value = safe_get(sdp, ["match_criteria", "match-type", 0, match_type, 0])
+
+ logger.debug(
+ "Configuring match criteria for SDP: %s with match type: %s and value: %s",
+ safe_get(sdp, ["sdp", "id"]),
+ match_type,
+ value,
+ )
+
+ mapping = _get_match_mapping(layer_type)
+ if match_type not in mapping:
+ logger.warning("Unknown match type: %s", match_type)
+ return
+
+ if match_type == "vlan":
+ _configure_vlan_match(network_access, site, sdp, value, layer_type)
+ if layer_type == "l3":
+ return
+
if match_type == "any":
return
rule = {
"id": f"match-{match_type}-{index}",
- "match-flow": {
- MATCH_TYPE_MAPPING[match_type]: value
- }
+ "match-flow": {mapping[match_type]: value},
}
-
network_access["service"]["qos"]["qos-classification-policy"]["rule"].append(rule)
diff --git a/src/realizer/restconf/service_types/builders/configure_slos.py b/src/realizer/restconf/service_types/builders/configure_slos.py
index 7721e51ee2e3cb2eb32398b96f20356805e3682d..d7f669e2fc3156db9a063ca0fc07f3558407aae8 100644
--- a/src/realizer/restconf/service_types/builders/configure_slos.py
+++ b/src/realizer/restconf/service_types/builders/configure_slos.py
@@ -14,35 +14,50 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
+"""SLO configuration helper for network access objects."""
+
+from __future__ import annotations
+
import logging
+from typing import Any
+
from src.utils.safe_get import safe_get
+
from .apply_metric_constraint import apply_metric_constraint
-def configure_slos(network_access, ietf_intent, layer_type):
- """Configura los SLOs (Service Level Objectives) en el acceso a la red."""
+logger = logging.getLogger(__name__)
+
+
+def configure_slos(
+ network_access: dict[str, Any],
+ ietf_intent: dict[str, Any],
+ layer_type: str,
+) -> None:
+ """Configure Service Level Objectives (SLOs) on the network access structure.
+
+ Args:
+ network_access: Network access dictionary to mutate.
+ ietf_intent: Full IETF slice intent dictionary.
+ layer_type: Network layer ('l2' or 'l3').
+ """
service = network_access["service"]
qos_class = service["qos"]["qos-profile"]["classes"]["class"][0]
-
- logging.debug(f"Configuring SLOs with constraints: {safe_get(ietf_intent, ['template', 'slo-policy', 'metric-bound'])}")
-
- # Configure metric constraints
+
metric_bounds = safe_get(ietf_intent, ["template", "slo-policy", "metric-bound"])
+ logger.debug("Configuring SLOs with constraints: %s", metric_bounds)
+
if metric_bounds:
+ vpn_id = str(ietf_intent.get("id", ""))
for constraint in metric_bounds:
- apply_metric_constraint(service, qos_class, constraint, ietf_intent["id"], layer_type)
-
- # Configure availability
+ apply_metric_constraint(service, qos_class, constraint, vpn_id, layer_type)
+
availability = safe_get(ietf_intent, ["template", "slo-policy", "availability"])
- if availability:
+ if availability is not None:
qos_class.setdefault("bandwidth", {})["guaranteed-bw-percent"] = availability
-
- # Configure MTU
+
mtu = safe_get(ietf_intent, ["template", "slo-policy", "mtu"])
- if mtu:
+ if mtu is not None:
service["svc-mtu"] = mtu
-
- # Configure availability and MTU defaults if not configured
- if "guaranteed-bw-percent" not in qos_class.get("bandwidth", {}):
- qos_class.setdefault("bandwidth", {})["guaranteed-bw-percent"] = 0
- if "svc-mtu" not in service:
- service["svc-mtu"] = 1500
\ No newline at end of file
+
+ qos_class.setdefault("bandwidth", {}).setdefault("guaranteed-bw-percent", 0)
+ service.setdefault("svc-mtu", 1500)
diff --git a/src/realizer/restconf/service_types/builders/create_site_from_sdp.py b/src/realizer/restconf/service_types/builders/create_site_from_sdp.py
index 536cd9b636233e788d8b2c366b0dd0d9f5d6f6e6..bc92bd304ac39491efa350eac074029f1bfcb188 100644
--- a/src/realizer/restconf/service_types/builders/create_site_from_sdp.py
+++ b/src/realizer/restconf/service_types/builders/create_site_from_sdp.py
@@ -14,64 +14,84 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
+"""Site structure builder from Service Delivery Point (SDP)."""
+
+from __future__ import annotations
+
import logging
+from typing import Any
+
from src.utils.safe_get import safe_get
-from .create_network_access import create_network_access
+
from .configure_match_criteria import configure_match_criteria
from .configure_slos import configure_slos
+from .create_network_access import create_network_access
+
+logger = logging.getLogger(__name__)
-def create_site_from_sdp(sdp, ietf_intent, connectivity_type, layer_type):
- """
- Creates the configuration of a site from an SDP.
+
+def create_site_from_sdp(
+ sdp: dict[str, Any],
+ ietf_intent: dict[str, Any],
+ connectivity_type: str,
+ layer_type: str,
+) -> dict[str, Any]:
+ """Create the site configuration structure from an SDP.
Args:
- sdp: Service Delivery Point
- ietf_intent: Complete IETF intent
- connectivity_type: Connectivity type
+ sdp: Service Delivery Point dictionary.
+ ietf_intent: Full IETF slice intent dictionary.
+ connectivity_type: Connectivity type (point-to-point, hub-spoke, etc.).
+ layer_type: Network layer ('l2' or 'l3').
Returns:
- Dictionary with site configuration
+ Structured site dictionary.
"""
- logging.debug(f"Processing SDP: {sdp}")
-
- # Extract basic information
+ logger.debug("Processing SDP: %s", sdp)
+
location = safe_get(sdp, ["sdp", "node-id"])
router_id = safe_get(sdp, ["sdp", "attachment-circuits", "attachment-circuit", 0, "ac-node-id"])
router_if = safe_get(sdp, ["sdp", "attachment-circuits", "attachment-circuit", 0, "ac-tp-id"])
-
sdp_id = safe_get(sdp, ["sdp", "id"]) or safe_get(sdp, ["sdp", "node-id"])
- logging.debug(f"Configured site for SDP {sdp_id} with location: {location}, router_id: {router_id}, router_if: {router_if}")
-
- network_access = create_network_access(sdp, ietf_intent, connectivity_type, router_id, router_if, layer_type)
- # Create site structure
- site = {
+ logger.debug(
+ "Configured site for SDP %s with location: %s, router_id: %s, router_if: %s",
+ sdp_id,
+ location,
+ router_id,
+ router_if,
+ )
+
+ network_access = create_network_access(
+ sdp,
+ ietf_intent,
+ connectivity_type,
+ router_id,
+ router_if,
+ layer_type,
+ )
+
+ site: dict[str, Any] = {
"site-id": sdp_id,
- "locations": {
- "location": [{"location-id": location}]
- },
+ "locations": {"location": [{"location-id": location}]},
"devices": {
- "device": [{
- "device-id": router_id,
- "location": location
- }]
- },
- "management": {
- "type": "provider-managed"
+ "device": [
+ {
+ "device-id": router_id,
+ "location": location,
+ }
+ ]
},
- "site-network-accesses": {
- "site-network-access": [network_access]
- }
+ "management": {"type": "provider-managed"},
+ "site-network-accesses": {"site-network-access": [network_access]},
}
if layer_type == "l2":
site["default-ce-vlan-id"] = 1
-
- if layer_type == "l3":
+ elif layer_type == "l3":
site["routing-protocols"] = {"routing-protocol": []}
- # Configure match criteria and SLOs
configure_match_criteria(network_access, site, sdp, layer_type)
configure_slos(network_access, ietf_intent, layer_type)
-
+
return site
diff --git a/src/realizer/restconf/service_types/builders/initialize_structure.py b/src/realizer/restconf/service_types/builders/initialize_structure.py
index d8d652f09bf427fc2acbad0b2d368fe08dc9a6ac..3bf2ebdea7929fc31c1c52ed36ac7c9934e008a4 100644
--- a/src/realizer/restconf/service_types/builders/initialize_structure.py
+++ b/src/realizer/restconf/service_types/builders/initialize_structure.py
@@ -14,25 +14,38 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
-def initialize_structure(vpn_id, connectivity_type, layer_type):
- """Inicializa la estructura base del servicio."""
- structure = {
- f"ietf-{layer_type}vpn-svc:{layer_type}vpn-svc": {
- "vpn-services": {
- "vpn-service": [
- {"vpn-id": vpn_id}
- ]
- },
- "sites": {
- "site": []
- }
- }
- }
+"""Base YANG datastore structure initializer for L2VPN/L3VPN services."""
+
+from __future__ import annotations
+
+from typing import Any
+
+
+def initialize_structure(vpn_id: str, connectivity_type: str, layer_type: str) -> dict[str, Any]:
+ """Initialize the base IETF L2VPN or L3VPN service structure.
+
+ Args:
+ vpn_id: Unique VPN identifier.
+ connectivity_type: Type of VPN connectivity (point-to-point, hub-spoke, etc.).
+ layer_type: Network layer ('l2' or 'l3').
+
+ Returns:
+ Structured base service dictionary.
+ """
+ vpn_service_entry: dict[str, Any] = {"vpn-id": vpn_id}
if layer_type == "l2":
- structure[f"ietf-{layer_type}vpn-svc:{layer_type}vpn-svc"]["vpn-services"]["vpn-service"][0] = {
- "vpn-id": vpn_id,
- "ce-vlan-preservation": False,
- "ce-vlan-cos-preservation": False,
- "frame-delivery": { "multicast-gp-port-mapping":"static-mapping"} # This field should not be needed, it is optional but the YANG validator in TFS sets it to mandatory
+ vpn_service_entry.update(
+ {
+ "ce-vlan-preservation": False,
+ "ce-vlan-cos-preservation": False,
+ "frame-delivery": {"multicast-gp-port-mapping": "static-mapping"},
+ }
+ )
+
+ root_key = f"ietf-{layer_type}vpn-svc:{layer_type}vpn-svc"
+ return {
+ root_key: {
+ "vpn-services": {"vpn-service": [vpn_service_entry]},
+ "sites": {"site": []},
}
- return structure
\ No newline at end of file
+ }
diff --git a/src/realizer/restconf/service_types/l2vpn.py b/src/realizer/restconf/service_types/l2vpn.py
index 5d48474e27478a2ef2032e437308e7e4a4220f3e..2de1d9885ec93ce993cecea4a8d27fc014bb15e0 100644
--- a/src/realizer/restconf/service_types/l2vpn.py
+++ b/src/realizer/restconf/service_types/l2vpn.py
@@ -1,11 +1,11 @@
# Copyright 2022-2026 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.
@@ -14,39 +14,41 @@
# This file includes original contributions from Telefonica Innovación Digital S.L.
+"""IETF L2VPN service model realizer for RESTCONF."""
+
+from __future__ import annotations
+
import logging
-from .builders.initialize_structure import initialize_structure
+from typing import Any
+
from .builders.create_site_from_sdp import create_site_from_sdp
+from .builders.initialize_structure import initialize_structure
+logger = logging.getLogger(__name__)
-def l2vpn(ietf_intent):
- """
- Creates an L2VPN service based on the provided IETF intent.
+def l2vpn(ietf_intent: dict[str, Any]) -> dict[str, Any] | None:
+ """Create an L2VPN service payload based on the provided IETF slice intent.
Args:
- ietf_intent: Dictionary with IETF intent configuration
- response: Response object
+ ietf_intent: Dictionary containing parsed IETF slice configuration.
Returns:
- Dictionary with L2VPN service configuration or None if no SDPs
+ Structured L2VPN service dictionary or None if SDPs are missing.
"""
- # Early validation
- if not ietf_intent.get("sdps"):
- logging.warning("SDPs not found in the intent. Skipping L2VPN realization.")
+ sdps = ietf_intent.get("sdps")
+ if not sdps:
+ logger.warning("SDPs not found in the intent. Skipping L2VPN realization.")
return None
- # Initialize L2VPN structure
- connectivity_type = ietf_intent["connectivity_type"]
- l2_service = initialize_structure(ietf_intent["id"], connectivity_type, layer_type="l2")
-
-
- # Process each SDP
- for sdp in ietf_intent["sdps"]:
+ connectivity_type = ietf_intent.get("connectivity_type", "ietf-vpn-common:point-to-point")
+ vpn_id = str(ietf_intent.get("id", ""))
+ l2_service = initialize_structure(vpn_id, connectivity_type, layer_type="l2")
+
+ for sdp in sdps:
site = create_site_from_sdp(sdp, ietf_intent, connectivity_type, layer_type="l2")
l2_service["ietf-l2vpn-svc:l2vpn-svc"]["sites"]["site"].append(site)
- logging.debug(f"L2VPN service created: {l2_service}")
- logging.info("L2VPN Intent realized")
-
- return l2_service
\ No newline at end of file
+ logger.debug("L2VPN service created: %s", l2_service)
+ logger.info("L2VPN Intent realized")
+ return l2_service
diff --git a/src/realizer/restconf/service_types/l3vpn.py b/src/realizer/restconf/service_types/l3vpn.py
index 2fdf7bbd4d203b34d8e07f44f0e93f344ae307c1..30f76251796b99489d3b9be98889374af519c0d8 100644
--- a/src/realizer/restconf/service_types/l3vpn.py
+++ b/src/realizer/restconf/service_types/l3vpn.py
@@ -1,11 +1,11 @@
# Copyright 2022-2026 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.
@@ -14,36 +14,41 @@
# This file includes original contributions from Telefonica Innovación Digital S.L.
+"""IETF L3VPN service model realizer for RESTCONF."""
+
+from __future__ import annotations
+
import logging
-from .builders.initialize_structure import initialize_structure
+from typing import Any
+
from .builders.create_site_from_sdp import create_site_from_sdp
+from .builders.initialize_structure import initialize_structure
-def l3vpn(ietf_intent):
- """
- Creates an L3VPN service based on the provided IETF intent.
+logger = logging.getLogger(__name__)
+
+
+def l3vpn(ietf_intent: dict[str, Any]) -> dict[str, Any] | None:
+ """Create an L3VPN service payload based on the provided IETF slice intent.
Args:
- ietf_intent: Dictionary with IETF intent configuration
- response: Response object
+ ietf_intent: Dictionary containing parsed IETF slice configuration.
Returns:
- Dictionary with L3VPN service configuration or None if no SDPs
+ Structured L3VPN service dictionary or None if SDPs are missing.
"""
- # Early validation
- if not ietf_intent.get("sdps"):
- logging.warning("SDPs not found in the intent. Skipping L3VPN realization.")
+ sdps = ietf_intent.get("sdps")
+ if not sdps:
+ logger.warning("SDPs not found in the intent. Skipping L3VPN realization.")
return None
- # Initialize L3VPN structure
- connectivity_type = ietf_intent["connectivity_type"]
- l3_service = initialize_structure(ietf_intent["id"], connectivity_type, layer_type="l3")
-
- # Process each SDP
- for sdp in ietf_intent["sdps"]:
+ connectivity_type = ietf_intent.get("connectivity_type", "ietf-vpn-common:point-to-point")
+ vpn_id = str(ietf_intent.get("id", ""))
+ l3_service = initialize_structure(vpn_id, connectivity_type, layer_type="l3")
+
+ for sdp in sdps:
site = create_site_from_sdp(sdp, ietf_intent, connectivity_type, layer_type="l3")
l3_service["ietf-l3vpn-svc:l3vpn-svc"]["sites"]["site"].append(site)
- logging.debug(f"L3VPN service created: {l3_service}")
- logging.info("L3VPN Intent realized")
-
- return l3_service
\ No newline at end of file
+ logger.debug("L3VPN service created: %s", l3_service)
+ logger.info("L3VPN Intent realized")
+ return l3_service
diff --git a/src/realizer/select_way.py b/src/realizer/select_way.py
index d0f2c5867ee7840658fea99231d7dffa88d7c110..592afbcacac398daac46ef216a97a5884ba277e7 100644
--- a/src/realizer/select_way.py
+++ b/src/realizer/select_way.py
@@ -1,11 +1,11 @@
# Copyright 2022-2026 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
-
+#
+# 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.
@@ -14,41 +14,49 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
+"""Dispatcher for selecting realization provider according to target controller."""
+
+from __future__ import annotations
+
import logging
-from .ixia.main import ixia
-from .tfs.main import tfs
-from .e2e.main import e2e
-from .restconf.main import restconf
+from typing import Any
+
+from .e2e.main import e2e
+from .ixia.main import ixia
+from .restconf.main import restconf
+from .tfs.main import tfs
+
+logger = logging.getLogger(__name__)
-def select_way(controller=None, way=None, ietf_intent=None, response=None, rules = None):
- """
- Determine the method of slice realization.
+
+def select_way(
+ controller: str | None = None,
+ way: str | None = None,
+ ietf_intent: Any = None,
+ response: Any = None,
+ rules: Any = None,
+) -> Any:
+ """Determine the method of slice realization and invoke the appropriate provider.
Args:
- controller (str): The controller to use for slice realization. Defaults to None.
- Supported values:
- - "IXIA": IXIA NEII for network testing
- - "TFS": TeraFlow Service for network slice management
- - "E2E": End-to-End controller for e2e slice management
- way (str): The type of technology to use. Defaults to None.
- ietf_intent (dict): IETF-formatted network slice intent. Defaults to None.
- response (dict): Response built for user feedback. Defaults to None.
- rules (list, optional): Specific rules for slice realization. Defaults to None.
+ controller: Target controller (TFS, IXIA, E2E, RESTCONF).
+ way: Technology way identifier (e.g., 'L2', 'L3', 'L3oWDM').
+ ietf_intent: IETF formatted network slice intent.
+ response: Outgoing user response dictionary.
+ rules: Dynamic rule specifications.
Returns:
- dict: A realization request for the specified network slice type.
-
+ Response payload from the selected controller integration.
"""
- realizing_request = None
- if controller == "TFS":
- realizing_request = tfs(ietf_intent, way, response)
- elif controller == "IXIA":
- realizing_request = ixia(ietf_intent)
- elif controller == "E2E":
- realizing_request = e2e(ietf_intent, way, response, rules)
- elif controller == "RESTCONF":
- realizing_request = restconf(ietf_intent, way, response)
- else:
- logging.warning(f"Unsupported controller: {controller}. Defaulting to TFS realization.")
- realizing_request = tfs(ietf_intent, way, response)
- return realizing_request
\ No newline at end of file
+ match controller:
+ case "TFS":
+ return tfs(ietf_intent, way, response)
+ case "IXIA":
+ return ixia(ietf_intent)
+ case "E2E":
+ return e2e(ietf_intent, way, response, rules)
+ case "RESTCONF":
+ return restconf(ietf_intent, way, response)
+ case _:
+ logger.warning("Unsupported controller: %s. Defaulting to TFS realization.", controller)
+ return tfs(ietf_intent, way, response)
diff --git a/src/realizer/send_controller.py b/src/realizer/send_controller.py
index da285b744574d0cbec09a6f86cbf1fd6ee8acbf4..10bfcee46a18575a2810cc1434a4a7d92a7a7ea4 100644
--- a/src/realizer/send_controller.py
+++ b/src/realizer/send_controller.py
@@ -1,11 +1,11 @@
# Copyright 2022-2026 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
-
+#
+# 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.
@@ -14,57 +14,69 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
+"""Router dispatching provisioning requests to designated network controllers."""
+
+from __future__ import annotations
+
import logging
+from typing import Any
+
from flask import current_app
-from .tfs.tfs_connect import tfs_connect
-from .ixia.ixia_connect import ixia_connect
+
from .e2e.e2e_connect import e2e_connect
+from .ixia.ixia_connect import ixia_connect
from .restconf.restconf_connect import restconf_connect
+from .tfs.tfs_connect import tfs_connect
+
+logger = logging.getLogger(__name__)
+
+
+def send_controller(
+ controller_type: str | None,
+ requests: Any,
+ is_update: bool = False,
+ old_service_id: str | None = None,
+) -> Any:
+ """Route provisioning requests to the appropriate network controller.
-def send_controller(controller_type, requests, is_update=False, old_service_id=None):
- """
- Route provisioning requests to the appropriate network controller.
-
- This function acts as a dispatcher that sends configuration requests to
- different SDN controller types based on the specified controller type.
-
Args:
- controller_type (str): Type of controller to send requests to:
- - "TFS": TeraFlow SDN controller
- - "IXIA": Ixia network emulation controller
- - "E2E": TeraFlow End-to-End controller
- requests (dict or list): Configuration requests to be sent to the controller
- is_update (bool): Whether it is a modification/update request
- old_service_id (str): Old service ID to delete (optional)
-
+ controller_type: Type of controller ("TFS", "IXIA", "E2E", "RESTCONF").
+ requests: Configuration request payload.
+ is_update: Whether it is a modification request. Defaults to False.
+ old_service_id: Old service ID to delete on update. Defaults to None.
+
Returns:
- bool or dict: Response from the controller indicating success/failure
- of the provisioning operation. Returns True in DUMMY_MODE.
-
- Notes:
- - If DUMMY_MODE is enabled in config, returns True without sending requests
- - Uses IP addresses from Flask application configuration:
- * TFS_IP for TeraFlow
- * IXIA_IP for Ixia
- * TFS_E2E for End-to-End
- - Logs the controller type that received the request
-
- Raises:
- Exception: May be raised by individual connect functions on communication errors
+ Response from the controller, or True if DUMMY_MODE is active.
"""
- if current_app.config["DUMMY_MODE"]:
+ if current_app.config.get("DUMMY_MODE", False):
return True
-
- if controller_type == "TFS":
- response = tfs_connect(requests, current_app.config["TFS_IP"])
- logging.info("Request sent to Teraflow")
- elif controller_type == "IXIA":
- response = ixia_connect(requests, current_app.config["IXIA_IP"])
- logging.info("Requests sent to Ixia")
- elif controller_type == "E2E":
- 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"])
- logging.info("Requests sent to restconf controller")
- return response
\ No newline at end of file
+
+ match controller_type:
+ case "TFS":
+ response = tfs_connect(requests, current_app.config["TFS_IP"])
+ logger.info("Request sent to Teraflow")
+ return response
+
+ case "IXIA":
+ response = ixia_connect(requests, current_app.config["IXIA_IP"])
+ logger.info("Requests sent to Ixia")
+ return response
+
+ case "E2E":
+ response = e2e_connect(
+ requests,
+ current_app.config["TFS_E2E_IP"],
+ is_update=is_update,
+ old_service_id=old_service_id,
+ )
+ logger.info("Requests sent to Teraflow E2E")
+ return response
+
+ case "RESTCONF":
+ response = restconf_connect(requests, current_app.config["RESTCONF_IP"])
+ logger.info("Requests sent to restconf controller")
+ return response
+
+ case _:
+ logger.warning("Unknown controller type: %s", controller_type)
+ return None
diff --git a/src/realizer/tfs/helpers/cisco_connector.py b/src/realizer/tfs/helpers/cisco_connector.py
index 27a3b9d205dfd40c366b275a318c73aa2a91e623..9430506cf4e820528081f5350b2b3acffe1d1a2a 100644
--- a/src/realizer/tfs/helpers/cisco_connector.py
+++ b/src/realizer/tfs/helpers/cisco_connector.py
@@ -1,11 +1,11 @@
# Copyright 2022-2026 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.
@@ -14,106 +14,92 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
+"""SSH Netmiko connector for Cisco XR devices within TFS integration."""
+
+from __future__ import annotations
+
import logging
+from typing import Any
+
from netmiko import ConnectHandler
-class cisco_connector():
- """Class to interact with Cisco devices via SSH using Netmiko."""
- def __init__(self, address, configs=None):
- self.address=address
- self.configs=configs
+logger = logging.getLogger(__name__)
- def execute_commands(self, commands):
- """
- Execute a list of commands on the Cisco device.
- Args:
- commands (list): List of commands to execute on the device.
- """
- try:
- # Device configuration
- device = {
- 'device_type': 'cisco_xr',
- 'host': self.address,
- 'username': 'cisco',
- 'password': 'cisco12345',
- }
- # SSH connection
- connection = ConnectHandler(**device)
+class cisco_connector:
+ """Class to interact with Cisco XR devices via SSH using Netmiko."""
- # Send commands
- output = connection.send_config_set(commands)
- logging.debug(output)
+ def __init__(self, address: str, configs: list[dict[str, Any]] | None = None) -> None:
+ self.address = address
+ self.configs = configs or []
- # Close connection
- connection.disconnect()
+ def execute_commands(self, commands: list[str]) -> None:
+ """Execute a list of commands on the Cisco device.
- except EOFError as e:
- logging.error("Failed to execute commands on %s: %s",self.address, str(e))
-
- def create_command_template(self, config):
+ Args:
+ commands: List of commands to execute on the device.
"""
- Create command template for configuring a Cisco device.
+ device: dict[str, Any] = {
+ "device_type": "cisco_xr",
+ "host": self.address,
+ "username": "cisco",
+ "password": "cisco12345",
+ }
+ try:
+ with ConnectHandler(**device) as connection:
+ output = connection.send_config_set(commands)
+ logger.debug(output)
+ except Exception as e:
+ logger.error("Failed to execute commands on %s: %s", self.address, e)
+
+ def create_command_template(self, config: dict[str, Any]) -> list[str]:
+ """Create command template for configuring a Cisco device.
Args:
- config (dict): Configuration parameters for the device.
+ config: Configuration parameters for the device.
Returns:
- list: List of commands to configure the device.
+ List of commands to configure the device.
"""
- commands = [
- "l2vpn",
- f"pw-class l2vpn_vpws_profile_example_{config['number']}",
- "encapsulation mpls"
- ]
+ number = config.get("number")
+ ni_name = config.get("ni_name")
+ interface = config.get("interface")
+ vlan = config.get("vlan")
+ remote_router = config.get("remote_router")
- commands.extend([
+ return [
+ "l2vpn",
+ f"pw-class l2vpn_vpws_profile_example_{number}",
+ "encapsulation mpls",
"transport-mode vlan passthrough",
- "control-word"
- ])
-
- commands.extend([
- f"preferred-path interface tunnel-te {config['number']}",
+ "control-word",
+ f"preferred-path interface tunnel-te {number}",
+ "exit",
"exit",
- "exit"
- ])
-
- commands.extend([
"xconnect group l2vpn_vpws_group_example",
- f"p2p {config['ni_name']}",
- f"interface {config['interface']}.{config['vlan']}",
- f"neighbor ipv4 {config['remote_router']} pw-id {config['vlan']}",
+ f"p2p {ni_name}",
+ f"interface {interface}.{vlan}",
+ f"neighbor ipv4 {remote_router} pw-id {vlan}",
"no pw-class l2vpn_vpws_profile_example",
- f"pw-class l2vpn_vpws_profile_example_{config['number']}"
- ])
-
- return commands
+ f"pw-class l2vpn_vpws_profile_example_{number}",
+ ]
- def full_create_command_template(self):
- """
- Create full command template for configuring a Cisco device based on the provided configurations.
+ def full_create_command_template(self) -> list[str]:
+ """Create full command template for configuring a Cisco device based on the provided configurations.
Returns:
- list: List of commands to configure the device.
+ List of commands to configure the device.
"""
- commands =[]
+ commands: list[str] = []
for config in self.configs:
- commands_temp = self.create_command_template(config)
- commands.extend(commands_temp)
- commands.append("commit")
- commands.append("end")
+ commands.extend(self.create_command_template(config))
+ commands.extend(["commit", "end"])
return commands
- def create_command_template_delete(self):
- """
- Create command template for deleting L2VPN configuration on a Cisco device.
+ def create_command_template_delete(self) -> list[str]:
+ """Create command template for deleting L2VPN configuration on a Cisco device.
+
Returns:
- list: List of commands to delete the L2VPN configuration.
+ List of commands to delete the L2VPN configuration.
"""
- commands = [
- "no l2vpn",
- ]
-
- commands.append("commit")
- commands.append("end")
- return commands
+ return ["no l2vpn", "commit", "end"]
diff --git a/src/realizer/tfs/helpers/tfs_connector.py b/src/realizer/tfs/helpers/tfs_connector.py
index c927b238a1e235d53371edd5e1d6b06f3a67ccad..e919d43860a0769299e7119d205c45d469adba4e 100644
--- a/src/realizer/tfs/helpers/tfs_connector.py
+++ b/src/realizer/tfs/helpers/tfs_connector.py
@@ -1,11 +1,11 @@
# Copyright 2022-2026 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.
@@ -14,152 +14,111 @@
# This file includes original contributions from Telefonica Innovación Digital S.L.
-import logging, requests, json
+"""TeraFlowSDN NBI and WebUI HTTP connector helper."""
+
+from __future__ import annotations
+
+import json
+import logging
+from typing import Any
+
+import requests
+
from src.config.constants import NBI_L2_PATH, NBI_L3_PATH
-class tfs_connector():
- """
- Helper class to interact with TeraFlowSDN Northbound Interface (NBI) and WebUI.
- """
- def webui_post(self, tfs_ip, service):
- """
- Post service descriptor to TFS WebUI.
-
- Args:
- tfs_ip (str): IP address of the TFS instance
- service (dict): Service descriptor to be posted
-
- Returns:
- requests.Response: Response object from the POST request
- """
- user="admin"
- password="admin"
- token=""
+logger = logging.getLogger(__name__)
+
+
+class tfs_connector:
+ """Helper class to interact with TeraFlowSDN Northbound Interface (NBI) and WebUI."""
+
+ def webui_post(self, tfs_ip: str, service: dict[str, Any]) -> requests.Response:
+ """Post service descriptor to TFS WebUI."""
session = requests.Session()
- session.auth = (user, password)
- url=f'http://{tfs_ip}/webui'
- response=session.get(url=url)
+ session.auth = ("admin", "admin")
+ url = f"http://{tfs_ip}/webui"
+ response = session.get(url=url, timeout=60)
+
+ token = ""
for item in response.iter_lines():
- if("csrf_token" in str(item)):
- string=str(item).split(' requests.Response:
+ """Post service descriptor to TFS NBI."""
session = requests.Session()
- session.auth = (user, password)
- url = f'http://{tfs_ip}/{path}'
- headers = {'Content-Type': 'application/json'}
+ session.auth = ("admin", "admin")
+ url = f"http://{tfs_ip}/{path}"
+ headers = {"Content-Type": "application/json"}
data = json.dumps(service)
- logging.debug("Posting to TFS NBI: %s",data)
- token={'csrf_token':token}
- response = session.post(url,headers=headers,data=data,timeout=60)
+ logger.debug("Posting to TFS NBI: %s", data)
+ response = session.post(url, headers=headers, data=data, timeout=60)
response.raise_for_status()
- logging.debug("Http response: %s",response.text)
+ logger.debug("Http response: %s", response.text)
return response
- def ipowdm_post(self, tfs_ip: str, slice_id: str, payload: object, timeout: int = 60):
- """
- Post IPoWDM service payload to the controller NBI endpoint:
- http://{tfs_ip}/restconf/ipowdm/v1/service/{slice_id}
-
- Args:
- tfs_ip: controller host (ip[:port])
- slice_id: identifier for the slice (path parameter)
- payload: JSON-serializable payload to send
- timeout: request timeout in seconds
-
- Returns:
- requests.Response
- """
+ def ipowdm_post(self, tfs_ip: str, slice_id: str, payload: Any, timeout: int = 60) -> requests.Response:
+ """Post IPoWDM service payload to the controller NBI endpoint."""
session = requests.Session()
- url = f'http://{tfs_ip}/restconf/ipowdm/v1/service/{slice_id}'
- headers = {'Content-Type': 'application/json'}
+ 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)
+ logger.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)
+ logger.debug("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}
- """
+ def ipowdm_put(self, tfs_ip: str, slice_id: str, payload: Any, timeout: int = 60) -> requests.Response:
+ """Put (Update) IPoWDM service payload to the controller NBI endpoint."""
session = requests.Session()
- url = f'http://{tfs_ip}/restconf/ipowdm/v1/service/{slice_id}'
- headers = {'Content-Type': 'application/json'}
+ 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)
+ logger.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)
+ logger.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}
- """
+ def ipowdm_delete(self, tfs_ip: str, slice_id: str, timeout: int = 60) -> requests.Response | None:
+ """Delete IPoWDM service payload from the controller NBI endpoint."""
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)
+ url = f"http://{tfs_ip}/restconf/ipowdm/v1/service/{slice_id}"
+ headers = {"Content-Type": "application/json"}
+ logger.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)
+ logger.debug("Delete Http response: %s", response.text)
return response
except Exception as e:
- logging.error(f"Failed to delete IPoWDM service: {e}")
+ logger.error("Failed to delete IPoWDM service: %s", e)
return None
-
- def nbi_delete(self, tfs_ip: str, service_type: str , service_id: str) -> requests.Response:
- """
- Delete service from TFS NBI.
- Args:
- tfs_ip (str): IP address of the TFS instance
- service_type (str): Type of the service ('L2' or 'L3')
- service_id (str): Unique identifier of the service to delete
- Returns:
- requests.Response: Response object from the DELETE request
- """
- user="admin"
- password="admin"
- url = f'http://{user}:{password}@{tfs_ip}'
- if service_type == 'L2':
- url = url + f'/{NBI_L2_PATH}/vpn-service={service_id}'
- elif service_type == 'L3':
- url = url + f'/{NBI_L3_PATH}/vpn-service={service_id}'
- else:
- raise ValueError("Invalid service type. Use 'L2' or 'L3'.")
+
+ def nbi_delete(self, tfs_ip: str, service_type: str, service_id: str) -> requests.Response:
+ """Delete service from TFS NBI."""
+ base_url = f"http://admin:admin@{tfs_ip}"
+ match service_type:
+ case "L2":
+ url = f"{base_url}/{NBI_L2_PATH}/vpn-service={service_id}"
+ case "L3":
+ url = f"{base_url}/{NBI_L3_PATH}/vpn-service={service_id}"
+ case _:
+ raise ValueError("Invalid service type. Use 'L2' or 'L3'.")
+
response = requests.delete(url, timeout=60)
response.raise_for_status()
- logging.debug('Service deleted successfully')
- logging.debug("Http response: %s",response.text)
- return response
\ No newline at end of file
+ logger.debug("Service deleted successfully")
+ logger.debug("Http response: %s", response.text)
+ return response
diff --git a/src/realizer/tfs/main.py b/src/realizer/tfs/main.py
index 8e98a7b91bd383342ee6bff46909d66c89c50578..8e7cb3790209e4a6b63088ac7e7b2c32cf2a1a98 100644
--- a/src/realizer/tfs/main.py
+++ b/src/realizer/tfs/main.py
@@ -1,11 +1,11 @@
# Copyright 2022-2026 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.
@@ -14,27 +14,39 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
+"""TeraFlowSDN (TFS) slice intent realizer entry point."""
+
+from __future__ import annotations
+
import logging
+from typing import Any
+
from .service_types.tfs_l2vpn import tfs_l2vpn
from .service_types.tfs_l3vpn import tfs_l3vpn
-def tfs(ietf_intent, way=None, response=None):
- """
- Generates a TFS realizing request based on the specified way (L2 or L3).
+logger = logging.getLogger(__name__)
+
+
+def tfs(
+ ietf_intent: dict[str, Any],
+ way: str | None = None,
+ response: Any = None,
+) -> dict[str, Any] | None:
+ """Generate a TFS realization request based on the specified way (L2 or L3).
Args:
- ietf_intent (dict): The IETF intent to be realized. Defaults to None.
- way (str): The type of service to realize ("L2" or "L3"). Defaults to None.
- response (dict): Response built for user feedback. Defaults to None.
-
+ ietf_intent: The IETF slice intent dictionary to be realized.
+ way: Service layer type ('L2' or 'L3'). Defaults to None.
+ response: Response built for user feedback. Defaults to None.
+
Returns:
- dict: A realization request for the specified network slice type.
+ Realization request dictionary or None if endpoints are missing.
"""
- if way == "L2":
- realizing_request = tfs_l2vpn(ietf_intent, response)
- elif way == "L3":
- realizing_request = tfs_l3vpn(ietf_intent, response)
- else:
- logging.warning(f"Unsupported way: {way}. Defaulting to L2 realization.")
- realizing_request = tfs_l2vpn(ietf_intent, response)
- return realizing_request
\ No newline at end of file
+ match way:
+ case "L2":
+ return tfs_l2vpn(ietf_intent, response)
+ case "L3":
+ return tfs_l3vpn(ietf_intent, response)
+ case _:
+ logger.warning("Unsupported way: %s. Defaulting to L2 realization.", way)
+ return tfs_l2vpn(ietf_intent, response)
diff --git a/src/realizer/tfs/service_types/tfs_l2vpn.py b/src/realizer/tfs/service_types/tfs_l2vpn.py
index 972545913c3a6782dedff78fa234bbcf39c8ee6e..3a25bad0160d140b621fa42690b3515208729ac5 100644
--- a/src/realizer/tfs/service_types/tfs_l2vpn.py
+++ b/src/realizer/tfs/service_types/tfs_l2vpn.py
@@ -1,11 +1,11 @@
# Copyright 2022-2026 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.
@@ -14,124 +14,202 @@
# This file includes original contributions from Telefonica Innovación Digital S.L.
-import logging, os
-from src.config.constants import TEMPLATES_PATH, NBI_L2_PATH
+"""TFS L2VPN slice intent translation and service template builder."""
+
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+from typing import Any
+
+from flask import current_app
+
+from src.config.constants import NBI_L2_PATH, TEMPLATES_PATH
from src.utils.load_template import load_template
from src.utils.safe_get import safe_get
+
from ..helpers.cisco_connector import cisco_connector
-from flask import current_app
-def tfs_l2vpn(ietf_intent, response):
- """
- Translate slice intent into a TeraFlow service request.
+logger = logging.getLogger(__name__)
+
+
+def _build_webui_l2vpn(
+ ietf_intent: dict[str, Any],
+ slice_data: dict[str, Any] | None,
+ origin_router_id: str,
+ destination_router_id: str,
+) -> dict[str, Any]:
+ """Build TFS WebUI L2VPN service descriptor."""
+ origin_router_if = "0/0/0-GigabitEthernet0/0/0/0"
+ destination_router_if = "0/0/0-GigabitEthernet0/0/0/0"
+
+ tfs_request = load_template(str(Path(TEMPLATES_PATH) / "L2-VPN_template_empty.json"))["services"][0]
+ slice_id = safe_get(
+ ietf_intent,
+ ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "id"],
+ )
+ tfs_request["service_id"]["service_uuid"]["uuid"] = slice_id
+
+ for endpoint in tfs_request["service_endpoint_ids"]:
+ is_first = endpoint is tfs_request["service_endpoint_ids"][0]
+ endpoint["device_id"]["device_uuid"]["uuid"] = origin_router_id if is_first else destination_router_id
+ endpoint["endpoint_uuid"]["uuid"] = origin_router_if if is_first else destination_router_if
+
+ if slice_data:
+ for constraint in slice_data.get("requirements", []):
+ tfs_request["service_constraints"].append({"custom": constraint})
+
+ for i, config_rule in enumerate(tfs_request["service_config"]["config_rules"][1:], start=1):
+ router_id = origin_router_id if i == 1 else destination_router_id
+ router_if = origin_router_if if i == 1 else destination_router_if
+ resource_value = config_rule["custom"]["resource_value"]
+
+ sdp_index = i - 1
+ vlan_value = safe_get(
+ ietf_intent,
+ [
+ "ietf-network-slice-service:network-slice-services",
+ "slice-service",
+ 0,
+ "sdps",
+ "sdp",
+ sdp_index,
+ "service-match-criteria",
+ "match-criterion",
+ 0,
+ "match-type",
+ 0,
+ "vlan",
+ 0,
+ ],
+ )
+ if vlan_value:
+ resource_value["vlan_id"] = int(vlan_value)
+ resource_value["circuit_id"] = vlan_value
+ resource_value["remote_router"] = destination_router_id if i == 1 else origin_router_id
+ resource_value["ni_name"] = f"ELAN{vlan_value!s}"
+ config_rule["custom"]["resource_key"] = f"/device[{router_id}]/endpoint[{router_if}]/settings"
+
+ return tfs_request
+
- This method prepares a L2VPN service request by:
- 1. Defining endpoint routers
- 2. Loading a service template
- 3. Generating a unique service UUID
- 4. Configuring service endpoints
- 5. Adding QoS constraints
- 6. Preparing configuration rules for network interfaces
+def _build_nbi_l2vpn(
+ ietf_intent: dict[str, Any],
+ origin_router_id: str,
+ destination_router_id: str,
+) -> dict[str, Any]:
+ """Build TFS NBI L2VPN service descriptor."""
+ tfs_request = load_template(str(Path(TEMPLATES_PATH) / "ietfL2VPN_template_empty.json"))
+ tfs_request["path"] = NBI_L2_PATH
+
+ full_id = (
+ safe_get(
+ ietf_intent,
+ ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "id"],
+ )
+ or ""
+ )
+ uuid_only = full_id.split("slice-service-")[-1]
+ tfs_request["ietf-l2vpn-svc:vpn-service"][0]["vpn-id"] = uuid_only
+
+ sites = tfs_request["ietf-l2vpn-svc:vpn-service"][0]["site"]
+ sdps = (
+ safe_get(
+ ietf_intent,
+ ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "sdps", "sdp"],
+ )
+ or []
+ )
+
+ for i, site in enumerate(sites):
+ is_origin = i == 0
+ router_id = origin_router_id if is_origin else destination_router_id
+ sdp = sdps[0] if is_origin and len(sdps) > 0 else (sdps[1] if len(sdps) > 1 else {})
+ site["site-id"] = router_id
+ site["site-location"] = sdp.get("node-id")
+ site["site-network-access"]["interface"]["ip-address"] = sdp.get("sdp-ip-address")
+
+ return tfs_request
+
+
+def tfs_l2vpn(
+ ietf_intent: dict[str, Any],
+ response: list[dict[str, Any]] | None = None,
+) -> dict[str, Any] | None:
+ """Translate slice intent into a TeraFlow L2VPN service request.
Args:
- ietf_intent (dict): IETF-formatted network slice intent.
- response (dict): Response data containing slice information.
+ ietf_intent: IETF-formatted network slice intent.
+ response: Slice database records.
Returns:
- dict: A TeraFlow service request for L2VPN configuration.
-
+ Structured TFS L2VPN service request.
"""
- # Hardcoded router endpoints
- # TODO (should be dynamically determined)
- logging.info(ietf_intent)
- origin_router_id = safe_get(ietf_intent, ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "sdps", "sdp", 0, "attachment-circuits", "attachment-circuit", 0, "sdp-peering", "peer-sap-id"])
+ logger.info("Translating TFS L2VPN intent: %s", ietf_intent)
+ origin_router_id = safe_get(
+ ietf_intent,
+ [
+ "ietf-network-slice-service:network-slice-services",
+ "slice-service",
+ 0,
+ "sdps",
+ "sdp",
+ 0,
+ "attachment-circuits",
+ "attachment-circuit",
+ 0,
+ "sdp-peering",
+ "peer-sap-id",
+ ],
+ )
if not origin_router_id:
- logging.warning("Origin router ID not found in the intent. Skipping L2VPN realization.")
+ logger.warning("Origin router ID not found in the intent. Skipping L2VPN realization.")
return None
- origin_router_if = '0/0/0-GigabitEthernet0/0/0/0'
- destination_router_id = safe_get(ietf_intent, ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "sdps", "sdp", 1, "attachment-circuits", "attachment-circuit", 0, "sdp-peering", "peer-sap-id"])
+
+ destination_router_id = safe_get(
+ ietf_intent,
+ [
+ "ietf-network-slice-service:network-slice-services",
+ "slice-service",
+ 0,
+ "sdps",
+ "sdp",
+ 1,
+ "attachment-circuits",
+ "attachment-circuit",
+ 0,
+ "sdp-peering",
+ "peer-sap-id",
+ ],
+ )
if not destination_router_id:
- logging.warning("Destination router ID not found in the intent. Skipping L2VPN realization.")
+ logger.warning("Destination router ID not found in the intent. Skipping L2VPN realization.")
return None
- destination_router_if = '0/0/0-GigabitEthernet0/0/0/0'
- id = ietf_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"]
- slice = next((d for d in response if d.get("id") == id), None)
-
- if current_app.config["UPLOAD_TYPE"] == "WEBUI":
- # Load L2VPN service template
- tfs_request = load_template(os.path.join(TEMPLATES_PATH, "L2-VPN_template_empty.json"))["services"][0]
-
- # Configure service UUID
- tfs_request["service_id"]["service_uuid"]["uuid"] = ietf_intent['ietf-network-slice-service:network-slice-services']['slice-service'][0]["id"]
- # Configure service endpoints
- for endpoint in tfs_request["service_endpoint_ids"]:
- endpoint["device_id"]["device_uuid"]["uuid"] = origin_router_id if endpoint is tfs_request["service_endpoint_ids"][0] else destination_router_id
- endpoint["endpoint_uuid"]["uuid"] = origin_router_if if endpoint is tfs_request["service_endpoint_ids"][0] else destination_router_if
+ slice_id = safe_get(
+ ietf_intent,
+ ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "id"],
+ )
+ slice_data = next((d for d in (response or []) if d.get("id") == slice_id), None)
- # Add service constraints
- for constraint in slice.get("requirements", []):
- tfs_request["service_constraints"].append({"custom": constraint})
+ upload_type = current_app.config.get("UPLOAD_TYPE", "WEBUI")
+ if upload_type == "WEBUI":
+ tfs_request = _build_webui_l2vpn(ietf_intent, slice_data, origin_router_id, destination_router_id)
+ elif upload_type == "NBI":
+ tfs_request = _build_nbi_l2vpn(ietf_intent, origin_router_id, destination_router_id)
+ else:
+ logger.warning("Unsupported upload type: %s", upload_type)
+ return None
- # Add configuration rules
- for i, config_rule in enumerate(tfs_request["service_config"]["config_rules"][1:], start=1):
- router_id = origin_router_id if i == 1 else destination_router_id
- router_if = origin_router_if if i == 1 else destination_router_if
- resource_value = config_rule["custom"]["resource_value"]
-
- sdp_index = i - 1
- vlan_value = ietf_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][sdp_index]["service-match-criteria"]["match-criterion"][0]["match-type"][0]["vlan"][0]
- if vlan_value:
- resource_value["vlan_id"] = int(vlan_value)
- resource_value["circuit_id"] = vlan_value
- resource_value["remote_router"] = destination_router_id if i == 1 else origin_router_id
- resource_value["ni_name"] = 'ELAN{:s}'.format(str(vlan_value))
- config_rule["custom"]["resource_key"] = f"/device[{router_id}]/endpoint[{router_if}]/settings"
-
- elif current_app.config["UPLOAD_TYPE"] == "NBI":
- #self.path = NBI_L2_PATH
- # Load IETF L2VPN service template
- tfs_request = load_template(os.path.join(TEMPLATES_PATH, "ietfL2VPN_template_empty.json"))
-
- # Add path to the request
- tfs_request["path"] = NBI_L2_PATH
-
- # Generate service UUID
- full_id = ietf_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"]
- uuid_only = full_id.split("slice-service-")[-1]
- tfs_request["ietf-l2vpn-svc:vpn-service"][0]["vpn-id"] = uuid_only
-
- # Configure service endpoints
- sites = tfs_request["ietf-l2vpn-svc:vpn-service"][0]["site"]
- sdps = ietf_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"]
-
- for i, site in enumerate(sites):
- is_origin = (i == 0)
- router_id = origin_router_id if is_origin else destination_router_id
- sdp = sdps[0] if is_origin else sdps[1]
- site["site-id"] = router_id
- site["site-location"] = sdp["node-id"]
- site["site-network-access"]["interface"]["ip-address"] = sdp["sdp-ip-address"]
-
- logging.info(f"L2VPN Intent realized")
+ logger.info("L2VPN Intent realized")
return tfs_request
-def tfs_l2vpn_support(requests):
- """
- Configuration support for L2VPN with path selection based on MPLS traffic-engineering tunnels
- Args:
- requests (list): A list of configuration parameters.
+def tfs_l2vpn_support(requests: list[dict[str, Any]]) -> None:
+ """Configure Cisco devices for L2VPN with MPLS traffic-engineering tunnels."""
+ sources: dict[str, Any] = {"source": "10.60.125.44", "config": []}
+ destinations: dict[str, Any] = {"destination": "10.60.125.45", "config": []}
- """
- sources={
- "source": "10.60.125.44",
- "config":[]
- }
- destinations={
- "destination": "10.60.125.45",
- "config":[]
- }
for request in requests:
config_rules = safe_get(request, ["service_config", "config_rules"]) or []
if len(config_rules) < 3:
@@ -144,50 +222,39 @@ def tfs_l2vpn_support(requests):
continue
endpoints = request.get("service_endpoint_ids", [{}, {}])
- config = {
- "ni_name": temp_source.get("ni_name", ""),
- "remote_router": temp_source.get("remote_router", ""),
- "interface": endpoints[0].get("endpoint_uuid", {}).get("uuid", "").replace("0/0/0-", ""),
- "vlan" : temp_source.get("vlan_id", 0),
- "number" : temp_source.get("vlan_id", 0) % 10 + 1
- }
- sources["config"].append(config)
-
- config = {
- "ni_name": temp_destiny.get("ni_name", ""),
- "remote_router": temp_destiny.get("remote_router", ""),
- "interface": endpoints[1].get("endpoint_uuid", {}).get("uuid", "").replace("0/0/3-", ""),
- "vlan" : temp_destiny.get("vlan_id", 0),
- "number" : temp_destiny.get("vlan_id", 0) % 10 + 1
- }
- destinations["config"].append(config)
-
- #cisco_source = cisco_connector(source_address, ni_name, remote_router, vlan, vlan % 10 + 1)
+ sources["config"].append(
+ {
+ "ni_name": temp_source.get("ni_name", ""),
+ "remote_router": temp_source.get("remote_router", ""),
+ "interface": endpoints[0].get("endpoint_uuid", {}).get("uuid", "").replace("0/0/0-", ""),
+ "vlan": temp_source.get("vlan_id", 0),
+ "number": temp_source.get("vlan_id", 0) % 10 + 1,
+ }
+ )
+
+ destinations["config"].append(
+ {
+ "ni_name": temp_destiny.get("ni_name", ""),
+ "remote_router": temp_destiny.get("remote_router", ""),
+ "interface": endpoints[1].get("endpoint_uuid", {}).get("uuid", "").replace("0/0/3-", ""),
+ "vlan": temp_destiny.get("vlan_id", 0),
+ "number": temp_destiny.get("vlan_id", 0) % 10 + 1,
+ }
+ )
+
cisco_source = cisco_connector(sources["source"], sources["config"])
- commands = cisco_source.full_create_command_template()
- cisco_source.execute_commands(commands)
+ cisco_source.execute_commands(cisco_source.full_create_command_template())
- #cisco_destiny = cisco_connector(destination_address, ni_name, remote_router, vlan, vlan % 10 + 1)
cisco_destiny = cisco_connector(destinations["destination"], destinations["config"])
- commands = cisco_destiny.full_create_command_template()
- cisco_destiny.execute_commands(commands)
+ cisco_destiny.execute_commands(cisco_destiny.full_create_command_template())
-def tfs_l2vpn_delete():
- """
- Delete L2VPN configurations from Cisco devices.
- This method removes L2VPN configurations from Cisco routers
-
- Notes:
- - Uses cisco_connector to generate and execute deletion commands
- - Clears Network Interface (NI) settings
- """
- # Delete Source Endpoint Configuration
+def tfs_l2vpn_delete() -> None:
+ """Delete L2VPN configurations from Cisco devices."""
source_address = "10.60.125.44"
cisco_source = cisco_connector(source_address)
cisco_source.execute_commands(cisco_source.create_command_template_delete())
- # Delete Destination Endpoint Configuration
destination_address = "10.60.125.45"
cisco_destiny = cisco_connector(destination_address)
- cisco_destiny.execute_commands(cisco_destiny.create_command_template_delete())
\ No newline at end of file
+ cisco_destiny.execute_commands(cisco_destiny.create_command_template_delete())
diff --git a/src/realizer/tfs/service_types/tfs_l3vpn.py b/src/realizer/tfs/service_types/tfs_l3vpn.py
index 5d561d48a168a35627de7e33483349a3ca6a575e..073876c17986bde18e59baa2e5bab414983ed64e 100644
--- a/src/realizer/tfs/service_types/tfs_l3vpn.py
+++ b/src/realizer/tfs/service_types/tfs_l3vpn.py
@@ -1,11 +1,11 @@
# Copyright 2022-2026 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.
@@ -14,128 +14,224 @@
# This file includes original contributions from Telefonica Innovación Digital S.L.
-import logging, os
-from src.config.constants import TEMPLATES_PATH, NBI_L3_PATH
+"""TFS L3VPN slice intent translation and service template builder."""
+
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+from typing import Any
+
+from flask import current_app
+
+from src.config.constants import NBI_L3_PATH, TEMPLATES_PATH
from src.utils.load_template import load_template
from src.utils.safe_get import safe_get
-from flask import current_app
-def tfs_l3vpn(ietf_intent, response):
- """
- Translate L3VPN (Layer 3 Virtual Private Network) intent into a TeraFlow service request.
+logger = logging.getLogger(__name__)
+
+
+def _apply_qos_requirements(access: dict[str, Any], requirements: list[dict[str, Any]]) -> None:
+ """Apply QoS requirements to an NBI L3VPN network access structure."""
+ for constraint in requirements:
+ ctype = constraint.get("constraint_type", "")
+ cvalue = float(constraint.get("constraint_value", 0))
+
+ if ctype.startswith("one-way-bandwidth"):
+ unit = ctype.split("[")[-1].rstrip("]")
+ multiplier = {"bps": 1, "kbps": 1_000, "Mbps": 1_000_000, "Gbps": 1_000_000_000}.get(unit, 1)
+ value = int(cvalue * multiplier)
+ access["service"]["svc-input-bandwidth"] = value
+ access["service"]["svc-output-bandwidth"] = value
+ elif ctype == "one-way-delay-maximum[milliseconds]":
+ access["service"]["qos"]["qos-profile"]["classes"]["class"][0]["latency"]["latency-boundary"] = int(cvalue)
+ elif ctype == "availability[%]":
+ access["service"]["qos"]["qos-profile"]["classes"]["class"][0]["bandwidth"]["guaranteed-bw-percent"] = int(
+ cvalue
+ )
+ elif ctype == "mtu[bytes]":
+ access["service"]["svc-mtu"] = int(cvalue)
+
+
+def _build_webui_l3vpn(
+ ietf_intent: dict[str, Any],
+ slice_data: dict[str, Any] | None,
+ origin_router_id: str,
+ destination_router_id: str,
+) -> dict[str, Any]:
+ """Build TFS WebUI L3VPN service descriptor."""
+ origin_router_if = "0/0/0-GigabitEthernet0/0/0/0"
+ destination_router_if = "0/0/0-GigabitEthernet0/0/0/0"
+
+ tfs_request = load_template(str(Path(TEMPLATES_PATH) / "L3-VPN_template_empty.json"))["services"][0]
+ slice_id = safe_get(
+ ietf_intent,
+ ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "id"],
+ )
+ tfs_request["service_id"]["service_uuid"]["uuid"] = slice_id
+
+ for endpoint in tfs_request["service_endpoint_ids"]:
+ is_first = endpoint is tfs_request["service_endpoint_ids"][0]
+ endpoint["device_id"]["device_uuid"]["uuid"] = origin_router_id if is_first else destination_router_id
+ endpoint["endpoint_uuid"]["uuid"] = origin_router_if if is_first else destination_router_if
+
+ if slice_data:
+ for constraint in slice_data.get("requirements", []):
+ tfs_request["service_constraints"].append({"custom": constraint})
- Similar to __tfs_l2vpn, but configured for Layer 3 VPN:
- 1. Defines endpoint routers
- 2. Loads service template
- 3. Generates unique service UUID
- 4. Configures service endpoints
- 5. Adds QoS constraints
- 6. Prepares configuration rules for network interfaces
+ for i, config_rule in enumerate(tfs_request["service_config"]["config_rules"][1:], start=1):
+ router_id = origin_router_id if i == 1 else destination_router_id
+ router_if = origin_router_if if i == 1 else destination_router_if
+ resource_value = config_rule["custom"]["resource_value"]
+
+ sdp_index = i - 1
+ vlan_value = safe_get(
+ ietf_intent,
+ [
+ "ietf-network-slice-service:network-slice-services",
+ "slice-service",
+ 0,
+ "sdps",
+ "sdp",
+ sdp_index,
+ "service-match-criteria",
+ "match-criterion",
+ 0,
+ "match-type",
+ 0,
+ "vlan",
+ 0,
+ ],
+ )
+ resource_value["router_id"] = destination_router_id if i == 1 else origin_router_id
+ resource_value["vlan_id"] = int(vlan_value) if vlan_value else 0
+ resource_value["address_ip"] = destination_router_id if i == 1 else origin_router_id
+ resource_value["policy_AZ"] = "policyA"
+ resource_value["policy_ZA"] = "policyB"
+ resource_value["ni_name"] = f"ELAN{vlan_value!s}"
+ config_rule["custom"]["resource_key"] = f"/device[{router_id}]/endpoint[{router_if}]/settings"
+
+ return tfs_request
+
+
+def _build_nbi_l3vpn(
+ ietf_intent: dict[str, Any],
+ slice_data: dict[str, Any] | None,
+ origin_router_id: str,
+ destination_router_id: str,
+) -> dict[str, Any]:
+ """Build TFS NBI L3VPN service descriptor."""
+ origin_router_if = "0/0/0-GigabitEthernet0/0/0/0"
+ destination_router_if = "0/0/0-GigabitEthernet0/0/0/0"
+
+ tfs_request = load_template(str(Path(TEMPLATES_PATH) / "ietfL3VPN_template_empty.json"))
+ tfs_request["path"] = NBI_L3_PATH
+
+ full_id = safe_get(
+ ietf_intent,
+ ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "id"],
+ )
+ tfs_request["ietf-l3vpn-svc:l3vpn-svc"]["vpn-services"]["vpn-service"][0]["vpn-id"] = full_id
+
+ sites = tfs_request["ietf-l3vpn-svc:l3vpn-svc"]["sites"]["site"]
+ sdps = (
+ safe_get(
+ ietf_intent,
+ ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "sdps", "sdp"],
+ )
+ or []
+ )
+
+ for i, site in enumerate(sites):
+ is_origin = i == 0
+ sdp_index = 0 if is_origin else 1
+ location = sdps[sdp_index].get("node-id") if len(sdps) > sdp_index else None
+ router_id = origin_router_id if is_origin else destination_router_id
+ router_if = origin_router_if if is_origin else destination_router_if
+
+ site["site-id"] = f"site_{location}"
+ site["locations"]["location"][0]["location-id"] = location
+ site["devices"]["device"][0]["device-id"] = router_id
+ site["devices"]["device"][0]["location"] = location
+
+ access = site["site-network-accesses"]["site-network-access"][0]
+ access["site-network-access-id"] = router_if
+ access["device-reference"] = router_id
+ access["vpn-attachment"]["vpn-id"] = full_id
+
+ if slice_data:
+ _apply_qos_requirements(access, slice_data.get("requirements", []))
+
+ return tfs_request
+
+
+def tfs_l3vpn(
+ ietf_intent: dict[str, Any],
+ response: list[dict[str, Any]] | None = None,
+) -> dict[str, Any] | None:
+ """Translate L3VPN slice intent into a TeraFlow service request.
Args:
- ietf_intent (dict): IETF-formatted network slice intent.
- response (dict): Response data containing slice information.
+ ietf_intent: IETF-formatted network slice intent dictionary.
+ response: Slice database records.
Returns:
- dict: A TeraFlow service request for L3VPN configuration.
+ Structured TFS L3VPN service request dictionary.
"""
- # Hardcoded router endpoints
- # TODO (should be dynamically determined)
- origin_router_id = safe_get(ietf_intent, ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "sdps", "sdp", 0, "attachment-circuits", "attachment-circuit", 0, "sdp-peering", "peer-sap-id"])
+ origin_router_id = safe_get(
+ ietf_intent,
+ [
+ "ietf-network-slice-service:network-slice-services",
+ "slice-service",
+ 0,
+ "sdps",
+ "sdp",
+ 0,
+ "attachment-circuits",
+ "attachment-circuit",
+ 0,
+ "sdp-peering",
+ "peer-sap-id",
+ ],
+ )
if not origin_router_id:
- logging.warning("Origin router ID not found in the intent. Skipping L3VPN realization.")
+ logger.warning("Origin router ID not found in the intent. Skipping L3VPN realization.")
return None
- origin_router_if = '0/0/0-GigabitEthernet0/0/0/0'
- destination_router_id = safe_get(ietf_intent, ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "sdps", "sdp", 1, "attachment-circuits", "attachment-circuit", 0, "sdp-peering", "peer-sap-id"])
+
+ destination_router_id = safe_get(
+ ietf_intent,
+ [
+ "ietf-network-slice-service:network-slice-services",
+ "slice-service",
+ 0,
+ "sdps",
+ "sdp",
+ 1,
+ "attachment-circuits",
+ "attachment-circuit",
+ 0,
+ "sdp-peering",
+ "peer-sap-id",
+ ],
+ )
if not destination_router_id:
- logging.warning("Destination router ID not found in the intent. Skipping L3VPN realization.")
+ logger.warning("Destination router ID not found in the intent. Skipping L3VPN realization.")
+ return None
+
+ slice_id = safe_get(
+ ietf_intent,
+ ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "id"],
+ )
+ slice_data = next((d for d in (response or []) if d.get("id") == slice_id), None)
+
+ upload_type = current_app.config.get("UPLOAD_TYPE", "WEBUI")
+ if upload_type == "WEBUI":
+ tfs_request = _build_webui_l3vpn(ietf_intent, slice_data, origin_router_id, destination_router_id)
+ elif upload_type == "NBI":
+ tfs_request = _build_nbi_l3vpn(ietf_intent, slice_data, origin_router_id, destination_router_id)
+ else:
+ logger.warning("Unsupported upload type: %s", upload_type)
return None
- destination_router_if = '0/0/0-GigabitEthernet0/0/0/0'
- id = ietf_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"]
- slice = next((d for d in response if d.get("id") == id), None)
-
- if current_app.config["UPLOAD_TYPE"] == "WEBUI":
- # Load L3VPN service template
- tfs_request = load_template(os.path.join(TEMPLATES_PATH, "L3-VPN_template_empty.json"))["services"][0]
- # Configure service UUID
- tfs_request["service_id"]["service_uuid"]["uuid"] = ietf_intent['ietf-network-slice-service:network-slice-services']['slice-service'][0]["id"]
-
- # Configure service endpoints
- for endpoint in tfs_request["service_endpoint_ids"]:
- endpoint["device_id"]["device_uuid"]["uuid"] = origin_router_id if endpoint is tfs_request["service_endpoint_ids"][0] else destination_router_id
- endpoint["endpoint_uuid"]["uuid"] = origin_router_if if endpoint is tfs_request["service_endpoint_ids"][0] else destination_router_if
-
- # Add service constraints
- for constraint in slice.get("requirements", []):
- tfs_request["service_constraints"].append({"custom": constraint})
- # Add configuration rules
- for i, config_rule in enumerate(tfs_request["service_config"]["config_rules"][1:], start=1):
- router_id = origin_router_id if i == 1 else destination_router_id
- router_if = origin_router_if if i == 1 else destination_router_if
- resource_value = config_rule["custom"]["resource_value"]
-
- sdp_index = i - 1
- vlan_value = ietf_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][sdp_index]["service-match-criteria"]["match-criterion"][0]["match-type"][0]["vlan"][0]
- resource_value["router_id"] = destination_router_id if i == 1 else origin_router_id
- resource_value["vlan_id"] = int(vlan_value)
- resource_value["address_ip"] = destination_router_id if i == 1 else origin_router_id
- resource_value["policy_AZ"] = "policyA"
- resource_value["policy_ZA"] = "policyB"
- resource_value["ni_name"] = 'ELAN{:s}'.format(str(vlan_value))
- config_rule["custom"]["resource_key"] = f"/device[{router_id}]/endpoint[{router_if}]/settings"
-
- elif current_app.config["UPLOAD_TYPE"] == "NBI":
- #self.path = NBI_L3_PATH
-
- # Load IETF L3VPN service template
- tfs_request = load_template(os.path.join(TEMPLATES_PATH, "ietfL3VPN_template_empty.json"))
-
- # Add path to the request
- tfs_request["path"] = NBI_L3_PATH
-
- # Generate service UUID
- full_id = ietf_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"]
- tfs_request["ietf-l3vpn-svc:l3vpn-svc"]["vpn-services"]["vpn-service"][0]["vpn-id"] = full_id
- # Configure service endpoints
- for i, site in enumerate(tfs_request["ietf-l3vpn-svc:l3vpn-svc"]["sites"]["site"]):
-
- # Determine if origin or destination
- is_origin = (i == 0)
- sdp_index = 0 if is_origin else 1
- location = ietf_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][sdp_index]["node-id"]
- router_id = origin_router_id if is_origin else destination_router_id
- router_if = origin_router_if if is_origin else destination_router_if
-
- # Assign common values
- site["site-id"] = f"site_{location}"
- site["locations"]["location"][0]["location-id"] = location
- site["devices"]["device"][0]["device-id"] = router_id
- site["devices"]["device"][0]["location"] = location
-
- access = site["site-network-accesses"]["site-network-access"][0]
- access["site-network-access-id"] = router_if
- access["device-reference"] = router_id
- access["vpn-attachment"]["vpn-id"] = full_id
-
- # Aplicar restricciones QoS
- for constraint in slice.get("requirements", []):
- ctype = constraint["constraint_type"]
- cvalue = float(constraint["constraint_value"])
- if constraint["constraint_type"].startswith("one-way-bandwidth"):
- unit = constraint["constraint_type"].split("[")[-1].rstrip("]")
- multiplier = {"bps": 1, "kbps": 1_000, "Mbps": 1_000_000, "Gbps": 1_000_000_000}.get(unit, 1)
- value = int(cvalue * multiplier)
- access["service"]["svc-input-bandwidth"] = value
- access["service"]["svc-output-bandwidth"] = value
- elif ctype == "one-way-delay-maximum[milliseconds]":
- access["service"]["qos"]["qos-profile"]["classes"]["class"][0]["latency"]["latency-boundary"] = int(cvalue)
- elif ctype == "availability[%]":
- access["service"]["qos"]["qos-profile"]["classes"]["class"][0]["bandwidth"]["guaranteed-bw-percent"] = int(cvalue)
- elif ctype == "mtu[bytes]":
- access["service"]["svc-mtu"] = int(cvalue)
-
-
- logging.info(f"L3VPN Intent realized")
- #self.answer[self.subnet]["VLAN"] = vlan_value
- return tfs_request
\ No newline at end of file
+ logger.info("L3VPN Intent realized")
+ return tfs_request
diff --git a/src/realizer/tfs/tfs_connect.py b/src/realizer/tfs/tfs_connect.py
index 99697a1e93e2d4c39e8df8142ce01086226c7288..d385a3f74bc6ca3a182fa3d0951165581f806476 100644
--- a/src/realizer/tfs/tfs_connect.py
+++ b/src/realizer/tfs/tfs_connect.py
@@ -1,11 +1,11 @@
# Copyright 2022-2026 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.
@@ -14,35 +14,48 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
-from .helpers.tfs_connector import tfs_connector
+"""Connector sending slice provisioning requests to TeraFlowSDN (TFS) controller."""
+
+from __future__ import annotations
+
+from typing import Any
+
from flask import current_app
+
from src.utils.send_response import send_response
+
+from .helpers.tfs_connector import tfs_connector
from .service_types.tfs_l2vpn import tfs_l2vpn_support
-def tfs_connect(requests, tfs_ip):
- """
- Connect to TeraflowSDN (TFS) controller and upload services.
-
+
+def tfs_connect(requests: dict[str, Any], tfs_ip: str) -> Any:
+ """Connect to TeraFlowSDN (TFS) controller and upload services.
+
Args:
- requests (dict): Dictionary containing services to upload
- tfs_ip (str): IP address of the TFS controller
-
+ requests: Dictionary containing services to upload.
+ tfs_ip: IP address of the TFS controller.
+
Returns:
- response (requests.Response): Response from TFS controller
- """
- if current_app.config["UPLOAD_TYPE"] == "WEBUI":
- response = tfs_connector().webui_post(tfs_ip, requests)
- elif current_app.config["UPLOAD_TYPE"] == "NBI":
- for intent in requests["services"]:
- # Send each separate NBI request
- path = intent.pop("path")
- response = tfs_connector().nbi_post(tfs_ip, intent, path)
+ Response from TFS controller or API error response.
+ """
+ upload_type = current_app.config.get("UPLOAD_TYPE", "WEBUI")
+ connector = tfs_connector()
+ response = None
+ if upload_type == "WEBUI":
+ response = connector.webui_post(tfs_ip, requests)
+ elif upload_type == "NBI":
+ for intent in requests.get("services", []):
+ path = intent.pop("path", "")
+ response = connector.nbi_post(tfs_ip, intent, path)
if not response.ok:
- return send_response(False, code=response.status_code, message=f"Teraflow upload failed. Response: {response.text}")
-
- # For deploying an L2VPN with path selection (not supported by Teraflow)
- if current_app.config["TFS_L2VPN_SUPPORT"]:
- tfs_l2vpn_support(requests["services"])
-
- return response
\ No newline at end of file
+ return send_response(
+ False,
+ code=response.status_code,
+ message=f"Teraflow upload failed. Response: {response.text}",
+ )
+
+ if current_app.config.get("TFS_L2VPN_SUPPORT"):
+ tfs_l2vpn_support(requests.get("services", []))
+
+ return response
diff --git a/src/tests/conftest.py b/src/tests/conftest.py
index b0dd88923088dd89154dccb282b5964722503374..46d2e7f18472bf02ec24348b61e1f735d56e0890 100644
--- a/src/tests/conftest.py
+++ b/src/tests/conftest.py
@@ -14,19 +14,16 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
-import sys
-import os
-import sqlite3
-import time
import base64
-from unittest.mock import MagicMock, patch
+import sys
+from unittest.mock import MagicMock
+
import pytest
-from flask import Flask
# -----------------------------------------------------------------------------
# Safely mock C-extensions (sysrepo, libyang) if not installed in current environment
# -----------------------------------------------------------------------------
-if 'sysrepo' not in sys.modules:
+if "sysrepo" not in sys.modules:
try:
import sysrepo
except ImportError:
@@ -36,45 +33,49 @@ if 'sysrepo' not in sys.modules:
conn_mock = MagicMock()
conn_mock.start_session.return_value = session_mock
sysrepo_mock.SysrepoConnection.return_value = conn_mock
- sys.modules['sysrepo'] = sysrepo_mock
+ sys.modules["sysrepo"] = sysrepo_mock
-if 'libyang' not in sys.modules:
+if "libyang" not in sys.modules:
try:
import libyang
except ImportError:
libyang_mock = MagicMock()
- sys.modules['libyang'] = libyang_mock
+ sys.modules["libyang"] = libyang_mock
# -----------------------------------------------------------------------------
# Shared Pytest Fixtures
# -----------------------------------------------------------------------------
+
@pytest.fixture(scope="session")
def flask_app():
"""Creates a minimal Flask app for testing with default configuration."""
from app import create_app
+
app = create_app()
app.config["DUMMY_MODE"] = True
- app.config.update({
- "TESTING": True,
- "SERVER_NAME": "localhost",
- "API_USERNAME": "admin",
- "API_PASSWORD": "password",
- "NRP_ENABLED": False,
- "PLANNER_ENABLED": False,
- "PCE_EXTERNAL": False,
- "DUMMY_MODE": True,
- "DUMP_TEMPLATES": False,
- "TFS_L2VPN_SUPPORT": False,
- "WEBUI_DEPLOY": False,
- "UPLOAD_TYPE": "WEBUI",
- "PLANNER_TYPE": "ENERGY",
- "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",
- })
+ app.config.update(
+ {
+ "TESTING": True,
+ "SERVER_NAME": "localhost",
+ "API_USERNAME": "admin",
+ "API_PASSWORD": "password",
+ "NRP_ENABLED": False,
+ "PLANNER_ENABLED": False,
+ "PCE_EXTERNAL": False,
+ "DUMMY_MODE": True,
+ "DUMP_TEMPLATES": False,
+ "TFS_L2VPN_SUPPORT": False,
+ "WEBUI_DEPLOY": False,
+ "UPLOAD_TYPE": "WEBUI",
+ "PLANNER_TYPE": "ENERGY",
+ "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
@@ -89,11 +90,8 @@ def auth_headers(flask_app):
"""Generates Basic Auth headers matching app config."""
username = flask_app.config["API_USERNAME"]
password = flask_app.config["API_PASSWORD"]
- token = base64.b64encode(f"{username}:{password}".encode('utf-8')).decode('utf-8')
- return {
- "Authorization": f"Basic {token}",
- "Content-Type": "application/json"
- }
+ token = base64.b64encode(f"{username}:{password}".encode()).decode("utf-8")
+ return {"Authorization": f"Basic {token}", "Content-Type": "application/json"}
@pytest.fixture
@@ -105,10 +103,10 @@ def temp_sqlite_db(tmp_path, monkeypatch):
monkeypatch.setattr("src.database.telemetry_client_db.DB_NAME", str(tmp_path / "test_telemetry.db"))
monkeypatch.setattr("src.database.alert_db.DB_NAME", str(tmp_path / "test_alert.db"))
+ from src.database.alert_db import init_db as init_alert
from src.database.db import init_db as init_slice
from src.database.service_db import init_db as init_service
from src.database.telemetry_client_db import init_db as init_telemetry
- from src.database.alert_db import init_db as init_alert
init_slice()
init_service()
@@ -129,13 +127,9 @@ def sample_ietf_intent():
"id": "qos1",
"slo-policy": {
"metric-bound": [
- {
- "metric-type": "one-way-bandwidth",
- "metric-unit": "kbps",
- "bound": 100000
- }
+ {"metric-type": "one-way-bandwidth", "metric-unit": "kbps", "bound": 100000}
]
- }
+ },
}
]
},
@@ -150,56 +144,22 @@ def sample_ietf_intent():
"node-id": "A",
"sdp-ip-address": "10.0.0.1",
"service-match-criteria": {
- "match-criterion": [
- {
- "match-type": [
- {
- "type": "vlan",
- "vlan": [100]
- }
- ]
- }
- ]
+ "match-criterion": [{"match-type": [{"type": "vlan", "vlan": [100]}]}]
},
- "attachment-circuits": {
- "attachment-circuit": [
- {
- "sdp-peering": {
- "peer-sap-id": "R1"
- }
- }
- ]
- }
+ "attachment-circuits": {"attachment-circuit": [{"sdp-peering": {"peer-sap-id": "R1"}}]},
},
{
"id": "sdp-2",
"node-id": "B",
"sdp-ip-address": "10.0.0.2",
"service-match-criteria": {
- "match-criterion": [
- {
- "match-type": [
- {
- "type": "vlan",
- "vlan": [100]
- }
- ]
- }
- ]
+ "match-criterion": [{"match-type": [{"type": "vlan", "vlan": [100]}]}]
},
- "attachment-circuits": {
- "attachment-circuit": [
- {
- "sdp-peering": {
- "peer-sap-id": "R2"
- }
- }
- ]
- }
- }
+ "attachment-circuits": {"attachment-circuit": [{"sdp-peering": {"peer-sap-id": "R2"}}]},
+ },
]
- }
+ },
}
- ]
+ ],
}
}
diff --git a/src/tests/test_api.py b/src/tests/test_api.py
index f030bb2d7fb6aba8d8877f2177409f0f67237868..2f255855dfcb3b8be6ceaafec5d3f1965151f906 100644
--- a/src/tests/test_api.py
+++ b/src/tests/test_api.py
@@ -15,41 +15,44 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
import json
-import pytest
import os
-from unittest.mock import patch, Mock, MagicMock
-from pathlib import Path
-from dotenv import load_dotenv
import sqlite3
import time
+from unittest.mock import MagicMock, patch
+
+import pytest
+from dotenv import load_dotenv
from flask import Flask
-from src.main import NSController
-from src.api.main import Api
+from src.api.main import Api
+from src.main import NSController
# Load environment variables
load_dotenv()
+
@pytest.fixture(scope="session")
def flask_app():
"""Creates a minimal Flask app for tests."""
app = Flask(__name__)
- app.config.update({
- "TESTING": True,
- "SERVER_NAME": "localhost",
- 'NRP_ENABLED': os.getenv('NRP_ENABLED', 'False').lower() == 'true',
- 'PLANNER_ENABLED': os.getenv('PLANNER_ENABLED', 'False').lower() == 'true',
- 'PCE_EXTERNAL': os.getenv('PCE_EXTERNAL', 'False').lower() == 'true',
- 'DUMMY_MODE': os.getenv('DUMMY_MODE', 'True').lower() == 'true',
- 'DUMP_TEMPLATES': os.getenv('DUMP_TEMPLATES', 'False').lower() == 'true',
- 'TFS_L2VPN_SUPPORT': os.getenv('TFS_L2VPN_SUPPORT', 'False').lower() == 'true',
- 'WEBUI_DEPLOY': os.getenv('WEBUI_DEPLOY', 'True').lower() == 'true',
- 'UPLOAD_TYPE': os.getenv('UPLOAD_TYPE', 'WEBUI'),
- 'PLANNER_TYPE': os.getenv('PLANNER_TYPE', 'ENERGY'),
- 'HRAT_IP' : os.getenv('HRAT_IP', '10.0.0.1'),
- 'OPTICAL_PLANNER_IP' : os.getenv('OPTICAL_PLANNER_IP', '10.0.0.1'),
- 'TFS_IP' : os.getenv('TFS_IP', '10.0.0.1')
- })
+ app.config.update(
+ {
+ "TESTING": True,
+ "SERVER_NAME": "localhost",
+ "NRP_ENABLED": os.getenv("NRP_ENABLED", "False").lower() == "true",
+ "PLANNER_ENABLED": os.getenv("PLANNER_ENABLED", "False").lower() == "true",
+ "PCE_EXTERNAL": os.getenv("PCE_EXTERNAL", "False").lower() == "true",
+ "DUMMY_MODE": True,
+ "DUMP_TEMPLATES": os.getenv("DUMP_TEMPLATES", "False").lower() == "true",
+ "TFS_L2VPN_SUPPORT": os.getenv("TFS_L2VPN_SUPPORT", "False").lower() == "true",
+ "WEBUI_DEPLOY": os.getenv("WEBUI_DEPLOY", "True").lower() == "true",
+ "UPLOAD_TYPE": os.getenv("UPLOAD_TYPE", "WEBUI"),
+ "PLANNER_TYPE": os.getenv("PLANNER_TYPE", "ENERGY"),
+ "HRAT_IP": os.getenv("HRAT_IP", "10.0.0.1"),
+ "OPTICAL_PLANNER_IP": os.getenv("OPTICAL_PLANNER_IP", "10.0.0.1"),
+ "TFS_IP": os.getenv("TFS_IP", "10.0.0.1"),
+ }
+ )
return app
@@ -59,11 +62,12 @@ def push_flask_context(flask_app):
with flask_app.app_context():
yield
+
@pytest.fixture
def temp_db(tmp_path):
"""Fixture to create and cleanup a test database using SQLite instead of JSON."""
test_db_name = str(tmp_path / "test_slice.db")
-
+
# Create database with proper schema
conn = sqlite3.connect(test_db_name)
cursor = conn.cursor()
@@ -76,9 +80,9 @@ def temp_db(tmp_path):
""")
conn.commit()
conn.close()
-
+
yield test_db_name
-
+
# Cleanup - properly close connections and remove file
try:
time.sleep(0.1)
@@ -97,15 +101,15 @@ def temp_db(tmp_path):
def env_variables():
"""Fixture to load and provide environment variables."""
env_vars = {
- 'NRP_ENABLED': os.getenv('NRP_ENABLED', 'False').lower() == 'true',
- 'PLANNER_ENABLED': os.getenv('PLANNER_ENABLED', 'False').lower() == 'true',
- 'PCE_EXTERNAL': os.getenv('PCE_EXTERNAL', 'False').lower() == 'true',
- 'DUMMY_MODE': os.getenv('DUMMY_MODE', 'True').lower() == 'true',
- 'DUMP_TEMPLATES': os.getenv('DUMP_TEMPLATES', 'False').lower() == 'true',
- 'TFS_L2VPN_SUPPORT': os.getenv('TFS_L2VPN_SUPPORT', 'False').lower() == 'true',
- 'WEBUI_DEPLOY': os.getenv('WEBUI_DEPLOY', 'True').lower() == 'true',
- 'UPLOAD_TYPE': os.getenv('UPLOAD_TYPE', 'WEBUI'),
- 'PLANNER_TYPE': os.getenv('PLANNER_TYPE', 'standard'),
+ "NRP_ENABLED": os.getenv("NRP_ENABLED", "False").lower() == "true",
+ "PLANNER_ENABLED": os.getenv("PLANNER_ENABLED", "False").lower() == "true",
+ "PCE_EXTERNAL": os.getenv("PCE_EXTERNAL", "False").lower() == "true",
+ "DUMMY_MODE": os.getenv("DUMMY_MODE", "True").lower() == "true",
+ "DUMP_TEMPLATES": os.getenv("DUMP_TEMPLATES", "False").lower() == "true",
+ "TFS_L2VPN_SUPPORT": os.getenv("TFS_L2VPN_SUPPORT", "False").lower() == "true",
+ "WEBUI_DEPLOY": os.getenv("WEBUI_DEPLOY", "True").lower() == "true",
+ "UPLOAD_TYPE": os.getenv("UPLOAD_TYPE", "WEBUI"),
+ "PLANNER_TYPE": os.getenv("PLANNER_TYPE", "standard"),
}
return env_vars
@@ -113,7 +117,7 @@ def env_variables():
@pytest.fixture
def controller_with_mocked_db(temp_db):
"""Creates an NSController with a mocked database."""
- with patch('src.database.db.DB_NAME', temp_db):
+ with patch("src.database.db.DB_NAME", temp_db):
yield NSController(controller_type="TFS")
@@ -127,14 +131,8 @@ def ietf_intent():
{
"id": "qos1",
"slo-policy": {
- "metric-bound": [
- {
- "metric-type": "one-way-bandwidth",
- "metric-unit": "kbps",
- "bound": 1000
- }
- ]
- }
+ "metric-bound": [{"metric-type": "one-way-bandwidth", "metric-unit": "kbps", "bound": 1000}]
+ },
}
]
},
@@ -147,51 +145,17 @@ def ietf_intent():
"sdp-ip-address": "10.0.0.1",
"node-id": "node1",
"service-match-criteria": {
- "match-criterion": [
- {
- "match-type": [
- {
- "type": "vlan",
- "vlan": [100]
- }
- ]
- }
- ]
- },
- "attachment-circuits": {
- "attachment-circuit": [
- {
- "sdp-peering": {
- "peer-sap-id": "R1"
- }
- }
- ]
+ "match-criterion": [{"match-type": [{"type": "vlan", "vlan": [100]}]}]
},
+ "attachment-circuits": {"attachment-circuit": [{"sdp-peering": {"peer-sap-id": "R1"}}]},
},
{
"sdp-ip-address": "10.0.0.2",
"node-id": "node2",
"service-match-criteria": {
- "match-criterion": [
- {
- "match-type": [
- {
- "type": "vlan",
- "vlan": [100]
- }
- ]
- }
- ]
- },
- "attachment-circuits": {
- "attachment-circuit": [
- {
- "sdp-peering": {
- "peer-sap-id": "R2"
- }
- }
- ]
+ "match-criterion": [{"match-type": [{"type": "vlan", "vlan": [100]}]}]
},
+ "attachment-circuits": {"attachment-circuit": [{"sdp-peering": {"peer-sap-id": "R2"}}]},
},
]
},
@@ -204,121 +168,113 @@ def ietf_intent():
class TestBasicApiOperations:
"""Tests for basic API operations."""
-
+
def test_get_flows_empty(self, controller_with_mocked_db):
- """Should return an error when there are no slices."""
+ """Should return 200 with an empty list when there are no slices."""
result, code = Api(controller_with_mocked_db).get_flows()
- assert code == 404
- assert result["success"] is False
- assert result["data"] is None
-
+ assert code == 200
+ assert result["success"] is True
+ assert result["data"] == []
+
def test_add_flow_success(self, controller_with_mocked_db, ietf_intent):
"""Should successfully add a flow."""
- with patch('src.database.db.save_data') as mock_save:
+ with patch("src.database.db.save_data") as mock_save:
result, code = Api(controller_with_mocked_db).add_flow(ietf_intent)
assert code == 201
assert result["success"] is True
assert "slices" in result["data"]
-
+
def test_add_and_get_flow(self, controller_with_mocked_db, ietf_intent):
"""Should add a flow and then retrieve it."""
- with patch('src.database.db.save_data') as mock_save, \
- patch('src.database.db.get_all_data') as mock_get_all:
-
+ with patch("src.database.db.save_data") as mock_save, patch("src.database.db.get_all_data") as mock_get_all:
Api(controller_with_mocked_db).add_flow(ietf_intent)
-
- mock_get_all.return_value = [
- {
- "slice_id": "slice-test-1",
- "intent": ietf_intent,
- "controller": "TFS"
- }
- ]
-
+
+ mock_get_all.return_value = [{"slice_id": "slice-test-1", "intent": ietf_intent, "controller": "TFS"}]
+
flows, code = Api(controller_with_mocked_db).get_flows()
assert code == 200
- assert any(s["slice_id"] == "slice-test-1" for s in flows)
-
+ assert flows["success"] is True
+ assert any(s["slice_id"] == "slice-test-1" for s in flows["data"])
+
def test_modify_flow_success(self, controller_with_mocked_db, ietf_intent):
"""Should successfully modify an existing flow."""
- with patch('src.database.db.update_data') as mock_update:
+ with patch("src.database.db.update_data") as mock_update:
Api(controller_with_mocked_db).add_flow(ietf_intent)
new_intent = ietf_intent.copy()
- new_intent["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["id"] = "qos2"
-
+ new_intent["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0][
+ "id"
+ ] = "qos2"
+
result, code = Api(controller_with_mocked_db).modify_flow("slice-test-1", new_intent)
print(result)
assert code == 200
assert result["success"] is True
-
+
def test_delete_specific_flow_success(self, controller_with_mocked_db, ietf_intent):
"""Should delete a specific flow."""
- with patch('src.database.db.delete_data') as mock_delete:
+ with patch("src.database.db.delete_data") as mock_delete:
Api(controller_with_mocked_db).add_flow(ietf_intent)
result, code = Api(controller_with_mocked_db).delete_flows("slice-test-1")
assert code == 204
assert result == {}
-
+
def test_delete_all_flows_success(self, controller_with_mocked_db):
"""Should delete all flows."""
- with patch('src.database.db.delete_all_data') as mock_delete_all:
+ with patch("src.database.db.delete_all_data") as mock_delete_all:
result, code = Api(controller_with_mocked_db).delete_flows()
assert code == 204
assert result == {}
-
+
def test_get_specific_flow(self, controller_with_mocked_db, ietf_intent):
"""Should retrieve a specific flow."""
- with patch('src.database.db.get_data') as mock_get:
+ with patch("src.database.db.get_data") as mock_get:
Api(controller_with_mocked_db).add_flow(ietf_intent)
- mock_get.return_value = {
- "slice_id": "slice-test-1",
- "intent": ietf_intent,
- "controller": "TFS"
- }
-
+ mock_get.return_value = {"slice_id": "slice-test-1", "intent": ietf_intent, "controller": "TFS"}
+
result, code = Api(controller_with_mocked_db).get_flows("slice-test-1")
assert code == 200
- assert result["slice_id"] == "slice-test-1"
+ assert result["success"] is True
+ assert result["data"]["slice_id"] == "slice-test-1"
class TestErrorHandling:
"""Tests for error handling."""
-
+
def test_add_flow_with_empty_intent(self, controller_with_mocked_db):
"""Should fail if an empty intent is provided."""
result, code = Api(controller_with_mocked_db).add_flow({})
assert code in (400, 404, 500)
assert result["success"] is False
-
+
def test_add_flow_with_none(self, controller_with_mocked_db):
"""Should fail if None is provided as intent."""
result, code = Api(controller_with_mocked_db).add_flow(None)
assert code in (400, 500)
assert result["success"] is False
-
+
def test_get_nonexistent_slice(self, controller_with_mocked_db):
"""Should return 404 if a nonexistent slice is requested."""
- with patch('src.database.db.get_data') as mock_get:
+ with patch("src.database.db.get_data") as mock_get:
mock_get.side_effect = ValueError("No slice found")
-
+
result, code = Api(controller_with_mocked_db).get_flows("slice-does-not-exist")
assert code == 404
assert result["success"] is False
-
+
def test_modify_nonexistent_flow(self, controller_with_mocked_db, ietf_intent):
"""Should fail if attempting to modify a nonexistent flow."""
- with patch('src.database.db.update_data') as mock_update:
+ with patch("src.database.db.update_data") as mock_update:
mock_update.side_effect = ValueError("No slice found")
-
+
result, code = Api(controller_with_mocked_db).modify_flow("nonexistent", ietf_intent)
assert code == 404
assert result["success"] is False
-
+
def test_delete_nonexistent_flow(self, controller_with_mocked_db):
"""Should fail if attempting to delete a nonexistent flow."""
- with patch('src.database.db.delete_data') as mock_delete:
+ with patch("src.database.db.delete_data") as mock_delete:
mock_delete.side_effect = ValueError("No slice found")
-
+
result, code = Api(controller_with_mocked_db).delete_flows("nonexistent")
assert code == 404
assert result["success"] is False
@@ -363,7 +319,8 @@ class TestClientAndSubscriptionOperations:
# Update subscription
res_upd, code_upd = api.update_subscription("client-sub-1", "slice-1", frequency=20)
- assert code_upd == 201
+ assert code_upd == 200
+ assert res_upd["data"]["slice_id"] == "slice-1"
# Delete all subscriptions for client
res_del_all, code_del_all = api.delete_subscriptions("client-sub-1")
@@ -388,7 +345,7 @@ class TestAlertOperations:
"tapi-notification:notification": {
"uuid": "alert-100",
"notification-type": "ALARM",
- "additional-info": {"service-id": "slice-1"}
+ "additional-info": {"service-id": "slice-1"},
}
}
]
@@ -415,45 +372,50 @@ class TestAlertOperations:
def test_receive_alert_endpoint_swap(self, controller_with_mocked_db, sample_ietf_intent, temp_sqlite_db):
"""Test receive_alert when swapping receiver endpoints."""
api = Api(controller_with_mocked_db)
-
+
# Setup intent with P2MP sender and receivers
intent_p2mp = {
"ietf-network-slice-service:network-slice-services": {
- "slice-service": [{
- "id": "slice-p2mp-1",
- "sdps": {
- "sdp": [
- {"id": "sdp-sender"},
- {"id": "sdp-rec-1"},
- {"id": "sdp-rec-2"},
- {"id": "sdp-alt-1"}
- ]
- },
- "connection-groups": {
- "connection-group": [{
- "id": "cg-1",
- "connectivity-construct": [{
- "id": "cc-1",
- "p2mp-sender-sdp": "sdp-sender",
- "p2mp-receiver-sdp": ["sdp-rec-1", "sdp-rec-2"]
- }]
- }]
+ "slice-service": [
+ {
+ "id": "slice-p2mp-1",
+ "sdps": {
+ "sdp": [{"id": "sdp-sender"}, {"id": "sdp-rec-1"}, {"id": "sdp-rec-2"}, {"id": "sdp-alt-1"}]
+ },
+ "connection-groups": {
+ "connection-group": [
+ {
+ "id": "cg-1",
+ "connectivity-construct": [
+ {
+ "id": "cc-1",
+ "p2mp-sender-sdp": "sdp-sender",
+ "p2mp-receiver-sdp": ["sdp-rec-1", "sdp-rec-2"],
+ }
+ ],
+ }
+ ]
+ },
}
- }]
+ ]
}
}
-
- with patch("src.database.db.get_data") as mock_get_db, \
- patch.object(api.slice_service, "nsc", return_value=True):
+
+ with (
+ patch("src.database.db.get_data") as mock_get_db,
+ patch.object(api.slice_service, "nsc", return_value=True),
+ ):
mock_get_db.return_value = {"slice_id": "slice-p2mp-1", "intent": intent_p2mp}
alert_payload = {
- "tapi-notification:notification-context": [{
- "tapi-notification:notification": {
- "uuid": "alert-swap-1",
- "notification-type": "ALARM",
- "additional-info": {"service-id": "slice-p2mp-1"}
+ "tapi-notification:notification-context": [
+ {
+ "tapi-notification:notification": {
+ "uuid": "alert-swap-1",
+ "notification-type": "ALARM",
+ "additional-info": {"service-id": "slice-p2mp-1"},
+ }
}
- }]
+ ]
}
res, code = api.receive_alert(alert_payload)
assert code in [200, 201]
@@ -465,9 +427,11 @@ class TestRestconfDatastoreOperations:
def test_slo_sle_template_crud(self, controller_with_mocked_db):
api = Api(controller_with_mocked_db)
tmpl = {"id": "tmpl-test-1", "slo-policy": {}}
-
- with patch("src.api.main.get_data_store", return_value=None), \
- patch("src.api.main.create_data_store", return_value=True):
+
+ with (
+ patch("src.api.main.get_data_store", return_value=None),
+ patch("src.api.main.create_data_store", return_value=True),
+ ):
res_add, code_add = api.add_slo_sle_template(tmpl.copy())
assert code_add == 201
@@ -479,8 +443,10 @@ class TestRestconfDatastoreOperations:
res_get_err, code_get_err = api.get_slo_sle_templates("nonexistent-tmpl")
assert code_get_err == 404
- with patch("src.api.main.get_data_store", return_value={"tmpl-test-1": tmpl}), \
- patch("src.api.main.delete_data_store", return_value=True):
+ with (
+ patch("src.api.main.get_data_store", return_value={"tmpl-test-1": tmpl}),
+ patch("src.api.main.delete_data_store", return_value=True),
+ ):
res_del, code_del = api.delete_slo_sle_templates("tmpl-test-1")
assert code_del == 204
@@ -488,9 +454,11 @@ class TestRestconfDatastoreOperations:
api = Api(controller_with_mocked_db)
slice_intent = {"id": "slice-svc-1", "service-slo-sle-policy": {"bound": 10}}
- with patch("src.api.main.get_data_store", return_value=None), \
- patch.object(api.slice_service, "nsc", return_value=True), \
- patch("src.api.main.create_data_store", return_value=True):
+ with (
+ patch("src.api.main.get_data_store", return_value=None),
+ patch.object(api.slice_service, "nsc", return_value=True),
+ patch("src.api.main.create_data_store", return_value=True),
+ ):
res_add, code_add = api.add_slice_service(slice_intent.copy())
assert code_add == 201
@@ -498,8 +466,10 @@ class TestRestconfDatastoreOperations:
res_get, code_get = api.get_slice_services("slice-svc-1")
assert code_get == 200
- with patch("src.api.main.get_data_store", return_value={"slice-svc-1": slice_intent}), \
- patch("src.api.main.delete_data_store", return_value=True):
+ with (
+ patch("src.api.main.get_data_store", return_value={"slice-svc-1": slice_intent}),
+ patch("src.api.main.delete_data_store", return_value=True),
+ ):
res_del, code_del = api.delete_slice_services("slice-svc-1")
assert code_del == 204
@@ -507,8 +477,10 @@ class TestRestconfDatastoreOperations:
api = Api(controller_with_mocked_db)
sdp_data = {"id": "sdp-10", "node-id": "N1"}
- with patch("src.api.main.get_data_store", return_value=None), \
- patch("src.api.main.create_data_store", return_value=True):
+ with (
+ patch("src.api.main.get_data_store", return_value=None),
+ patch("src.api.main.create_data_store", return_value=True),
+ ):
res_add, code_add = api.add_sdp("slice-1", sdp_data.copy())
assert code_add == 201
@@ -516,8 +488,10 @@ class TestRestconfDatastoreOperations:
res_get, code_get = api.get_sdps("slice-1", "sdp-10")
assert code_get == 200
- with patch("src.api.main.get_data_store", return_value={"sdp-10": sdp_data}), \
- patch("src.api.main.delete_data_store", return_value=True):
+ with (
+ patch("src.api.main.get_data_store", return_value={"sdp-10": sdp_data}),
+ patch("src.api.main.delete_data_store", return_value=True),
+ ):
res_del, code_del = api.delete_sdps("slice-1", "sdp-10")
assert code_del == 204
@@ -558,14 +532,18 @@ class TestApiFullCoverage:
res_404, code_404 = api.update_network_slice_service(sample_ietf_intent)
assert code_404 == 404
- with patch("src.api.main.get_data_store", return_value={"existing": 1}), \
- patch.object(api.slice_service, "nsc", return_value=None):
+ with (
+ patch("src.api.main.get_data_store", return_value={"existing": 1}),
+ patch.object(api.slice_service, "nsc", return_value=None),
+ ):
res_500, code_500 = api.update_network_slice_service(sample_ietf_intent)
assert code_500 == 500
- with patch("src.api.main.get_data_store", return_value={"existing": 1}), \
- patch.object(api.slice_service, "nsc", return_value=True), \
- patch("src.api.main.update_data_store", return_value=True):
+ with (
+ patch("src.api.main.get_data_store", return_value={"existing": 1}),
+ patch.object(api.slice_service, "nsc", return_value=True),
+ patch("src.api.main.update_data_store", return_value=True),
+ ):
res_200, code_200 = api.update_network_slice_service(sample_ietf_intent)
assert code_200 == 200
@@ -584,26 +562,15 @@ class TestApiFullCoverage:
assert code_400 == 400
# Success updating referencing slice
- slices_data = {
- "network-slice-services": {
- "slice-service": [{
- "id": "slice-1",
- "slo-sle-template": "tmpl-1"
- }]
- }
- }
+ slices_data = {"network-slice-services": {"slice-service": [{"id": "slice-1", "slo-sle-template": "tmpl-1"}]}}
existing_template = {
- "network-slice-services": {
- "slo-sle-templates": {
- "slo-sle-template": {
- "tmpl-1": {"id": "tmpl-1"}
- }
- }
- }
+ "network-slice-services": {"slo-sle-templates": {"slo-sle-template": {"tmpl-1": {"id": "tmpl-1"}}}}
}
- with patch("src.api.main.get_data_store", side_effect=[existing_template, slices_data]), \
- patch.object(api.slice_service, "nsc", return_value=True), \
- patch("src.api.main.update_data_store", return_value=True):
+ with (
+ patch("src.api.main.get_data_store", side_effect=[existing_template, slices_data]),
+ patch.object(api.slice_service, "nsc", return_value=True),
+ patch("src.api.main.update_data_store", return_value=True),
+ ):
res_200, code_200 = api.update_slo_sle_template("tmpl-1", tmpl.copy())
assert code_200 == 200
@@ -628,9 +595,11 @@ class TestApiFullCoverage:
assert code_tmpl_404 == 404
# Success update
- with patch("src.api.main.get_data_store", return_value={"slice-1": intent}), \
- patch.object(api.slice_service, "nsc", return_value=True), \
- patch("src.api.main.update_data_store", return_value=True):
+ with (
+ patch("src.api.main.get_data_store", return_value={"slice-1": intent}),
+ patch.object(api.slice_service, "nsc", return_value=True),
+ patch("src.api.main.update_data_store", return_value=True),
+ ):
res_200, code_200 = api.update_slice_service("slice-1", intent.copy())
assert code_200 == 200
@@ -654,8 +623,10 @@ class TestApiFullCoverage:
assert code_400 == 400
# Success
- with patch("src.api.main.get_data_store", return_value={"found": 1}), \
- patch("src.api.main.update_data_store", return_value=True):
+ with (
+ patch("src.api.main.get_data_store", return_value={"found": 1}),
+ patch("src.api.main.update_data_store", return_value=True),
+ ):
res_200, code_200 = api.update_sdp("slice-1", "sdp-1", sdp.copy())
assert code_200 == 200
@@ -667,19 +638,25 @@ class TestApiFullCoverage:
slice_data = {
"network-slice-services": {
- "slice-service": [{
- "id": "slice-1",
- "service-tags": {"tag-type": {"ietf-network-slice-service:service": {"tag-type-value": ["L2"]}}}
- }]
+ "slice-service": [
+ {
+ "id": "slice-1",
+ "service-tags": {
+ "tag-type": {"ietf-network-slice-service:service": {"tag-type-value": ["L2"]}}
+ },
+ }
+ ]
}
}
- with flask_app.app_context(), \
- patch("src.api.main.get_data_store", return_value=slice_data), \
- patch("src.api.main.get_data_by_slice_id", return_value=[{"service_id": "svc-1"}]), \
- patch("src.api.main.tfs_connector"), \
- patch("src.api.main.delete_by_slice_id"), \
- patch.object(api.slice_service, "tfs_l2vpn_delete", create=True), \
- patch("src.api.main.delete_data_store", return_value=True):
+ with (
+ flask_app.app_context(),
+ patch("src.api.main.get_data_store", return_value=slice_data),
+ patch("src.api.main.get_data_by_slice_id", return_value=[{"service_id": "svc-1"}]),
+ patch("src.api.main.tfs_connector"),
+ patch("src.api.main.delete_by_slice_id"),
+ patch.object(api.slice_service, "tfs_l2vpn_delete", create=True),
+ patch("src.api.main.delete_data_store", return_value=True),
+ ):
res_del, code_del = api.delete_network_slice_services()
assert code_del == 204
@@ -694,18 +671,22 @@ class TestApiFullCoverage:
"slice-service": {
"slice-1": {
"id": "slice-1",
- "service-tags": {"tag-type": {"ietf-network-slice-service:service": {"tag-type-value": ["L2"]}}}
+ "service-tags": {
+ "tag-type": {"ietf-network-slice-service:service": {"tag-type-value": ["L2"]}}
+ },
}
}
}
}
- with flask_app.app_context(), \
- patch("src.api.main.get_data_store", return_value=slice_single), \
- patch("src.api.main.get_data_by_slice_id", return_value=[{"service_id": "svc-1"}]), \
- patch("src.api.main.tfs_connector"), \
- patch("src.api.main.delete_by_slice_id"), \
- patch("src.api.main.tfs_l2vpn_delete"), \
- patch("src.api.main.delete_data_store", return_value=True):
+ with (
+ flask_app.app_context(),
+ patch("src.api.main.get_data_store", return_value=slice_single),
+ patch("src.api.main.get_data_by_slice_id", return_value=[{"service_id": "svc-1"}]),
+ patch("src.api.main.tfs_connector"),
+ patch("src.api.main.delete_by_slice_id"),
+ patch("src.api.main.tfs_l2vpn_delete"),
+ patch("src.api.main.delete_data_store", return_value=True),
+ ):
res_del, code_del = api.delete_slice_services("slice-1")
assert code_del == 204
@@ -723,8 +704,10 @@ class TestApiFullCoverage:
assert code_sdp_404 == 404
# Delete all SDPs success
- with patch("src.api.main.get_data_store", return_value={"slice": 1}), \
- patch("src.api.main.delete_data_store", return_value=True):
+ with (
+ patch("src.api.main.get_data_store", return_value={"slice": 1}),
+ patch("src.api.main.delete_data_store", return_value=True),
+ ):
res_all, code_all = api.delete_sdps("slice-1")
assert code_all == 204
@@ -757,13 +740,11 @@ class TestApiFullCoverage:
def test_nsc_full_pipeline(self, controller_with_mocked_db, sample_ietf_intent, flask_app):
api = Api(controller_with_mocked_db)
- with flask_app.app_context(), \
- patch.object(api.slice_service, "nsc", return_value={"status": "ok"}):
+ with flask_app.app_context(), patch.object(api.slice_service, "nsc", return_value={"status": "ok"}):
res, code = api.add_flow(sample_ietf_intent)
assert code == 201
- with flask_app.app_context(), \
- patch.object(api.slice_service, "nsc", side_effect=Exception("NSC failure")):
+ with flask_app.app_context(), patch.object(api.slice_service, "nsc", side_effect=Exception("NSC failure")):
res_err, code_err = api.add_flow(sample_ietf_intent)
assert code_err == 500
@@ -783,15 +764,18 @@ class TestApiFullCoverage:
with patch("src.api.main.get_all_data", return_value=[]):
res_none, code_none = api.get_flows()
- assert code_none == 404
-
- with flask_app.app_context(), \
- patch("src.api.main.get_data", return_value=slices[0]), \
- patch("src.api.main.tfs_connector"), \
- patch("src.api.main.delete_data"), \
- patch("src.api.main.get_all_data", return_value=slices), \
- patch("src.api.main.delete_all_data"), \
- patch("src.api.main.tfs_l2vpn_delete"):
+ assert code_none == 200
+ assert res_none["data"] == []
+
+ with (
+ flask_app.app_context(),
+ patch("src.api.main.get_data", return_value=slices[0]),
+ patch("src.api.main.tfs_connector"),
+ patch("src.api.main.delete_data"),
+ patch("src.api.main.get_all_data", return_value=slices),
+ patch("src.api.main.delete_all_data"),
+ patch("src.api.main.tfs_l2vpn_delete"),
+ ):
res_del_single, code_del_single = api.delete_flows("s-1")
assert code_del_single == 204
@@ -804,11 +788,7 @@ class TestApiExtendedCoverage:
def test_receive_alert_missing_uuid(self, controller_with_mocked_db):
api = Api(controller_with_mocked_db)
- alert_no_uuid = {
- "tapi-notification:notification-context": [
- {"tapi-notification:notification": {}}
- ]
- }
+ alert_no_uuid = {"tapi-notification:notification-context": [{"tapi-notification:notification": {}}]}
res, code = api.receive_alert(alert_no_uuid)
assert code == 400
@@ -819,7 +799,7 @@ class TestApiExtendedCoverage:
{
"tapi-notification:notification": {
"uuid": "alert-uuid-1",
- "additional-info": {"service-id": "service-p2mp"}
+ "additional-info": {"service-id": "service-p2mp"},
}
}
]
@@ -830,30 +810,27 @@ class TestApiExtendedCoverage:
"slice-service": [
{
"id": "service-p2mp",
- "sdps": {
- "sdp": [{"id": "sdp-1"}, {"id": "sdp-2"}, {"id": "sdp-3"}, {"id": "sdp-4"}]
- },
+ "sdps": {"sdp": [{"id": "sdp-1"}, {"id": "sdp-2"}, {"id": "sdp-3"}, {"id": "sdp-4"}]},
"connection-groups": {
"connection-group": [
{
"connectivity-construct": [
- {
- "p2mp-sender-sdp": "sdp-1",
- "p2mp-receiver-sdp": ["sdp-2", "sdp-3"]
- }
+ {"p2mp-sender-sdp": "sdp-1", "p2mp-receiver-sdp": ["sdp-2", "sdp-3"]}
]
}
]
- }
+ },
}
]
}
}
- with patch("src.api.main.alert_db.save_alert"), \
- patch("src.database.db.get_slice_id_by_subscription", return_value="slice-p2mp"), \
- patch("src.database.db.get_data", return_value={"slice_id": "slice-p2mp", "intent": slice_intent}), \
- patch.object(api.slice_service, "nsc", return_value=True):
+ with (
+ patch("src.api.main.alert_db.save_alert"),
+ patch("src.database.db.get_slice_id_by_subscription", return_value="slice-p2mp"),
+ patch("src.database.db.get_data", return_value={"slice_id": "slice-p2mp", "intent": slice_intent}),
+ patch.object(api.slice_service, "nsc", return_value=True),
+ ):
res, code = api.receive_alert(alert_data)
assert code == 201
@@ -861,38 +838,35 @@ class TestApiExtendedCoverage:
api = Api(controller_with_mocked_db)
alert_data = {
"tapi-notification:notification-context": [
- {
- "tapi-notification:notification": {
- "uuid": "alert-uuid-fallback"
- }
- }
+ {"tapi-notification:notification": {"uuid": "alert-uuid-fallback"}}
]
}
- fake_intent = {
- "ietf-network-slice-service:network-slice-services": {
- "slice-service": []
- }
- }
+ fake_intent = {"ietf-network-slice-service:network-slice-services": {"slice-service": []}}
fallback_file = tmp_path / "intent.json"
fallback_file.write_text(json.dumps(fake_intent))
monkeypatch.setattr("os.path.exists", lambda p: True if p == "/home/llmserver/tfs-nsc/intent.json" else False)
- with patch("src.api.main.alert_db.save_alert"), \
- patch("src.database.db.get_slice_id_by_subscription", return_value=None), \
- patch("src.database.db.get_all_data", return_value=[]), \
- patch("builtins.open", MagicMock(return_value=MagicMock(__enter__=lambda s: MagicMock(read=lambda: json.dumps(fake_intent))))):
+ with (
+ patch("src.api.main.alert_db.save_alert"),
+ patch("src.database.db.get_slice_id_by_subscription", return_value=None),
+ patch("src.database.db.get_all_data", return_value=[]),
+ patch(
+ "builtins.open",
+ MagicMock(return_value=MagicMock(__enter__=lambda s: MagicMock(read=lambda: json.dumps(fake_intent)))),
+ ),
+ ):
res, code = api.receive_alert(alert_data)
assert code == 201
def test_add_network_slice_service_branches(self, controller_with_mocked_db, sample_ietf_intent):
api = Api(controller_with_mocked_db)
- # RuntimeError -> 200
+ # RuntimeError -> 422
with patch.object(api.slice_service, "nsc", side_effect=RuntimeError("No content")):
res_rt, code_rt = api.add_network_slice_service(sample_ietf_intent)
- assert code_rt == 200
+ assert code_rt == 422
# Exception -> 500
with patch.object(api.slice_service, "nsc", side_effect=Exception("Uncaught")):
@@ -921,13 +895,12 @@ class TestApiExtendedCoverage:
assert code_400 == 400
# With service-slo-sle-policy
- intent_policy = {
- "id": "slice-policy",
- "service-slo-sle-policy": {"bound": 10}
- }
- with patch("src.api.main.get_data_store", return_value=None), \
- patch.object(api.slice_service, "nsc", return_value=True), \
- patch("src.api.main.create_data_store"):
+ intent_policy = {"id": "slice-policy", "service-slo-sle-policy": {"bound": 10}}
+ with (
+ patch("src.api.main.get_data_store", return_value=None),
+ patch.object(api.slice_service, "nsc", return_value=True),
+ patch("src.api.main.create_data_store"),
+ ):
res_201, code_201 = api.add_slice_service(intent_policy)
assert code_201 == 201
@@ -1000,13 +973,18 @@ class TestApiRequestedMethodsCoverage:
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)):
+ 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)):
+ 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
@@ -1043,15 +1021,14 @@ class TestApiRequestedMethodsCoverage:
api = Api(controller_with_mocked_db)
# Success
- with patch.object(api.slice_service, "nsc", return_value={"ok": True}), \
- patch("src.api.main.create_data_store"):
+ with patch.object(api.slice_service, "nsc", return_value={"ok": True}), patch("src.api.main.create_data_store"):
res, code = api.add_network_slice_service(sample_ietf_intent)
assert code == 201
- # RuntimeError -> 200
+ # RuntimeError -> 422
with patch.object(api.slice_service, "nsc", side_effect=RuntimeError("No content")):
res_rt, code_rt = api.add_network_slice_service(sample_ietf_intent)
- assert code_rt == 200
+ assert code_rt == 422
# Exception -> 500
with patch.object(api.slice_service, "nsc", side_effect=Exception("Uncaught")):
@@ -1064,32 +1041,32 @@ class TestApiRequestedMethodsCoverage:
# Success with referenced slo-sle-template
intent_tmpl = {"id": "slice-tmpl", "slo-sle-template": "tmpl-1"}
tmpl_store_data = {
- "network-slice-services": {
- "slo-sle-templates": {
- "slo-sle-template": {
- "tmpl-1": {"id": "tmpl-1"}
- }
- }
- }
+ "network-slice-services": {"slo-sle-templates": {"slo-sle-template": {"tmpl-1": {"id": "tmpl-1"}}}}
}
- with patch("src.api.main.get_data_store", side_effect=[None, tmpl_store_data]), \
- patch("src.api.main.normalize_libyang_data", side_effect=lambda x: x), \
- patch.object(api.slice_service, "nsc", return_value={"status": "created"}), \
- patch("src.api.main.create_data_store"):
+ with (
+ patch("src.api.main.get_data_store", side_effect=[None, tmpl_store_data]),
+ patch("src.api.main.normalize_libyang_data", side_effect=lambda x: x),
+ patch.object(api.slice_service, "nsc", return_value={"status": "created"}),
+ patch("src.api.main.create_data_store"),
+ ):
res, code = api.add_slice_service(intent_tmpl)
assert code == 201
- # RuntimeError -> 200
- with patch("src.api.main.get_data_store", side_effect=[None, tmpl_store_data]), \
- patch("src.api.main.normalize_libyang_data", side_effect=lambda x: x), \
- patch.object(api.slice_service, "nsc", side_effect=RuntimeError("No content")):
+ # RuntimeError -> 422
+ with (
+ patch("src.api.main.get_data_store", side_effect=[None, tmpl_store_data]),
+ patch("src.api.main.normalize_libyang_data", side_effect=lambda x: x),
+ patch.object(api.slice_service, "nsc", side_effect=RuntimeError("No content")),
+ ):
res_rt, code_rt = api.add_slice_service(intent_tmpl)
- assert code_rt == 200
+ assert code_rt == 422
# Exception -> 500
- with patch("src.api.main.get_data_store", side_effect=[None, tmpl_store_data]), \
- patch("src.api.main.normalize_libyang_data", side_effect=lambda x: x), \
- patch.object(api.slice_service, "nsc", side_effect=Exception("Err")):
+ with (
+ patch("src.api.main.get_data_store", side_effect=[None, tmpl_store_data]),
+ patch("src.api.main.normalize_libyang_data", side_effect=lambda x: x),
+ patch.object(api.slice_service, "nsc", side_effect=Exception("Err")),
+ ):
res_500, code_500 = api.add_slice_service(intent_tmpl)
assert code_500 == 500
@@ -1099,48 +1076,52 @@ class TestApiRequestedMethodsCoverage:
intent = {"id": "slice-1", "slo-sle-template": "tmpl-1"}
existing_slice = {"slice-1": {"id": "slice-1"}}
tmpl_data = {
- "network-slice-services": {
- "slo-sle-templates": {
- "slo-sle-template": {
- "tmpl-1": {"id": "tmpl-1"}
- }
- }
- }
+ "network-slice-services": {"slo-sle-templates": {"slo-sle-template": {"tmpl-1": {"id": "tmpl-1"}}}}
}
# Success update with referenced template
- with patch("src.api.main.get_data_store", side_effect=[existing_slice, tmpl_data]), \
- patch("src.api.main.normalize_libyang_data", side_effect=lambda x: x), \
- patch.object(api.slice_service, "nsc", return_value={"updated": True}), \
- patch("src.api.main.update_data_store"):
+ with (
+ patch("src.api.main.get_data_store", side_effect=[existing_slice, tmpl_data]),
+ patch("src.api.main.normalize_libyang_data", side_effect=lambda x: x),
+ patch.object(api.slice_service, "nsc", return_value={"updated": True}),
+ patch("src.api.main.update_data_store"),
+ ):
res, code = api.update_slice_service("slice-1", intent.copy())
assert code == 200
# nsc returns None -> 500
- with patch("src.api.main.get_data_store", side_effect=[existing_slice, tmpl_data]), \
- patch("src.api.main.normalize_libyang_data", side_effect=lambda x: x), \
- patch.object(api.slice_service, "nsc", return_value=None):
+ with (
+ patch("src.api.main.get_data_store", side_effect=[existing_slice, tmpl_data]),
+ patch("src.api.main.normalize_libyang_data", side_effect=lambda x: x),
+ patch.object(api.slice_service, "nsc", return_value=None),
+ ):
res_500, code_500 = api.update_slice_service("slice-1", intent.copy())
assert code_500 == 500
- # RuntimeError -> 200
- with patch("src.api.main.get_data_store", side_effect=[existing_slice, tmpl_data]), \
- patch("src.api.main.normalize_libyang_data", side_effect=lambda x: x), \
- patch.object(api.slice_service, "nsc", side_effect=RuntimeError("No service")):
+ # RuntimeError -> 422
+ with (
+ patch("src.api.main.get_data_store", side_effect=[existing_slice, tmpl_data]),
+ patch("src.api.main.normalize_libyang_data", side_effect=lambda x: x),
+ patch.object(api.slice_service, "nsc", side_effect=RuntimeError("No service")),
+ ):
res_rt, code_rt = api.update_slice_service("slice-1", intent.copy())
- assert code_rt == 200
+ assert code_rt == 422
# ValueError -> 404
- with patch("src.api.main.get_data_store", side_effect=[existing_slice, tmpl_data]), \
- patch("src.api.main.normalize_libyang_data", side_effect=lambda x: x), \
- patch.object(api.slice_service, "nsc", side_effect=ValueError("Val err")):
+ with (
+ patch("src.api.main.get_data_store", side_effect=[existing_slice, tmpl_data]),
+ patch("src.api.main.normalize_libyang_data", side_effect=lambda x: x),
+ patch.object(api.slice_service, "nsc", side_effect=ValueError("Val err")),
+ ):
res_ve, code_ve = api.update_slice_service("slice-1", intent.copy())
assert code_ve == 404
# Exception -> 500
- with patch("src.api.main.get_data_store", side_effect=[existing_slice, tmpl_data]), \
- patch("src.api.main.normalize_libyang_data", side_effect=lambda x: x), \
- patch.object(api.slice_service, "nsc", side_effect=Exception("Gen err")):
+ with (
+ patch("src.api.main.get_data_store", side_effect=[existing_slice, tmpl_data]),
+ patch("src.api.main.normalize_libyang_data", side_effect=lambda x: x),
+ patch.object(api.slice_service, "nsc", side_effect=Exception("Gen err")),
+ ):
res_ex, code_ex = api.update_slice_service("slice-1", intent.copy())
assert code_ex == 500
@@ -1156,18 +1137,20 @@ class TestApiRequestedMethodsCoverage:
"slice-service": {
"slice-1": {
"id": "slice-1",
- "service-tags": {"tag-type": {"ietf-network-slice-service:service": {"tag-type-value": []}}}
+ "service-tags": {"tag-type": {"ietf-network-slice-service:service": {"tag-type-value": []}}},
}
}
}
}
- with flask_app.app_context(), \
- patch("src.api.main.get_data_store", return_value=existing_single), \
- patch("src.api.main.get_data_by_slice_id", return_value=[{"service_id": "svc-1"}]), \
- patch("src.api.main.tfs_connector") as mock_conn_cls, \
- patch("src.api.main.delete_by_slice_id"), \
- patch("src.api.main.tfs_l2vpn_delete"), \
- patch("src.api.main.delete_data_store"):
+ with (
+ flask_app.app_context(),
+ patch("src.api.main.get_data_store", return_value=existing_single),
+ patch("src.api.main.get_data_by_slice_id", return_value=[{"service_id": "svc-1"}]),
+ patch("src.api.main.tfs_connector") as mock_conn_cls,
+ patch("src.api.main.delete_by_slice_id"),
+ patch("src.api.main.tfs_l2vpn_delete"),
+ patch("src.api.main.delete_data_store"),
+ ):
mock_conn = MagicMock()
mock_conn_cls.return_value = mock_conn
res_del, code_del = api.delete_slice_services("slice-1")
@@ -1185,23 +1168,21 @@ class TestApiRequestedMethodsCoverage:
{
"id": "slice-10",
"service-tags": {
- "tag-type": {
- "ietf-network-slice-service:service": {
- "tag-type-value": ["L2"]
- }
- }
- }
+ "tag-type": {"ietf-network-slice-service:service": {"tag-type-value": ["L2"]}}
+ },
}
]
}
}
- with flask_app.app_context(), \
- patch("src.api.main.get_data_store", return_value=store_content), \
- patch("src.api.main.get_data_by_slice_id", return_value=[{"service_id": "svc-10"}]), \
- patch("src.api.main.tfs_connector") as mock_conn_cls, \
- patch("src.api.main.delete_by_slice_id"), \
- patch("src.api.main.tfs_l2vpn_delete"), \
- patch("src.api.main.delete_data_store"):
+ with (
+ flask_app.app_context(),
+ patch("src.api.main.get_data_store", return_value=store_content),
+ patch("src.api.main.get_data_by_slice_id", return_value=[{"service_id": "svc-10"}]),
+ patch("src.api.main.tfs_connector") as mock_conn_cls,
+ patch("src.api.main.delete_by_slice_id"),
+ patch("src.api.main.tfs_l2vpn_delete"),
+ patch("src.api.main.delete_data_store"),
+ ):
mock_conn = MagicMock()
mock_conn_cls.return_value = mock_conn
res_del_all, code_del_all = api.delete_slice_services()
@@ -1226,10 +1207,11 @@ class TestApiRequestedMethodsCoverage:
res_all, code_all = api.get_clients()
assert code_all == 200
- # get_clients all - empty -> 404
+ # get_clients all - empty -> 200 with []
with patch("src.api.main.get_all_clients", return_value=[]):
- res_404, code_404 = api.get_clients()
- assert code_404 == 404
+ res_empty, code_empty = api.get_clients()
+ assert code_empty == 200
+ assert res_empty["data"] == []
# get_clients Exception -> 500
with patch("src.api.main.get_all_clients", side_effect=Exception("Client DB Error")):
@@ -1237,8 +1219,10 @@ class TestApiRequestedMethodsCoverage:
assert code_500 == 500
# delete_subscriptions single slice
- with patch("src.api.main.get_client_subscriptions", return_value=["slice-1"]), \
- patch("src.api.main.delete_subscription"):
+ with (
+ patch("src.api.main.get_client_subscriptions", return_value=["slice-1"]),
+ patch("src.api.main.delete_subscription"),
+ ):
res_del_sub, code_del_sub = api.delete_subscriptions("c1", "slice-1")
assert code_del_sub == 204
@@ -1248,8 +1232,10 @@ class TestApiRequestedMethodsCoverage:
assert code_del_sub_404 == 404
# delete_subscriptions all
- with patch("src.api.main.get_client_subscriptions", return_value=[]), \
- patch("src.api.main.delete_all_subscriptions"):
+ with (
+ patch("src.api.main.get_client_subscriptions", return_value=[]),
+ patch("src.api.main.delete_all_subscriptions"),
+ ):
res_del_all, code_del_all = api.delete_subscriptions("c1")
assert code_del_all == 204
@@ -1262,8 +1248,10 @@ class TestApiRequestedMethodsCoverage:
api = Api(controller_with_mocked_db)
# single slice subscription success
- with patch("src.api.main.get_subscription", return_value={"client_id": "c1", "slice_id": "s1"}), \
- patch.object(api, "get_telemetry", return_value=({"latency": 10}, 200)):
+ with (
+ patch("src.api.main.get_subscription", return_value={"client_id": "c1", "slice_id": "s1"}),
+ patch.object(api, "get_telemetry", return_value=({"latency": 10}, 200)),
+ ):
res_sub, code_sub = api.get_subscriptions("c1", "s1")
assert code_sub == 200
assert "telemetry" in res_sub
@@ -1274,8 +1262,10 @@ class TestApiRequestedMethodsCoverage:
assert code_404 == 404
# all subscriptions for client
- with patch("src.api.main.get_client_subscriptions", return_value=[{"slice_id": "s1"}]), \
- patch.object(api, "get_telemetry", return_value=({"latency": 10}, 200)):
+ with (
+ patch("src.api.main.get_client_subscriptions", return_value=[{"slice_id": "s1"}]),
+ patch.object(api, "get_telemetry", return_value=({"latency": 10}, 200)),
+ ):
res_subs_all, code_subs_all = api.get_subscriptions("c1")
assert code_subs_all == 200
assert len(res_subs_all["subscriptions"]) == 1
@@ -1289,27 +1279,15 @@ class TestApiRequestedMethodsCoverage:
api = Api(controller_with_mocked_db)
# Single slice telemetry success
- existing_slice = {
- "network-slice-services": {
- "slice-service": {
- "s1": {"slo-sle-template": "tmpl-1"}
- }
- }
- }
+ existing_slice = {"network-slice-services": {"slice-service": {"s1": {"slo-sle-template": "tmpl-1"}}}}
tmpl_data = [
- {
- "network-slice-services": {
- "slo-sle-templates": {
- "slo-sle-template": {
- "tmpl-1": {"id": "tmpl-1"}
- }
- }
- }
- }
+ {"network-slice-services": {"slo-sle-templates": {"slo-sle-template": {"tmpl-1": {"id": "tmpl-1"}}}}}
]
- 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}):
+ 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}
@@ -1321,22 +1299,22 @@ class TestApiRequestedMethodsCoverage:
# 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", return_value=existing_slice), \
- patch.object(api, "get_slo_sle_templates", return_value=empty_tmpl_data):
+ 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
# All slices telemetry loop (as list and dict)
slices_data_dict = {
- "network-slice-services": {
- "slice-service": {
- "s1": {"id": "s1", "slo-sle-template": "tmpl-1"}
- }
- }
+ "network-slice-services": {"slice-service": {"s1": {"id": "s1", "slo-sle-template": "tmpl-1"}}}
}
- 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.object(api.slice_service, "monitoring", return_value={"bw": 100}):
+ 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.object(api.slice_service, "monitoring", return_value={"bw": 100}),
+ ):
res_all, code_all = api.get_telemetry()
assert code_all == 200
assert "s1" in res_all
@@ -1345,8 +1323,10 @@ class TestApiRequestedMethodsCoverage:
api = Api(controller_with_mocked_db)
# 1. stream_client_subscriptions success & break via sleep StopAsyncIteration
- with patch.object(api, "get_subscriptions", return_value=({"subscriptions": [{"frequency": 1}]}, 200)), \
- patch("asyncio.sleep", side_effect=StopAsyncIteration):
+ with (
+ patch.object(api, "get_subscriptions", return_value=({"subscriptions": [{"frequency": 1}]}, 200)),
+ patch("asyncio.sleep", side_effect=StopAsyncIteration),
+ ):
gen = api.sync_stream(api.stream_client_subscriptions, "c1")
items = list(gen)
assert len(items) >= 1
@@ -1367,8 +1347,10 @@ class TestApiRequestedMethodsCoverage:
assert "event: error" in items[0]
# 4. stream_slice_subscription success
- with patch.object(api, "get_subscriptions", return_value=({"frequency": 2}, 200)), \
- patch("asyncio.sleep", side_effect=StopAsyncIteration):
+ with (
+ patch.object(api, "get_subscriptions", return_value=({"frequency": 2}, 200)),
+ patch("asyncio.sleep", side_effect=StopAsyncIteration),
+ ):
gen = api.sync_stream(api.stream_slice_subscription, "c1", "s1")
items = list(gen)
assert len(items) >= 1
@@ -1387,3 +1369,18 @@ class TestApiRequestedMethodsCoverage:
items = list(gen)
assert len(items) == 1
assert "event: error" in items[0]
+
+
+class TestSendResponseSecurity:
+ """Tests to ensure send_response does not leak server internals."""
+
+ def test_send_response_no_file_path_leak(self):
+ from src.utils.send_response import send_response
+
+ res, code = send_response(False, message="Invalid payload", code=400)
+ assert code == 400
+ assert res["success"] is False
+ assert res["error"] == "Invalid payload"
+ assert "File:" not in res["error"]
+ assert "Line:" not in res["error"]
+
diff --git a/src/tests/test_database.py b/src/tests/test_database.py
index 7b1c0ce67982589776f8d140ebdc213a55f088e4..8ce9b2f67fde49c61a9467973ea65715dc780117 100644
--- a/src/tests/test_database.py
+++ b/src/tests/test_database.py
@@ -14,21 +14,22 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
-import pytest
-import sqlite3
import json
import os
+import sqlite3
import time
-from unittest.mock import patch, MagicMock
+from unittest.mock import MagicMock, patch
+
+import pytest
+
from src.database.db import (
+ delete_all_data,
+ delete_data,
+ get_all_data,
+ get_data,
init_db,
save_data,
update_data,
- delete_data,
- get_data,
- get_all_data,
- delete_all_data,
- DB_NAME
)
from src.database.store_data import store_data
@@ -37,9 +38,9 @@ from src.database.store_data import store_data
def test_db(tmp_path):
"""Fixture to create and cleanup test database."""
test_db_name = str(tmp_path / "test_slice.db")
-
+
# Use test database
- with patch('src.database.db.DB_NAME', test_db_name):
+ with patch("src.database.db.DB_NAME", test_db_name):
conn = sqlite3.connect(test_db_name)
cursor = conn.cursor()
cursor.execute("""
@@ -51,24 +52,26 @@ def test_db(tmp_path):
""")
conn.commit()
conn.close()
-
+
yield test_db_name
-
+
# Cleanup - Close all connections and remove file
try:
# Force SQLite to release locks
- sqlite3.connect(':memory:').execute('VACUUM').close()
-
+ sqlite3.connect(":memory:").execute("VACUUM").close()
+
# Wait a moment for file locks to release
import time
+
time.sleep(0.1)
-
+
# Remove the file if it exists
if os.path.exists(test_db_name):
os.remove(test_db_name)
- except Exception as e:
+ except Exception:
# On Windows, sometimes files are locked. Try again after a delay
import time
+
time.sleep(0.5)
try:
if os.path.exists(test_db_name):
@@ -82,29 +85,24 @@ def sample_intent():
"""Fixture providing sample network slice intent."""
return {
"ietf-network-slice-service:network-slice-services": {
- "slice-service": [{
- "id": "slice-service-12345",
- "description": "Test network slice",
- "service-tags": {"tag-type": {"value": "L2VPN"}},
- "sdps": {
- "sdp": [{
- "node-id": "node1",
- "sdp-ip-address": "10.0.0.1"
- }]
+ "slice-service": [
+ {
+ "id": "slice-service-12345",
+ "description": "Test network slice",
+ "service-tags": {"tag-type": {"value": "L2VPN"}},
+ "sdps": {"sdp": [{"node-id": "node1", "sdp-ip-address": "10.0.0.1"}]},
}
- }],
+ ],
"slo-sle-templates": {
- "slo-sle-template": [{
- "id": "profile1",
- "slo-policy": {
- "metric-bound": [{
- "metric-type": "one-way-bandwidth",
- "metric-unit": "kbps",
- "bound": 1000
- }]
+ "slo-sle-template": [
+ {
+ "id": "profile1",
+ "slo-policy": {
+ "metric-bound": [{"metric-type": "one-way-bandwidth", "metric-unit": "kbps", "bound": 1000}]
+ },
}
- }]
- }
+ ]
+ },
}
}
@@ -112,132 +110,128 @@ def sample_intent():
@pytest.fixture
def simple_intent():
"""Fixture providing simple intent for basic testing."""
- return {
- "bandwidth": "1Gbps",
- "latency": "10ms",
- "provider": "opensec"
- }
+ return {"bandwidth": "1Gbps", "latency": "10ms", "provider": "opensec"}
class TestInitDb:
"""Tests for database initialization."""
-
+
def test_init_db_creates_table(self, tmp_path):
"""Test that init_db creates the slice table."""
test_db = str(tmp_path / "test.db")
-
- with patch('src.database.db.DB_NAME', test_db):
+
+ with patch("src.database.db.DB_NAME", test_db):
init_db()
-
+
conn = sqlite3.connect(test_db)
cursor = conn.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='slice'")
result = cursor.fetchone()
conn.close()
time.sleep(0.05) # Brief pause for file lock release
-
+
assert result is not None
- assert result[0] == 'slice'
-
+ assert result[0] == "slice"
+
def test_init_db_creates_correct_columns(self, tmp_path):
"""Test that init_db creates table with correct columns."""
test_db = str(tmp_path / "test.db")
-
- with patch('src.database.db.DB_NAME', test_db):
+
+ with patch("src.database.db.DB_NAME", test_db):
init_db()
-
+
conn = sqlite3.connect(test_db)
cursor = conn.cursor()
cursor.execute("PRAGMA table_info(slice)")
columns = cursor.fetchall()
conn.close()
time.sleep(0.05)
-
+
column_names = [col[1] for col in columns]
assert "slice_id" in column_names
assert "intent" in column_names
assert "controller" in column_names
-
+
def test_init_db_idempotent(self, tmp_path):
"""Test that init_db can be called multiple times without error."""
test_db = str(tmp_path / "test.db")
-
- with patch('src.database.db.DB_NAME', test_db):
+
+ with patch("src.database.db.DB_NAME", test_db):
init_db()
init_db() # Should not raise error
-
+
conn = sqlite3.connect(test_db)
cursor = conn.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='slice'")
result = cursor.fetchone()
conn.close()
time.sleep(0.05)
-
+
assert result is not None
class TestSaveData:
"""Tests for save_data function."""
-
+
def test_save_data_success(self, test_db, simple_intent):
"""Test successful data saving."""
- with patch('src.database.db.DB_NAME', test_db):
+ with patch("src.database.db.DB_NAME", test_db):
save_data("slice-001", simple_intent, "TFS")
-
+
conn = sqlite3.connect(test_db)
cursor = conn.cursor()
cursor.execute("SELECT * FROM slice WHERE slice_id = ?", ("slice-001",))
result = cursor.fetchone()
conn.close()
-
+
assert result is not None
assert result[0] == "slice-001"
assert result[2] == "TFS"
assert json.loads(result[1]) == simple_intent
-
+
def test_save_data_with_complex_intent(self, test_db, sample_intent):
"""Test saving complex nested intent structure."""
- with patch('src.database.db.DB_NAME', test_db):
+ with patch("src.database.db.DB_NAME", test_db):
slice_id = sample_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"]
save_data(slice_id, sample_intent, "IXIA")
-
+
conn = sqlite3.connect(test_db)
cursor = conn.cursor()
cursor.execute("SELECT intent FROM slice WHERE slice_id = ?", (slice_id,))
result = cursor.fetchone()
conn.close()
-
+
retrieved_intent = json.loads(result[0])
assert retrieved_intent == sample_intent
-
+
def test_save_data_duplicate_slice_id_raises_error(self, test_db, simple_intent):
"""Test that saving duplicate slice_id raises ValueError."""
- with patch('src.database.db.DB_NAME', test_db):
+ with patch("src.database.db.DB_NAME", test_db):
save_data("slice-001", simple_intent, "TFS")
-
+
with pytest.raises(ValueError, match="already exists"):
save_data("slice-001", simple_intent, "TFS")
-
+
def test_save_data_multiple_slices(self, test_db, simple_intent):
"""Test saving multiple different slices."""
- with patch('src.database.db.DB_NAME', test_db):
+ with patch("src.database.db.DB_NAME", test_db):
save_data("slice-001", simple_intent, "TFS")
save_data("slice-002", simple_intent, "IXIA")
-
+
conn = sqlite3.connect(test_db)
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM slice")
count = cursor.fetchone()[0]
conn.close()
-
+
assert count == 2
-
+
def test_save_data_with_different_controllers(self, test_db, simple_intent):
"""Test saving data with different controller types."""
- with patch('src.database.db.DB_NAME', test_db):
+ with patch("src.database.db.DB_NAME", test_db):
save_data("slice-tfs", simple_intent, "TFS")
save_data("slice-ixia", simple_intent, "IXIA")
-
+
conn = sqlite3.connect(test_db)
cursor = conn.cursor()
cursor.execute("SELECT controller FROM slice WHERE slice_id = ?", ("slice-tfs",))
@@ -245,98 +239,105 @@ class TestSaveData:
cursor.execute("SELECT controller FROM slice WHERE slice_id = ?", ("slice-ixia",))
ixia_result = cursor.fetchone()
conn.close()
-
+
assert tfs_result[0] == "TFS"
assert ixia_result[0] == "IXIA"
class TestUpdateData:
"""Tests for update_data function."""
-
+
def test_update_data_success(self, test_db, simple_intent):
"""Test successful data update."""
- with patch('src.database.db.DB_NAME', test_db):
+ with patch("src.database.db.DB_NAME", test_db):
save_data("slice-001", simple_intent, "TFS")
-
+
updated_intent = {"bandwidth": "2Gbps", "latency": "5ms", "provider": "opensec"}
update_data("slice-001", updated_intent, "TFS")
-
+
conn = sqlite3.connect(test_db)
cursor = conn.cursor()
cursor.execute("SELECT intent FROM slice WHERE slice_id = ?", ("slice-001",))
result = cursor.fetchone()
conn.close()
-
+
retrieved_intent = json.loads(result[0])
assert retrieved_intent == updated_intent
-
+
def test_update_data_nonexistent_slice_raises_error(self, test_db, simple_intent):
"""Test that updating nonexistent slice raises ValueError."""
- with patch('src.database.db.DB_NAME', test_db):
+ with patch("src.database.db.DB_NAME", test_db):
with pytest.raises(ValueError, match="No slice found"):
update_data("nonexistent-slice", simple_intent, "TFS")
-
+
def test_update_data_controller_type(self, test_db, simple_intent):
"""Test updating controller type."""
- with patch('src.database.db.DB_NAME', test_db):
+ with patch("src.database.db.DB_NAME", test_db):
save_data("slice-001", simple_intent, "TFS")
update_data("slice-001", simple_intent, "IXIA")
-
+
conn = sqlite3.connect(test_db)
cursor = conn.cursor()
cursor.execute("SELECT controller FROM slice WHERE slice_id = ?", ("slice-001",))
result = cursor.fetchone()
conn.close()
-
+
assert result[0] == "IXIA"
-
+
def test_update_data_complex_intent(self, test_db, sample_intent):
"""Test updating with complex nested structure."""
- with patch('src.database.db.DB_NAME', test_db):
+ with patch("src.database.db.DB_NAME", test_db):
slice_id = sample_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"]
save_data(slice_id, sample_intent, "TFS")
-
+
updated_sample = sample_intent.copy()
- updated_sample["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["description"] = "Updated description"
-
+ updated_sample["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["description"] = (
+ "Updated description"
+ )
+
update_data(slice_id, updated_sample, "IXIA")
-
+
retrieved = get_data(slice_id)
- assert retrieved["intent"]["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["description"] == "Updated description"
+ assert (
+ retrieved["intent"]["ietf-network-slice-service:network-slice-services"]["slice-service"][0][
+ "description"
+ ]
+ == "Updated description"
+ )
assert retrieved["controller"] == "IXIA"
class TestDeleteData:
"""Tests for delete_data function."""
-
+
def test_delete_data_success(self, test_db, simple_intent):
"""Test successful data deletion."""
- with patch('src.database.db.DB_NAME', test_db):
+ with patch("src.database.db.DB_NAME", test_db):
save_data("slice-001", simple_intent, "TFS")
delete_data("slice-001")
-
+
conn = sqlite3.connect(test_db)
cursor = conn.cursor()
cursor.execute("SELECT * FROM slice WHERE slice_id = ?", ("slice-001",))
result = cursor.fetchone()
conn.close()
-
+
assert result is None
-
+
def test_delete_data_nonexistent_slice_raises_error(self, test_db):
"""Test that deleting nonexistent slice raises ValueError."""
- with patch('src.database.db.DB_NAME', test_db):
+ with patch("src.database.db.DB_NAME", test_db):
with pytest.raises(ValueError, match="No slice found"):
delete_data("nonexistent-slice")
-
+
def test_delete_data_multiple_slices(self, test_db, simple_intent):
"""Test deleting one slice doesn't affect others."""
- with patch('src.database.db.DB_NAME', test_db):
+ with patch("src.database.db.DB_NAME", test_db):
save_data("slice-001", simple_intent, "TFS")
save_data("slice-002", simple_intent, "IXIA")
-
+
delete_data("slice-001")
-
+
conn = sqlite3.connect(test_db)
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM slice")
@@ -344,46 +345,46 @@ class TestDeleteData:
cursor.execute("SELECT * FROM slice WHERE slice_id = ?", ("slice-002",))
remaining = cursor.fetchone()
conn.close()
-
+
assert count == 1
assert remaining[0] == "slice-002"
class TestGetData:
"""Tests for get_data function."""
-
+
def test_get_data_success(self, test_db, simple_intent):
"""Test retrieving existing data."""
- with patch('src.database.db.DB_NAME', test_db):
+ with patch("src.database.db.DB_NAME", test_db):
save_data("slice-001", simple_intent, "TFS")
result = get_data("slice-001")
-
+
assert result["slice_id"] == "slice-001"
assert result["intent"] == simple_intent
assert result["controller"] == "TFS"
-
+
def test_get_data_nonexistent_raises_error(self, test_db):
"""Test that getting nonexistent slice raises ValueError."""
- with patch('src.database.db.DB_NAME', test_db):
+ with patch("src.database.db.DB_NAME", test_db):
with pytest.raises(ValueError, match="No slice found"):
get_data("nonexistent-slice")
-
+
def test_get_data_json_parsing(self, test_db, sample_intent):
"""Test that returned intent is parsed JSON."""
- with patch('src.database.db.DB_NAME', test_db):
+ with patch("src.database.db.DB_NAME", test_db):
slice_id = sample_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"]
save_data(slice_id, sample_intent, "TFS")
result = get_data(slice_id)
-
+
assert isinstance(result["intent"], dict)
assert result["intent"] == sample_intent
-
+
def test_get_data_returns_all_fields(self, test_db, simple_intent):
"""Test that get_data returns all fields."""
- with patch('src.database.db.DB_NAME', test_db):
+ with patch("src.database.db.DB_NAME", test_db):
save_data("slice-001", simple_intent, "TFS")
result = get_data("slice-001")
-
+
assert "slice_id" in result
assert "intent" in result
assert "controller" in result
@@ -392,58 +393,58 @@ class TestGetData:
class TestGetAllData:
"""Tests for get_all_data function."""
-
+
def test_get_all_data_empty_database(self, test_db):
"""Test retrieving all data from empty database."""
- with patch('src.database.db.DB_NAME', test_db):
+ with patch("src.database.db.DB_NAME", test_db):
result = get_all_data()
assert result == []
-
+
def test_get_all_data_single_slice(self, test_db, simple_intent):
"""Test retrieving all data with single slice."""
- with patch('src.database.db.DB_NAME', test_db):
+ with patch("src.database.db.DB_NAME", test_db):
save_data("slice-001", simple_intent, "TFS")
result = get_all_data()
-
+
assert len(result) == 1
assert result[0]["slice_id"] == "slice-001"
assert result[0]["intent"] == simple_intent
-
+
def test_get_all_data_multiple_slices(self, test_db, simple_intent):
"""Test retrieving all data with multiple slices."""
- with patch('src.database.db.DB_NAME', test_db):
+ with patch("src.database.db.DB_NAME", test_db):
save_data("slice-001", simple_intent, "TFS")
save_data("slice-002", simple_intent, "IXIA")
save_data("slice-003", simple_intent, "TFS")
-
+
result = get_all_data()
-
+
assert len(result) == 3
slice_ids = [slice_data["slice_id"] for slice_data in result]
assert "slice-001" in slice_ids
assert "slice-002" in slice_ids
assert "slice-003" in slice_ids
-
+
def test_get_all_data_json_parsing(self, test_db, sample_intent):
"""Test that all returned intents are parsed JSON."""
- with patch('src.database.db.DB_NAME', test_db):
+ with patch("src.database.db.DB_NAME", test_db):
slice_id = sample_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"]
save_data(slice_id, sample_intent, "TFS")
save_data("slice-002", sample_intent, "IXIA")
-
+
result = get_all_data()
-
+
for slice_data in result:
assert isinstance(slice_data["intent"], dict)
-
+
def test_get_all_data_includes_all_controllers(self, test_db, simple_intent):
"""Test that get_all_data includes slices from different controllers."""
- with patch('src.database.db.DB_NAME', test_db):
+ with patch("src.database.db.DB_NAME", test_db):
save_data("slice-tfs", simple_intent, "TFS")
save_data("slice-ixia", simple_intent, "IXIA")
-
+
result = get_all_data()
-
+
controllers = [slice_data["controller"] for slice_data in result]
assert "TFS" in controllers
assert "IXIA" in controllers
@@ -451,21 +452,21 @@ class TestGetAllData:
class TestDeleteAllData:
"""Tests for delete_all_data function."""
-
+
def test_delete_all_data_removes_all_slices(self, test_db, simple_intent):
"""Test that delete_all_data removes all slices."""
- with patch('src.database.db.DB_NAME', test_db):
+ with patch("src.database.db.DB_NAME", test_db):
save_data("slice-001", simple_intent, "TFS")
save_data("slice-002", simple_intent, "IXIA")
-
+
delete_all_data()
-
+
result = get_all_data()
assert result == []
-
+
def test_delete_all_data_empty_database(self, test_db):
"""Test delete_all_data on empty database doesn't raise error."""
- with patch('src.database.db.DB_NAME', test_db):
+ with patch("src.database.db.DB_NAME", test_db):
delete_all_data() # Should not raise error
result = get_all_data()
assert result == []
@@ -473,128 +474,133 @@ class TestDeleteAllData:
class TestStoreData:
"""Tests for store_data wrapper function."""
-
+
def test_store_data_save_new_slice(self, test_db, sample_intent):
"""Test store_data saves new slice without slice_id."""
- with patch('src.database.db.DB_NAME', test_db):
+ with patch("src.database.db.DB_NAME", test_db):
store_data(sample_intent, None, "TFS")
-
+
slice_id = sample_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"]
result = get_data(slice_id)
-
+
assert result["slice_id"] == slice_id
assert result["intent"] == sample_intent
assert result["controller"] == "TFS"
-
+
def test_store_data_update_existing_slice(self, test_db, sample_intent):
"""Test store_data updates existing slice when slice_id provided."""
- with patch('src.database.db.DB_NAME', test_db):
+ with patch("src.database.db.DB_NAME", test_db):
slice_id = sample_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"]
-
+
# Save initial data
save_data(slice_id, sample_intent, "TFS")
-
+
# Update with store_data
updated_intent = sample_intent.copy()
- updated_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["description"] = "Updated"
+ updated_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["description"] = (
+ "Updated"
+ )
store_data(updated_intent, slice_id, "IXIA")
-
+
result = get_data(slice_id)
assert result["controller"] == "IXIA"
- assert result["intent"]["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["description"] == "Updated"
-
+ assert (
+ result["intent"]["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["description"]
+ == "Updated"
+ )
+
def test_store_data_extracts_slice_id_from_intent(self, test_db, sample_intent):
"""Test store_data correctly extracts slice_id from intent structure."""
- with patch('src.database.db.DB_NAME', test_db):
+ with patch("src.database.db.DB_NAME", test_db):
store_data(sample_intent, None, "TFS")
-
+
all_data = get_all_data()
assert len(all_data) == 1
assert all_data[0]["slice_id"] == "slice-service-12345"
-
+
def test_store_data_with_different_controllers(self, test_db, sample_intent):
"""Test store_data works with different controller types."""
- with patch('src.database.db.DB_NAME', test_db):
+ with patch("src.database.db.DB_NAME", test_db):
store_data(sample_intent, None, "TFS")
-
+
slice_id = sample_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"]
result = get_data(slice_id)
-
+
assert result["controller"] == "TFS"
class TestDatabaseIntegration:
"""Integration tests for database operations."""
-
+
def test_full_lifecycle_create_read_update_delete(self, test_db, simple_intent):
"""Test complete slice lifecycle."""
- with patch('src.database.db.DB_NAME', test_db):
+ with patch("src.database.db.DB_NAME", test_db):
# Create
save_data("slice-lifecycle", simple_intent, "TFS")
-
+
# Read
result = get_data("slice-lifecycle")
assert result["slice_id"] == "slice-lifecycle"
-
+
# Update
updated_intent = {"bandwidth": "5Gbps", "latency": "2ms", "provider": "opensec"}
update_data("slice-lifecycle", updated_intent, "IXIA")
-
+
result = get_data("slice-lifecycle")
assert result["intent"] == updated_intent
assert result["controller"] == "IXIA"
-
+
# Delete
delete_data("slice-lifecycle")
-
+
with pytest.raises(ValueError):
get_data("slice-lifecycle")
-
+
def test_concurrent_operations(self, test_db, simple_intent):
"""Test multiple concurrent database operations."""
- with patch('src.database.db.DB_NAME', test_db):
+ with patch("src.database.db.DB_NAME", test_db):
# Create multiple slices
for i in range(5):
save_data(f"slice-{i}", simple_intent, "TFS" if i % 2 == 0 else "IXIA")
-
+
# Verify all created
all_data = get_all_data()
assert len(all_data) == 5
-
+
# Update some
updated_intent = {"updated": True}
- for i in range(0, 3):
+ for i in range(3):
update_data(f"slice-{i}", updated_intent, "TFS")
-
+
# Verify updates
- for i in range(0, 3):
+ for i in range(3):
result = get_data(f"slice-{i}")
assert result["intent"]["updated"] is True
-
+
# Delete some
delete_data("slice-0")
delete_data("slice-2")
-
+
all_data = get_all_data()
assert len(all_data) == 3
-
+
def test_data_persistence_across_operations(self, test_db, sample_intent):
"""Test that data persists correctly across multiple operations."""
- with patch('src.database.db.DB_NAME', test_db):
+ with patch("src.database.db.DB_NAME", test_db):
slice_id = sample_intent["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"]
-
+
# Save
save_data(slice_id, sample_intent, "TFS")
-
+
# Get all and verify
all_before = get_all_data()
assert len(all_before) == 1
-
+
# Save another
save_data("slice-other", sample_intent, "IXIA")
all_after = get_all_data()
assert len(all_after) == 2
-
+
# Verify first slice still intact
first_slice = get_data(slice_id)
assert first_slice["intent"] == sample_intent
@@ -605,7 +611,12 @@ class TestServiceDB:
"""Tests for service_db module."""
def test_service_db_crud(self, temp_sqlite_db):
- from src.database.service_db import save_data, get_data_by_slice_id, delete_by_slice_id, get_all_data
+ from src.database.service_db import (
+ delete_by_slice_id,
+ get_all_data,
+ get_data_by_slice_id,
+ save_data,
+ )
save_data("service-1", "slice-100")
@@ -630,8 +641,13 @@ class TestTelemetryClientDB:
def test_client_and_subscriptions(self, temp_sqlite_db):
from src.database.telemetry_client_db import (
- create_client, get_client, get_all_clients, delete_client, delete_all_clients,
- upsert_subscription, get_subscription, get_client_subscriptions, delete_subscription, delete_all_subscriptions
+ create_client,
+ delete_subscription,
+ get_all_clients,
+ get_client,
+ get_client_subscriptions,
+ get_subscription,
+ upsert_subscription,
)
create_client("c1")
@@ -653,11 +669,19 @@ class TestTelemetryClientDB:
assert len(subs) == 1
delete_subscription("c1", "slice-A")
+
+
class TestAlertDB:
"""Tests for alert_db module."""
def test_alert_db_crud(self, temp_sqlite_db):
- from src.database.alert_db import save_alert, get_alert, get_all_alerts, delete_alert, delete_all_alerts
+ from src.database.alert_db import (
+ delete_alert,
+ delete_all_alerts,
+ get_alert,
+ get_all_alerts,
+ save_alert,
+ )
save_alert("alert-1", {"uuid": "alert-1", "severity": "HIGH"})
alert = get_alert("alert-1")
@@ -677,13 +701,16 @@ class TestSysrepoStore:
"""Tests for sysrepo_store functions using mocked sysrepo session."""
def test_sysrepo_store_helpers(self):
- from src.database.sysrepo_store import create_data_store, get_data_store, delete_data_store, update_data_store, normalize_libyang_data
+ from src.database.sysrepo_store import (
+ create_data_store,
+ delete_data_store,
+ get_data_store,
+ normalize_libyang_data,
+ )
libyang_data = {
"ietf-network-slice-service:network-slice-services": {
- "slice-service": [
- {"id": "slice-1", "description": "Test"}
- ]
+ "slice-service": [{"id": "slice-1", "description": "Test"}]
}
}
normalized = normalize_libyang_data(libyang_data)
@@ -713,20 +740,22 @@ class TestSysrepoStore:
"int_key": 42,
"bool_key": True,
"list_key": ["item1", "item2"],
- "dict_key": {"inner": "val2"}
+ "dict_key": {"inner": "val2"},
}
_write_dict(mock_sess, "/path", complex_dict)
assert mock_sess.set_item.call_count >= 5
def test_database_error_branches(self, tmp_path):
- from src.database.service_db import init_db as init_service_db, update_data as service_update, delete_data as service_delete
- from src.database.alert_db import init_db as init_alert_db, update_alert
+ from src.database.alert_db import init_db as init_alert_db
+ from src.database.alert_db import update_alert
+ from src.database.service_db import delete_data as service_delete
+ from src.database.service_db import init_db as init_service_db
+ from src.database.service_db import update_data as service_update
s_db = str(tmp_path / "test_service.db")
a_db = str(tmp_path / "test_alert.db")
- with patch("src.database.service_db.DB_NAME", s_db), \
- patch("src.database.alert_db.DB_NAME", a_db):
+ with patch("src.database.service_db.DB_NAME", s_db), patch("src.database.alert_db.DB_NAME", a_db):
init_service_db()
init_alert_db()
@@ -739,4 +768,4 @@ class TestSysrepoStore:
# Non-existent alert_id error branch
with pytest.raises(ValueError, match="No alert found"):
- update_alert("nonexistent-alert", {"data": 1})
\ No newline at end of file
+ update_alert("nonexistent-alert", {"data": 1})
diff --git a/src/tests/test_e2e.py b/src/tests/test_e2e.py
index 6efce971097f0b7bfa2dac43dd499f066dde8733..8bd7e3fac9f7ca92f426019acc04f5821fd0c76d 100644
--- a/src/tests/test_e2e.py
+++ b/src/tests/test_e2e.py
@@ -1,262 +1,292 @@
-# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-# This file is an original contribution from Telefonica Innovación Digital S.L.
-
-import pytest
-import json
-from itertools import product
-from pathlib import Path
-from unittest.mock import MagicMock
-from src.api.main import Api
-from src.main import NSController
-from app import create_app
-
-# Folder where request JSON files are located
-REQUESTS_DIR = Path(__file__).parent / "requests"
-
-# Namespaces to test
-NAMESPACES = ["tfs", "ixia", "e2e", "restconf"]
-
-
-# Flag configurations covering all .env flags and their possible values
-FLAG_OPTIONS = {
- "DUMMY_MODE": [True, False],
- "WEBUI_DEPLOY": [True, False],
- "DUMP_TEMPLATES": [True, False],
- "NRP_ENABLED": [True, False],
- "PLANNER_ENABLED": [True, False],
- "PCE_EXTERNAL": [True, False],
- "PLANNER_TYPE": ["ENERGY", "HRAT", "E2E_OPTICAL"],
- "UPLOAD_TYPE": ["NBI", "WEBUI"],
- "TFS_L2VPN_SUPPORT": [True, False],
- "SDN_CONTROLLER_TYPE": ["TFS", "IXIA"],
- "DATAPLANE_SUPPORT": ["CISCO", "FRR"],
- "SUBSCRIBE_ALERTS": [True, False],
-}
-
-
-def generate_flag_combinations():
- """Generates all Cartesian product combinations of configuration flags."""
- keys = list(FLAG_OPTIONS.keys())
- values = list(FLAG_OPTIONS.values())
- for prod in product(*values):
- combo = dict(zip(keys, prod))
- combo.update({
- "SUBSCRIBE_ALERTS_URL": "http://127.0.0.1:8085/alert",
- "HRAT_IP": "10.0.0.1",
- "E2E_OPTICAL_IP": "127.0.0.1",
- "TFS_IP": "127.0.0.1",
- "IXIA_IP": "127.0.0.1",
- "TFS_E2E_IP": "127.0.0.1",
- "RESTCONF_IP": "192.168.27.189",
- "API_USERNAME": "admin",
- "API_PASSWORD": "password",
- })
- yield combo
-
-
-class MockResponse:
- """Mock response object for HTTP and controller connectors."""
- def __init__(self, status_code=200, text="OK", json_data=None):
- self.status_code = status_code
- self.text = text
- self.ok = status_code < 400
- self._json_data = json_data if json_data is not None else {
- "success": True,
- "tapi-notification:output": {
- "subscription-id": "mock-sub-123"
- }
- }
-
- def json(self):
- return self._json_data
-
- def raise_for_status(self):
- if not self.ok:
- raise Exception(f"HTTP Error {self.status_code}")
-
-
-@pytest.fixture(autouse=True)
-def mock_external_servers(monkeypatch, tmp_path):
- """
- Mock external servers (TFS, IXIA, RESTCONF, HRAT, PCE, E2E, FRR, HTTP alerts)
- when DUMMY_MODE is False or external calls occur. Assumes good/successful responses.
- """
- temp_templates = tmp_path / "templates"
- temp_templates.mkdir(exist_ok=True)
- monkeypatch.setattr("src.utils.dump_templates.TEMPLATES_PATH", str(temp_templates))
-
- def mock_http_call(*args, **kwargs):
- return MockResponse(200, text="OK")
-
- # Patch requests module methods
- monkeypatch.setattr("requests.get", mock_http_call)
- monkeypatch.setattr("requests.post", mock_http_call)
- monkeypatch.setattr("requests.put", mock_http_call)
- monkeypatch.setattr("requests.delete", mock_http_call)
- monkeypatch.setattr("requests.request", mock_http_call)
-
- class MockSession:
- def __init__(self):
- self.auth = None
- def get(self, *args, **kwargs):
- return MockResponse(200, text='')
- def post(self, *args, **kwargs):
- return MockResponse(200, text="OK")
- def put(self, *args, **kwargs):
- return MockResponse(200, text="OK")
- def delete(self, *args, **kwargs):
- return MockResponse(200, text="OK")
-
- monkeypatch.setattr("requests.Session", MockSession)
-
- # Patch TFS connectors
- try:
- from src.realizer.tfs.helpers.tfs_connector import tfs_connector
- monkeypatch.setattr(tfs_connector, "webui_post", lambda self, *a, **kw: MockResponse(200, "OK"))
- monkeypatch.setattr(tfs_connector, "nbi_post", lambda self, *a, **kw: MockResponse(200, "OK"))
- monkeypatch.setattr(tfs_connector, "nbi_delete", lambda self, *a, **kw: MockResponse(200, "OK"))
- monkeypatch.setattr(tfs_connector, "ipowdm_post", lambda self, *a, **kw: MockResponse(200, "OK"))
- monkeypatch.setattr(tfs_connector, "ipowdm_put", lambda self, *a, **kw: MockResponse(200, "OK"))
- monkeypatch.setattr(tfs_connector, "get_network_topology", lambda self, *a, **kw: ([], MockResponse(200, "OK")))
- except Exception:
- pass
-
- try:
- from src.realizer.restconf.connectors.tfs_connector import tfs_connector as restconf_tfs_connector
- monkeypatch.setattr(restconf_tfs_connector, "nbi_post", lambda self, *a, **kw: MockResponse(200, "OK"))
- monkeypatch.setattr(restconf_tfs_connector, "nbi_delete", lambda self, *a, **kw: MockResponse(200, "OK"))
- except Exception:
- pass
-
- # Patch IXIA controller
- try:
- from src.realizer.ixia.helpers.NEII_V4 import NEII_controller
- monkeypatch.setattr(NEII_controller, "nscNEII", lambda self, *a, **kw: MockResponse(200, "OK"))
- except Exception:
- pass
-
- # Patch FRR and Cisco connectors / Netmiko
- try:
- from src.realizer.restconf.connectors.frr_connector import frr_connector
- monkeypatch.setattr(frr_connector, "execute_commands", lambda self, commands: None)
- except Exception:
- pass
-
- try:
- from src.realizer.tfs.helpers.cisco_connector import cisco_connector
- monkeypatch.setattr(cisco_connector, "execute_commands", lambda self, commands: None)
- except Exception:
- pass
-
- try:
- import netmiko
- monkeypatch.setattr(netmiko, "ConnectHandler", lambda **kw: MagicMock())
- except Exception:
- pass
-
-
-@pytest.fixture
-def app(temp_sqlite_db):
- """Creates the Flask app with default configuration."""
- app = create_app()
- return app
-
-
-@pytest.fixture
-def set_flags(app):
- """Directly updates configuration flags in app.config."""
- def _set(flags: dict):
- for k, v in flags.items():
- app.config[k] = v
- return _set
-
-
-def load_request_files():
- """Recursively loads all JSON request files from subdirectories under requests/."""
- test_cases = []
- # Search all .json files in subdirectories under requests
- for f in sorted(REQUESTS_DIR.rglob("*.json")):
- try:
- with open(f, "r", encoding="utf-8") as file:
- json_data = json.load(file)
- rel_path = f.relative_to(REQUESTS_DIR).as_posix()
- test_cases.append((rel_path, json_data))
- except Exception:
- pass
- return test_cases
-
-
-def generate_test_cases():
- """Generates all 6,144 flag combinations paired across all request files and namespaces."""
- requests = load_request_files()
- if not requests:
- return
- flag_combos = list(generate_flag_combinations())
- num_reqs = len(requests)
- num_ns = len(NAMESPACES)
-
- for i, flags in enumerate(flag_combos):
- rel_path, json_data = requests[i % num_reqs]
- namespace = NAMESPACES[i % num_ns]
- yield (rel_path, json_data, namespace, flags)
-
-
-@pytest.mark.parametrize(
- "rel_path, json_data, namespace, flags",
- list(generate_test_cases()),
- ids=lambda param: param if isinstance(param, str) else (param.get("PLANNER_TYPE", "") if isinstance(param, dict) else None)
-)
-def test_add_and_delete_flow(app, rel_path, json_data, namespace, flags, set_flags, temp_sqlite_db):
- with app.app_context():
- set_flags(flags)
-
- controller_type = namespace.upper()
- controller = NSController(controller_type=controller_type)
- api = Api(controller)
-
- # Execute creation based on namespace
- if namespace in ["tfs", "ixia", "e2e"]:
- data, code = api.add_flow(json_data)
- elif namespace == "restconf":
- data, code = api.add_network_slice_service(json_data)
- else:
- pytest.fail(f"Unsupported namespace: {namespace}")
-
- if namespace in ["tfs", "ixia", "e2e"]:
- assert code in [200, 201], f"Creation failed for request '{rel_path}' in namespace '{namespace}' with flags: {flags}. Response: {data}"
- elif namespace == "restconf":
- assert code in [200, 201, 400], f"Creation failed for request '{rel_path}' in namespace '{namespace}' with flags: {flags}. Response: {data}"
-
- # Delete flow if created
- if code in [200, 201]:
- slice_id = None
- if isinstance(data, dict):
- # Check data payload for slice_id
- payload_data = data.get("data")
- if isinstance(payload_data, dict):
- slices = payload_data.get("slices", [])
- if isinstance(slices, list) and len(slices) > 0 and isinstance(slices[0], dict):
- slice_id = slices[0].get("id")
-
- if namespace in ["tfs", "ixia", "e2e"]:
- if slice_id:
- _, delete_code = api.delete_flows(slice_id=slice_id)
- else:
- _, delete_code = api.delete_flows()
- elif namespace == "restconf":
- _, delete_code = api.delete_slice_services()
-
- assert delete_code in [200, 204, 404], f"Deletion failed for slice '{slice_id}' in namespace '{namespace}'"
\ No newline at end of file
+# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# This file is an original contribution from Telefonica Innovación Digital S.L.
+
+import json
+from itertools import product
+from pathlib import Path
+from unittest.mock import MagicMock
+
+import pytest
+
+from app import create_app
+from src.api.main import Api
+from src.main import NSController
+
+# Folder where request JSON files are located
+REQUESTS_DIR = Path(__file__).parent / "requests"
+
+# Namespaces to test
+NAMESPACES = ["tfs", "ixia", "e2e", "restconf"]
+
+
+# Flag configurations covering all .env flags and their possible values
+FLAG_OPTIONS = {
+ "DUMMY_MODE": [True, False],
+ "WEBUI_DEPLOY": [True, False],
+ "DUMP_TEMPLATES": [True, False],
+ "NRP_ENABLED": [True, False],
+ "PLANNER_ENABLED": [True, False],
+ "PCE_EXTERNAL": [True, False],
+ "PLANNER_TYPE": ["ENERGY", "HRAT", "E2E_OPTICAL"],
+ "UPLOAD_TYPE": ["NBI", "WEBUI"],
+ "TFS_L2VPN_SUPPORT": [True, False],
+ "SDN_CONTROLLER_TYPE": ["TFS", "IXIA"],
+ "DATAPLANE_SUPPORT": ["CISCO", "FRR"],
+ "SUBSCRIBE_ALERTS": [True, False],
+}
+
+
+def generate_flag_combinations():
+ """Generates all Cartesian product combinations of configuration flags."""
+ keys = list(FLAG_OPTIONS.keys())
+ values = list(FLAG_OPTIONS.values())
+ for prod in product(*values):
+ combo = dict(zip(keys, prod))
+ combo.update(
+ {
+ "SUBSCRIBE_ALERTS_URL": "http://127.0.0.1:8085/alert",
+ "HRAT_IP": "10.0.0.1",
+ "E2E_OPTICAL_IP": "127.0.0.1",
+ "TFS_IP": "127.0.0.1",
+ "IXIA_IP": "127.0.0.1",
+ "TFS_E2E_IP": "127.0.0.1",
+ "RESTCONF_IP": "192.168.27.189",
+ "API_USERNAME": "admin",
+ "API_PASSWORD": "password",
+ }
+ )
+ yield combo
+
+
+class MockResponse:
+ """Mock response object for HTTP and controller connectors."""
+
+ def __init__(self, status_code=200, text="OK", json_data=None):
+ self.status_code = status_code
+ self.text = text
+ self.ok = status_code < 400
+ self._json_data = (
+ json_data
+ if json_data is not None
+ else {"success": True, "tapi-notification:output": {"subscription-id": "mock-sub-123"}}
+ )
+
+ def json(self):
+ return self._json_data
+
+ def raise_for_status(self):
+ if not self.ok:
+ raise Exception(f"HTTP Error {self.status_code}")
+
+
+@pytest.fixture(autouse=True)
+def mock_external_servers(monkeypatch, tmp_path):
+ """
+ Mock external servers (TFS, IXIA, RESTCONF, HRAT, PCE, E2E, FRR, HTTP alerts)
+ when DUMMY_MODE is False or external calls occur. Assumes good/successful responses.
+ """
+ temp_templates = tmp_path / "templates"
+ temp_templates.mkdir(exist_ok=True)
+ monkeypatch.setattr("src.utils.dump_templates.TEMPLATES_PATH", str(temp_templates))
+
+ def mock_http_call(*args, **kwargs):
+ return MockResponse(200, text="OK")
+
+ # Patch requests module methods
+ monkeypatch.setattr("requests.get", mock_http_call)
+ monkeypatch.setattr("requests.post", mock_http_call)
+ monkeypatch.setattr("requests.put", mock_http_call)
+ monkeypatch.setattr("requests.delete", mock_http_call)
+ monkeypatch.setattr("requests.request", mock_http_call)
+
+ class MockSession:
+ def __init__(self):
+ self.auth = None
+
+ def get(self, *args, **kwargs):
+ return MockResponse(200, text='')
+
+ def post(self, *args, **kwargs):
+ return MockResponse(200, text="OK")
+
+ def put(self, *args, **kwargs):
+ return MockResponse(200, text="OK")
+
+ def delete(self, *args, **kwargs):
+ return MockResponse(200, text="OK")
+
+ monkeypatch.setattr("requests.Session", MockSession)
+
+ # Patch TFS connectors
+ try:
+ from src.realizer.tfs.helpers.tfs_connector import tfs_connector
+
+ monkeypatch.setattr(tfs_connector, "webui_post", lambda self, *a, **kw: MockResponse(200, "OK"))
+ monkeypatch.setattr(tfs_connector, "nbi_post", lambda self, *a, **kw: MockResponse(200, "OK"))
+ monkeypatch.setattr(tfs_connector, "nbi_delete", lambda self, *a, **kw: MockResponse(200, "OK"))
+ monkeypatch.setattr(tfs_connector, "ipowdm_post", lambda self, *a, **kw: MockResponse(200, "OK"))
+ monkeypatch.setattr(tfs_connector, "ipowdm_put", lambda self, *a, **kw: MockResponse(200, "OK"))
+ monkeypatch.setattr(tfs_connector, "get_network_topology", lambda self, *a, **kw: ([], MockResponse(200, "OK")))
+ except Exception:
+ pass
+
+ try:
+ from src.realizer.restconf.connectors.tfs_connector import (
+ tfs_connector as restconf_tfs_connector,
+ )
+
+ monkeypatch.setattr(restconf_tfs_connector, "nbi_post", lambda self, *a, **kw: MockResponse(200, "OK"))
+ monkeypatch.setattr(restconf_tfs_connector, "nbi_delete", lambda self, *a, **kw: MockResponse(200, "OK"))
+ except Exception:
+ pass
+
+ # Patch IXIA controller
+ try:
+ from src.realizer.ixia.helpers.NEII_V4 import NEII_controller
+
+ monkeypatch.setattr(NEII_controller, "nscNEII", lambda self, *a, **kw: MockResponse(200, "OK"))
+ except Exception:
+ pass
+
+ # Patch FRR and Cisco connectors / Netmiko
+ try:
+ from src.realizer.restconf.connectors.frr_connector import frr_connector
+
+ monkeypatch.setattr(frr_connector, "execute_commands", lambda self, commands: None)
+ except Exception:
+ pass
+
+ try:
+ from src.realizer.tfs.helpers.cisco_connector import cisco_connector
+
+ monkeypatch.setattr(cisco_connector, "execute_commands", lambda self, commands: None)
+ except Exception:
+ pass
+
+ try:
+ import netmiko
+
+ monkeypatch.setattr(netmiko, "ConnectHandler", lambda **kw: MagicMock())
+ except Exception:
+ pass
+
+
+@pytest.fixture
+def app(temp_sqlite_db):
+ """Creates the Flask app with default configuration."""
+ app = create_app()
+ return app
+
+
+@pytest.fixture
+def set_flags(app):
+ """Directly updates configuration flags in app.config."""
+
+ def _set(flags: dict):
+ for k, v in flags.items():
+ app.config[k] = v
+
+ return _set
+
+
+def load_request_files():
+ """Recursively loads all JSON request files from subdirectories under requests/."""
+ test_cases = []
+ # Search all .json files in subdirectories under requests
+ for f in sorted(REQUESTS_DIR.rglob("*.json")):
+ try:
+ with open(f, encoding="utf-8") as file:
+ json_data = json.load(file)
+ rel_path = f.relative_to(REQUESTS_DIR).as_posix()
+ test_cases.append((rel_path, json_data))
+ except Exception:
+ pass
+ return test_cases
+
+
+def generate_test_cases():
+ """Generates all 6,144 flag combinations paired across all request files and namespaces."""
+ requests = load_request_files()
+ if not requests:
+ return
+ flag_combos = list(generate_flag_combinations())
+ num_reqs = len(requests)
+ num_ns = len(NAMESPACES)
+
+ for i, flags in enumerate(flag_combos):
+ rel_path, json_data = requests[i % num_reqs]
+ namespace = NAMESPACES[i % num_ns]
+ yield (rel_path, json_data, namespace, flags)
+
+
+@pytest.mark.parametrize(
+ "rel_path, json_data, namespace, flags",
+ list(generate_test_cases()),
+ ids=lambda param: (
+ param if isinstance(param, str) else (param.get("PLANNER_TYPE", "") if isinstance(param, dict) else None)
+ ),
+)
+def test_add_and_delete_flow(app, rel_path, json_data, namespace, flags, set_flags, temp_sqlite_db):
+ with app.app_context():
+ set_flags(flags)
+
+ controller_type = namespace.upper()
+ controller = NSController(controller_type=controller_type)
+ api = Api(controller)
+
+ # Execute creation based on namespace
+ if namespace in ["tfs", "ixia", "e2e"]:
+ data, code = api.add_flow(json_data)
+ elif namespace == "restconf":
+ data, code = api.add_network_slice_service(json_data)
+ else:
+ pytest.fail(f"Unsupported namespace: {namespace}")
+
+ # Strict contract validation:
+ if code in [200, 201]:
+ # Success path: must have success=True and proper payload structure
+ assert isinstance(data, dict) and data.get("success") is True, (
+ f"Successful creation with code {code} must have success=True: {data}"
+ )
+ slice_id = None
+ payload_data = data.get("data")
+ if isinstance(payload_data, dict):
+ slices = payload_data.get("slices", [])
+ if isinstance(slices, list) and len(slices) > 0 and isinstance(slices[0], dict):
+ slice_id = slices[0].get("id")
+
+ if namespace in ["tfs", "ixia", "e2e"]:
+ if slice_id:
+ _, delete_code = api.delete_flows(slice_id=slice_id)
+ else:
+ _, delete_code = api.delete_flows()
+ elif namespace == "restconf":
+ _, delete_code = api.delete_slice_services()
+
+ assert delete_code in [200, 204, 404], f"Deletion failed for slice '{slice_id}' in namespace '{namespace}'"
+
+ elif code in [400, 422]:
+ # Controlled business rejection path: must have success=False and explicit error reason
+ assert isinstance(data, dict) and data.get("success") is False, (
+ f"Controlled rejection with code {code} must have success=False: {data}"
+ )
+ assert data.get("error"), f"Rejection with code {code} must return an error reason: {data}"
+
+ else:
+ pytest.fail(
+ f"Unexpected status code {code} for request '{rel_path}' in namespace '{namespace}' "
+ f"with flags: {flags}. Response: {data}"
+ )
diff --git a/src/tests/test_initialization.py b/src/tests/test_initialization.py
index b4fed330c383507f83f8d6c160f3066d81593450..b445847869289648d80e6c18cb2722b0631f6eb5 100644
--- a/src/tests/test_initialization.py
+++ b/src/tests/test_initialization.py
@@ -14,14 +14,14 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
-import os
+from unittest.mock import patch
+
import pytest
from flask import Flask
-from unittest.mock import patch
-from src.main import NSController
-from src.config.config import create_config
from app import create_app
+from src.config.config import create_config
+from src.main import NSController
def test_init_default_values():
@@ -82,25 +82,23 @@ def test_create_app_initialization(temp_sqlite_db):
assert "API_USERNAME" in app.config
-def test_controller_monitoring():
+def test_controller_monitoring(flask_app):
"""Test NSController.monitoring dispatches to realizer and mapper with MONITOR action."""
- controller = NSController(controller_type="RESTCONF")
- slice_id = "slice-mon-1"
- slo_sle_template = {"slo-policy": {"metric-bound": []}}
-
- 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)
- 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},
- action="MONITOR",
- controller_type="RESTCONF"
- )
- mock_mapper.assert_called_once_with(
- {"slice_id": slice_id, "slo_sle_template": slo_sle_template},
- action="MONITOR"
- )
+ with flask_app.app_context():
+ flask_app.config["DUMMY_MODE"] = False
+ controller = NSController(controller_type="RESTCONF")
+ slice_id = "slice-mon-1"
+ slo_sle_template = {"slo-policy": {"metric-bound": []}}
+
+ 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)
+ 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}, action="MONITOR", controller_type="RESTCONF"
+ )
+ mock_mapper.assert_called_once_with(
+ {"slice_id": slice_id, "slo_sle_template": slo_sle_template}, action="MONITOR"
+ )
diff --git a/src/tests/test_mapper.py b/src/tests/test_mapper.py
index 65bb0d6ee3144c641cb7da4ee230b90cb250340a..8b4f18488f3266a41b1b548b5ef8edc30665181d 100644
--- a/src/tests/test_mapper.py
+++ b/src/tests/test_mapper.py
@@ -1,1041 +1,837 @@
-# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-# This file is an original contribution from Telefonica Innovación Digital S.L.
-
-import pytest
-import logging
-from unittest.mock import patch, MagicMock, call
-from flask import Flask
-from src.mapper.main import mapper
-from src.mapper.slo_viability import slo_viability
-
-
-@pytest.fixture
-def sample_ietf_intent():
- """Fixture providing sample IETF network slice intent."""
- return {
- "ietf-network-slice-service:network-slice-services": {
- "slice-service": [{
- "id": "slice-service-12345",
- "description": "Test network slice",
- "service-tags": {"tag-type": {"value": "L2VPN"}}
- }],
- "slo-sle-templates": {
- "slo-sle-template": [{
- "id": "profile1",
- "slo-policy": {
- "metric-bound": [
- {
- "metric-type": "one-way-bandwidth",
- "metric-unit": "kbps",
- "bound": 1000
- },
- {
- "metric-type": "one-way-delay-maximum",
- "metric-unit": "milliseconds",
- "bound": 10
- }
- ]
- }
- }]
- }
- }
- }
-
-
-@pytest.fixture
-def sample_nrp_view():
- """Fixture providing sample NRP view."""
- return [
- {
- "id": "nrp-1",
- "available": True,
- "slices": [],
- "slos": [
- {
- "metric-type": "one-way-bandwidth",
- "bound": 1500
- },
- {
- "metric-type": "one-way-delay-maximum",
- "bound": 8
- }
- ]
- },
- {
- "id": "nrp-2",
- "available": True,
- "slices": [],
- "slos": [
- {
- "metric-type": "one-way-bandwidth",
- "bound": 500
- },
- {
- "metric-type": "one-way-delay-maximum",
- "bound": 15
- }
- ]
- },
- {
- "id": "nrp-3",
- "available": False,
- "slices": [],
- "slos": [
- {
- "metric-type": "one-way-bandwidth",
- "bound": 2000
- },
- {
- "metric-type": "one-way-delay-maximum",
- "bound": 5
- }
- ]
- }
- ]
-
-
-@pytest.fixture
-def mock_app():
- """Fixture providing mock Flask app context."""
- app = Flask(__name__)
- app.config = {
- "NRP_ENABLED": False,
- "PLANNER_ENABLED": False,
- "SERVER_NAME": "localhost",
- "APPLICATION_ROOT": "/",
- "PREFERRED_URL_SCHEME": "http"
- }
- return app
-
-
-@pytest.fixture
-def app_context(mock_app):
- """Fixture providing Flask application context."""
- with mock_app.app_context():
- yield mock_app
-
-
-class TestSloViability:
- """Tests for slo_viability function."""
-
- def test_slo_viability_meets_all_requirements(self):
- """Test when NRP meets all SLO requirements."""
- slice_slos = [
- {
- "metric-type": "one-way-bandwidth",
- "bound": 1000
- },
- {
- "metric-type": "one-way-delay-maximum",
- "bound": 10
- }
- ]
-
- nrp_slos = {
- "slos": [
- {
- "metric-type": "one-way-bandwidth",
- "bound": 1500
- },
- {
- "metric-type": "one-way-delay-maximum",
- "bound": 8
- }
- ]
- }
-
- viable, score = slo_viability(slice_slos, nrp_slos)
-
- assert viable is True
- assert score > 0
-
- def test_slo_viability_fails_bandwidth_minimum(self):
- """Test when NRP doesn't meet minimum bandwidth requirement."""
- slice_slos = [
- {
- "metric-type": "one-way-bandwidth",
- "bound": 1000
- }
- ]
-
- nrp_slos = {
- "slos": [
- {
- "metric-type": "one-way-bandwidth",
- "bound": 500 # Less than required
- }
- ]
- }
-
- viable, score = slo_viability(slice_slos, nrp_slos)
-
- assert viable is False
- assert score == 0
-
- def test_slo_viability_fails_delay_maximum(self):
- """Test when NRP doesn't meet maximum delay requirement."""
- slice_slos = [
- {
- "metric-type": "one-way-delay-maximum",
- "bound": 10
- }
- ]
-
- nrp_slos = {
- "slos": [
- {
- "metric-type": "one-way-delay-maximum",
- "bound": 15 # Greater than maximum allowed
- }
- ]
- }
-
- viable, score = slo_viability(slice_slos, nrp_slos)
-
- assert viable is False
- assert score == 0
-
- def test_slo_viability_multiple_metrics_partial_failure(self):
- """Test when one metric fails in a multi-metric comparison."""
- slice_slos = [
- {
- "metric-type": "one-way-bandwidth",
- "bound": 1000
- },
- {
- "metric-type": "one-way-delay-maximum",
- "bound": 10
- }
- ]
-
- nrp_slos = {
- "slos": [
- {
- "metric-type": "one-way-bandwidth",
- "bound": 1500 # OK
- },
- {
- "metric-type": "one-way-delay-maximum",
- "bound": 15 # NOT OK
- }
- ]
- }
-
- viable, score = slo_viability(slice_slos, nrp_slos)
-
- assert viable is False
- assert score == 0
-
- def test_slo_viability_flexibility_score_calculation(self):
- """Test flexibility score calculation."""
- slice_slos = [
- {
- "metric-type": "one-way-bandwidth",
- "bound": 1000
- }
- ]
-
- nrp_slos = {
- "slos": [
- {
- "metric-type": "one-way-bandwidth",
- "bound": 2000 # 100% better than requirement
- }
- ]
- }
-
- viable, score = slo_viability(slice_slos, nrp_slos)
-
- assert viable is True
- # Flexibility = (2000 - 1000) / 1000 = 1.0
- assert score == 1.0
-
- def test_slo_viability_empty_slos(self):
- """Test with empty SLO list."""
- slice_slos = []
- nrp_slos = {"slos": []}
-
- viable, score = slo_viability(slice_slos, nrp_slos)
-
- assert viable is True
- assert score == 0
-
- def test_slo_viability_no_matching_metrics(self):
- """Test when there are no matching metric types."""
- slice_slos = [
- {
- "metric-type": "one-way-bandwidth",
- "bound": 1000
- }
- ]
-
- nrp_slos = {
- "slos": [
- {
- "metric-type": "two-way-bandwidth",
- "bound": 1500
- }
- ]
- }
-
- viable, score = slo_viability(slice_slos, nrp_slos)
-
- # Should still return True as no metrics failed
- assert viable is True
- assert score == 0
-
- def test_slo_viability_packet_loss_maximum_type(self):
- """Test packet loss as maximum constraint type."""
- slice_slos = [
- {
- "metric-type": "one-way-packet-loss",
- "bound": 0.01 # 1% maximum acceptable
- }
- ]
-
- nrp_slos = {
- "slos": [
- {
- "metric-type": "one-way-packet-loss",
- "bound": 0.005 # 0.5% NRP loss
- }
- ]
- }
-
- viable, score = slo_viability(slice_slos, nrp_slos)
-
- assert viable is True
- assert score > 0
-
-
-class TestMapper:
- """Tests for mapper function."""
-
- def test_mapper_with_nrp_disabled_and_planner_disabled(self, app_context, sample_ietf_intent):
- """Test mapper when both NRP and Planner are disabled."""
- app_context.config = {
- "NRP_ENABLED": False,
- "PLANNER_ENABLED": False
- }
-
- payload = {
- "intent": sample_ietf_intent
- }
-
- result = mapper(payload)
-
- assert result == ([sample_ietf_intent], None)
-
- @patch('src.mapper.main.Planner')
- def test_mapper_with_planner_enabled(self, mock_planner_class, app_context, sample_ietf_intent):
- """Test mapper when Planner is enabled."""
- app_context.config = {
- "NRP_ENABLED": False,
- "PLANNER_ENABLED": True,
- "PLANNER_TYPE":"ENERGY"
- }
-
- mock_planner_instance = MagicMock()
- mock_planner_instance.planner.return_value = {"path": "node1->node2->node3"}
- mock_planner_class.return_value = mock_planner_instance
-
- 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)
-
- @patch('src.mapper.main.realizer')
- def test_mapper_with_nrp_enabled_finds_best_nrp(self, mock_realizer, app_context, sample_ietf_intent, sample_nrp_view):
- """Test mapper with NRP enabled finds the best NRP."""
- app_context.config = {
- "NRP_ENABLED": True,
- "PLANNER_ENABLED": False,
- }
-
- mock_realizer.return_value = sample_nrp_view
-
- 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")
- assert result == ([sample_ietf_intent], None)
-
- @patch('src.mapper.main.realizer')
- def test_mapper_with_nrp_enabled_no_viable_candidates(self, mock_realizer, app_context, sample_ietf_intent):
- """Test mapper when no viable NRPs are found."""
- app_context.config = {
- "NRP_ENABLED": True,
- "PLANNER_ENABLED": False
- }
-
- # All NRPs are unavailable
- nrp_view = [
- {
- "id": "nrp-1",
- "available": False,
- "slices": [],
- "slos": [
- {
- "metric-type": "one-way-bandwidth",
- "bound": 500
- }
- ]
- }
- ]
-
- mock_realizer.return_value = nrp_view
-
- payload = {
- "intent": sample_ietf_intent
- }
-
- result = mapper(payload)
-
- assert result == ([sample_ietf_intent], None)
-
- @patch('src.mapper.main.realizer')
- def test_mapper_with_nrp_enabled_creates_new_nrp(self, mock_realizer, app_context, sample_ietf_intent):
- """Test mapper creates new NRP when no suitable candidate exists."""
- app_context.config = {
- "NRP_ENABLED": True,
- "PLANNER_ENABLED": False
- }
-
- # No viable NRPs
- nrp_view = []
-
- mock_realizer.side_effect = [nrp_view, None] # First call returns empty, second for CREATE
-
- 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"]
- assert len(create_call) > 0
-
- @patch('src.mapper.main.realizer')
- def test_mapper_with_nrp_and_planner_both_enabled(self, mock_realizer, app_context, sample_ietf_intent, sample_nrp_view):
- """Test mapper when both NRP and Planner are enabled."""
- app_context.config = {
- "NRP_ENABLED": True,
- "PLANNER_ENABLED": True,
- "PLANNER_TYPE":"ENERGY"
- }
-
- mock_realizer.return_value = sample_nrp_view
-
- with patch('src.mapper.main.Planner') as mock_planner_class:
- mock_planner_instance = MagicMock()
- mock_planner_instance.planner.return_value = {"path": "optimized_path"}
- mock_planner_class.return_value = mock_planner_instance
-
- payload = {
- "intent": sample_ietf_intent
- }
- result = mapper(payload)
-
- # Planner should be called and return the result
- assert result == ([sample_ietf_intent],{"path": "optimized_path"})
-
- @patch('src.mapper.main.realizer')
- def test_mapper_updates_best_nrp_with_slice(self, mock_realizer, app_context, sample_ietf_intent, sample_nrp_view):
- """Test mapper updates best NRP with new slice."""
- app_context.config = {
- "NRP_ENABLED": True,
- "PLANNER_ENABLED": False
- }
-
- mock_realizer.return_value = sample_nrp_view
-
- 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"]
- assert len(update_calls) > 0
-
- @patch('src.mapper.main.realizer')
- def test_mapper_extracts_slos_correctly(self, mock_realizer, app_context, sample_ietf_intent):
- """Test that mapper correctly extracts SLOs from intent."""
- app_context.config = {
- "NRP_ENABLED": True,
- "PLANNER_ENABLED": False
- }
-
- mock_realizer.return_value = []
-
- payload = {
- "intent": sample_ietf_intent
- }
- mapper(payload)
-
- # Verify the function processed the intent
- assert mock_realizer.called
-
- @patch('src.mapper.main.logging')
- def test_mapper_logs_debug_info(self, mock_logging, app_context, sample_ietf_intent, sample_nrp_view):
- """Test mapper logs debug information."""
- app_context.config = {
- "NRP_ENABLED": True,
- "PLANNER_ENABLED": False
- }
-
- with patch('src.mapper.main.realizer') as mock_realizer:
- mock_realizer.return_value = sample_nrp_view
-
- payload = {
- "intent": sample_ietf_intent
- }
- mapper(payload)
-
- # Verify debug logging was called
- assert mock_logging.debug.called
-
-
-class TestMapperIntegration:
- """Integration tests for mapper functionality."""
-
- def test_mapper_complete_nrp_workflow(self, app_context, sample_ietf_intent, sample_nrp_view):
- """Test complete NRP mapping workflow."""
- app_context.config = {
- "NRP_ENABLED": True,
- "PLANNER_ENABLED": False
- }
-
- with patch('src.mapper.main.realizer') as mock_realizer:
- mock_realizer.return_value = sample_nrp_view
-
- payload = {
- "intent": sample_ietf_intent
- }
- result = mapper(payload)
-
- # Verify the workflow sequence
- assert mock_realizer.call_count >= 1
- first_call = mock_realizer.call_args_list[0]
- assert first_call[0][1] is True # need_nrp parameter
- assert first_call[0][2] == "READ" # READ operation
-
- def test_mapper_complete_planner_workflow(self, app_context, sample_ietf_intent):
- """Test complete Planner workflow."""
- app_context.config = {
- "NRP_ENABLED": False,
- "PLANNER_ENABLED": True,
- "PLANNER_TYPE":"ENERGY"
- }
-
- expected_path = {
- "path": "node1->node2->node3",
- "cost": 10,
- "latency": 5
- }
-
- with patch('src.mapper.main.Planner') as mock_planner_class:
- mock_planner_instance = MagicMock()
- mock_planner_instance.planner.return_value = expected_path
- mock_planner_class.return_value = mock_planner_instance
-
- payload = {
- "intent": sample_ietf_intent
- }
- result = mapper(payload)
-
- assert result == ([sample_ietf_intent],expected_path)
- mock_planner_instance.planner.assert_called_once()
-
- def test_mapper_with_invalid_nrp_response(self, app_context, sample_ietf_intent):
- """Test mapper behavior with invalid NRP response."""
- app_context.config = {
- "NRP_ENABLED": True,
- "PLANNER_ENABLED": False
- }
-
- # Invalid NRP without expected fields
- invalid_nrp = {
- "id": "nrp-invalid"
- # Missing 'available' and 'slos' fields
- }
-
- with patch('src.mapper.main.realizer') as mock_realizer:
- mock_realizer.return_value = [invalid_nrp]
-
- # Should handle gracefully
- try:
- payload = {
- "intent": sample_ietf_intent
- }
- result = mapper(payload)
- except (KeyError, TypeError):
- # Expected to fail gracefully
- pass
-
- def test_mapper_with_missing_slos_in_intent(self, app_context):
- """Test mapper behavior when intent has no SLOs."""
- app_context.config = {
- "NRP_ENABLED": True,
- "PLANNER_ENABLED": False
- }
-
- invalid_intent = {
- "ietf-network-slice-service:network-slice-services": {
- "slice-service": [{
- "id": "slice-1"
- }],
- "slo-sle-templates": {
- "slo-sle-template": [{
- "id": "profile1",
- "slo-policy": {
- # No metric-bound key
- }
- }]
- }
- }
- }
-
- payload = {
- "intent": invalid_intent
- }
- try:
- mapper(payload)
- except (KeyError, TypeError):
- # Expected behavior
- pass
-
-
-class TestSloViabilityEdgeCases:
- """Edge case tests for slo_viability function."""
-
- def test_slo_viability_with_zero_bound(self):
- """Test handling of zero bounds in SLO."""
- slice_slos = [
- {
- "metric-type": "one-way-bandwidth",
- "bound": 0
- }
- ]
-
- nrp_slos = {
- "slos": [
- {
- "metric-type": "one-way-bandwidth",
- "bound": 100
- }
- ]
- }
-
- # Should handle zero division gracefully or fail as expected
- try:
- viable, score = slo_viability(slice_slos, nrp_slos)
- except (ZeroDivisionError, ValueError):
- pass
-
- def test_slo_viability_with_very_large_bounds(self):
- """Test handling of very large SLO bounds."""
- slice_slos = [
- {
- "metric-type": "one-way-bandwidth",
- "bound": 1e10
- }
- ]
-
- nrp_slos = {
- "slos": [
- {
- "metric-type": "one-way-bandwidth",
- "bound": 2e10
- }
- ]
- }
-
- viable, score = slo_viability(slice_slos, nrp_slos)
-
- assert viable is True
- assert isinstance(score, (int, float))
-
- def test_slo_viability_all_delay_types(self):
- """Test handling of all delay metric types."""
- delay_types = [
- "one-way-delay-maximum",
- "two-way-delay-maximum",
- "one-way-delay-percentile",
- "two-way-delay-percentile",
- "one-way-delay-variation-maximum",
- "two-way-delay-variation-maximum"
- ]
-
- for delay_type in delay_types:
- slice_slos = [{"metric-type": delay_type, "bound": 10}]
- nrp_slos = {"slos": [{"metric-type": delay_type, "bound": 8}]}
-
- viable, score = slo_viability(slice_slos, nrp_slos)
-
- assert viable is True
- assert score >= 0
-
-
-class TestMapperSubmodules:
- """Tests for mapper submodules: extract_sdp_info, process_connectivity, aggregate_monitoring, get_service_template."""
-
- def test_extract_sdp_info(self):
- from src.mapper.extract_sdp_info import extract_sdp_info
-
- slice_service = {
- "sdps": {
- "sdp": [
- {
- "id": "sdp-1",
- "sdp-ip-address": "10.0.0.1",
- "service-match-criteria": {
- "match-criterion": [{"target-connection-group-id": "cg-1"}]
- }
- }
- ]
- }
- }
- sdp, mc = extract_sdp_info("sdp-1", slice_service, "cg-1", None)
- assert sdp["id"] == "sdp-1"
- assert mc is not None
-
- def test_process_connectivity(self):
- from src.mapper.process_connnectivity import process_connectivity
-
- slice_service = {
- "sdps": {
- "sdp": [
- {"id": "sdp-1", "sdp-ip-address": "10.0.0.1"},
- {"id": "sdp-2", "sdp-ip-address": "10.0.0.2"}
- ]
- }
- }
- conn_construct = {"a2a-sdp": ["sdp-1", "sdp-2"]}
- res = process_connectivity("cg-1", "ietf-vpn-common:any-to-any", conn_construct, "cc-1", slice_service)
- assert isinstance(res, list)
- assert len(res) == 2
-
- def test_aggregate_monitoring(self, flask_app):
- from src.mapper.aggregate_monitoring import aggregate_monitoring
-
- flask_app.config["TELEMETRY_CACHE"] = {
- "slice-1": {
- "A-B": {"bandwidth": 1000, "latency": 5}
- }
- }
- slo_template = {
- "slo-policy": {
- "metric-bound": [
- {"metric-type": "ietf-network-slice-service:two-way-bandwidth", "bound": 500},
- {"metric-type": "ietf-network-slice-service:two-way-delay-maximum", "bound": 10}
- ]
- }
- }
- with flask_app.app_context(), patch("src.mapper.aggregate_monitoring.upsert_telemetry"):
- aggregate_monitoring("slice-1", slo_template)
-
- def test_get_service_template_and_get_template(self):
- from src.mapper.get_service_template import get_service_template
- from src.mapper.get_template import get_template
-
- available = [{"id": "tmpl-1", "slo-policy": {}}]
- elem = {"slo-sle-template": "tmpl-1"}
- res = get_service_template(elem, available)
- assert res == {"id": "tmpl-1", "slo-policy": {}}
-
- tmpl = get_template("tmpl-1", available)
- assert tmpl["id"] == "tmpl-1"
-
- def test_process_connectivity_hub_spoke_and_p2p(self):
- from src.mapper.process_connnectivity import process_connectivity
-
- slice_service = {
- "sdps": {
- "sdp": [
- {"id": "sdp-1", "sdp-ip-address": "10.0.0.1"},
- {"id": "sdp-2", "sdp-ip-address": "10.0.0.2"}
- ]
- }
- }
-
- # Hub-Spoke
- hs_construct = {"p2mp-sender-sdp": "sdp-1", "p2mp-receiver-sdp": ["sdp-2"]}
- res_hs = process_connectivity("cg-1", "ietf-vpn-common:hub-spoke", hs_construct, "cc-1", slice_service)
- assert len(res_hs) == 2
- assert res_hs[0]["type"] == "sender"
- assert res_hs[1]["type"] == "receiver"
-
- # Point-to-Point
- p2p_construct = {"p2p-sender-sdp": "sdp-1", "p2p-receiver-sdp": "sdp-2"}
- res_p2p = process_connectivity("cg-1", "point-to-point", p2p_construct, "cc-1", slice_service)
- assert len(res_p2p) == 2
- assert res_p2p[0]["type"] == "sender"
- assert res_p2p[1]["type"] == "receiver"
-
-
-class TestMapperMonitoring:
- """Comprehensive tests for MONITOR action in mapper and aggregate_monitoring."""
-
- def test_mapper_monitor_action(self, flask_app):
- payload = {
- "slice_id": "slice-mon-100",
- "slo_sle_template": {
- "slo-policy": {
- "metric-bound": [
- {"metric-type": "ietf-network-slice-service:two-way-bandwidth", "bound": 100},
- {"metric-type": "ietf-network-slice-service:two-way-delay-maximum", "bound": 20}
- ]
- }
- }
- }
- flask_app.config["TELEMETRY_CACHE"]["slice-mon-100"] = {
- "link-1": {"bandwidth": 150, "latency": 15}
- }
-
- with flask_app.app_context(), \
- patch("src.mapper.aggregate_monitoring.upsert_telemetry") as mock_upsert:
- metrics = mapper(payload, action="MONITOR")
- assert metrics["slice_id"] == "slice-mon-100"
- assert metrics["slo_sle_compliance"]["is_compliant"] is True
- assert metrics["slo_sle_compliance"]["violated_metrics"] == []
- mock_upsert.assert_called_once()
-
- def test_aggregate_monitoring_latency_and_bandwidth_violations(self, flask_app):
- from src.mapper.aggregate_monitoring import aggregate_monitoring
-
- slice_id = "slice-mon-violations"
- slo_template = {
- "slo-policy": {
- "metric-bound": [
- {"metric-type": "ietf-network-slice-service:two-way-bandwidth", "bound": 500},
- {"metric-type": "ietf-network-slice-service:two-way-delay-maximum", "bound": 10}
- ]
- }
- }
-
- # Case 1: Latency violation only
- flask_app.config["TELEMETRY_CACHE"][slice_id] = {
- "link-1": {"bandwidth": 600, "latency": 15}
- }
- with flask_app.app_context(), patch("src.mapper.aggregate_monitoring.upsert_telemetry"):
- res_lat = aggregate_monitoring(slice_id, slo_template)
- assert res_lat["slo_sle_compliance"]["is_compliant"] is False
- assert res_lat["slo_sle_compliance"]["violated_metrics"] == ["latency"]
-
- # Case 2: Bandwidth violation only
- flask_app.config["TELEMETRY_CACHE"][slice_id] = {
- "link-1": {"bandwidth": 400, "latency": 5}
- }
- with flask_app.app_context(), patch("src.mapper.aggregate_monitoring.upsert_telemetry"):
- res_bw = aggregate_monitoring(slice_id, slo_template)
- assert res_bw["slo_sle_compliance"]["is_compliant"] is False
- assert res_bw["slo_sle_compliance"]["violated_metrics"] == ["bandwidth"]
-
- # Case 3: Both violated
- flask_app.config["TELEMETRY_CACHE"][slice_id] = {
- "link-1": {"bandwidth": 300, "latency": 25}
- }
- with flask_app.app_context(), patch("src.mapper.aggregate_monitoring.upsert_telemetry"):
- res_both = aggregate_monitoring(slice_id, slo_template)
- assert res_both["slo_sle_compliance"]["is_compliant"] is False
- assert set(res_both["slo_sle_compliance"]["violated_metrics"]) == {"latency", "bandwidth"}
-
- def test_aggregate_monitoring_empty_links_error(self, flask_app):
- from src.mapper.aggregate_monitoring import aggregate_monitoring
-
- flask_app.config["TELEMETRY_CACHE"]["slice-empty"] = {}
- with flask_app.app_context():
- with pytest.raises(Exception, match="No telemetry data available for slice"):
- aggregate_monitoring("slice-empty", {})
-
- def test_aggregate_monitoring_cache_not_initialized_error(self, flask_app):
- from src.mapper.aggregate_monitoring import aggregate_monitoring
-
- flask_app.config["TELEMETRY_CACHE"].pop("slice-missing", None)
- with flask_app.app_context():
- with pytest.raises(Exception, match="Telemetry cache for slice .* is not initialized"):
- aggregate_monitoring("slice-missing", {})
-
- def test_mapper_connection_groups_and_template_overrides(self, flask_app):
- from src.mapper.main import mapper
-
- flask_app.config["DUMMY_MODE"] = False
-
- intent_full = {
- "ietf-network-slice-service:network-slice-services": {
- "slo-sle-templates": {
- "slo-sle-template": [
- {"id": "service-tmpl", "bound": 10},
- {"id": "group-tmpl", "bound": 20},
- {"id": "construct-tmpl", "bound": 30}
- ]
- },
- "slice-service": [
- {
- "id": "slice-full-1",
- "slo-sle-template": "service-tmpl",
- "service-tags": {
- "tag-type": {
- "ietf-network-slice-service:service": {
- "tag-type-value": ["L2"]
- }
- }
- },
- "sdps": {
- "sdp": [
- {"id": "sdp-1", "node-id": "N1"},
- {"id": "sdp-2", "node-id": "N2"}
- ]
- },
- "connection-groups": {
- "connection-group": [
- {
- "id": "cg-1",
- "slo-sle-template": "group-tmpl",
- "connectivity-type": "point-to-point",
- "connectivity-construct": [
- {
- "id": "cc-1",
- "slo-sle-template": "construct-tmpl",
- "p2p-sender-sdp": "sdp-1",
- "p2p-receiver-sdp": "sdp-2"
- },
- {
- "id": "cc-2",
- "p2p-sender-sdp": "sdp-1",
- "p2p-receiver-sdp": "sdp-2"
- }
- ]
- }
- ]
- }
- }
- ]
- }
- }
-
- payload = {"intent": intent_full, "is_update": False}
-
- with flask_app.app_context(), \
- patch("src.mapper.main.get_data_store", return_value=None), \
- patch("src.mapper.main.normalize_libyang_data", side_effect=lambda x: x), \
- patch("src.mapper.main.save_data") as mock_save_data:
- services, rules = mapper(payload, controller_type="RESTCONF")
- assert len(services) == 1
- assert services[0]["id"] == "slice-full-1-cg-1-cc-1"
- assert services[0]["template"] == {"id": "construct-tmpl", "bound": 30}
- assert services[0]["connectivity_type"] == "point-to-point"
- mock_save_data.assert_called_once()
-
- def test_mapper_non_p2p_and_group_template_inheritance(self, flask_app):
- from src.mapper.main import mapper
-
- flask_app.config["DUMMY_MODE"] = True
-
- intent_non_p2p = {
- "ietf-network-slice-service:network-slice-services": {
- "slo-sle-templates": {
- "slo-sle-template": [
- {"id": "service-tmpl", "bound": 10},
- {"id": "group-tmpl", "bound": 20}
- ]
- },
- "slice-service": [
- {
- "id": "slice-non-p2p",
- "slo-sle-template": "service-tmpl",
- "sdps": {
- "sdp": [
- {"id": "sdp-1", "node-id": "N1"},
- {"id": "sdp-2", "node-id": "N2"}
- ]
- },
- "connection-groups": {
- "connection-group": [
- {
- "id": "cg-1",
- "slo-sle-template": "group-tmpl",
- "connectivity-type": "ietf-vpn-common:any-to-any",
- "connectivity-construct": [
- {
- "id": "cc-1",
- "a2a-sdp": ["sdp-1", "sdp-2"]
- },
- {
- "id": "cc-2",
- "a2a-sdp": ["sdp-1", "sdp-2"]
- }
- ]
- }
- ]
- }
- }
- ]
- }
- }
-
- payload = {"intent": intent_non_p2p}
-
- with flask_app.app_context(), \
- patch("src.mapper.main.get_data_store", return_value=None), \
- patch("src.mapper.main.normalize_libyang_data", side_effect=lambda x: x):
- services, _ = mapper(payload, controller_type="RESTCONF")
- assert len(services) == 2
- assert services[0]["template"] == {"id": "group-tmpl", "bound": 20}
- assert services[1]["template"] == {"id": "group-tmpl", "bound": 20}
-
- def test_mapper_empty_sdps_skipped(self, flask_app):
- from src.mapper.main import mapper
-
- flask_app.config["DUMMY_MODE"] = True
-
- intent_empty_sdp = {
- "ietf-network-slice-service:network-slice-services": {
- "slice-service": [
- {
- "id": "slice-empty-sdps",
- "connection-groups": {
- "connection-group": [
- {
- "id": "cg-1",
- "connectivity-type": "point-to-point",
- "connectivity-construct": [
- {"id": "cc-1"}
- ]
- }
- ]
- }
- }
- ]
- }
- }
-
- payload = {"intent": intent_empty_sdp}
-
- with flask_app.app_context(), \
- patch("src.mapper.main.get_data_store", return_value=None), \
- patch("src.mapper.main.process_connectivity", return_value=[]):
- services, _ = mapper(payload, controller_type="RESTCONF")
- assert services == []
\ No newline at end of file
+# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# This file is an original contribution from Telefonica Innovación Digital S.L.
+
+from unittest.mock import MagicMock, call, patch
+
+import pytest
+from flask import Flask
+
+from src.mapper.main import mapper
+from src.mapper.slo_viability import slo_viability
+
+
+@pytest.fixture
+def sample_ietf_intent():
+ """Fixture providing sample IETF network slice intent."""
+ return {
+ "ietf-network-slice-service:network-slice-services": {
+ "slice-service": [
+ {
+ "id": "slice-service-12345",
+ "description": "Test network slice",
+ "service-tags": {"tag-type": {"value": "L2VPN"}},
+ }
+ ],
+ "slo-sle-templates": {
+ "slo-sle-template": [
+ {
+ "id": "profile1",
+ "slo-policy": {
+ "metric-bound": [
+ {"metric-type": "one-way-bandwidth", "metric-unit": "kbps", "bound": 1000},
+ {"metric-type": "one-way-delay-maximum", "metric-unit": "milliseconds", "bound": 10},
+ ]
+ },
+ }
+ ]
+ },
+ }
+ }
+
+
+@pytest.fixture
+def sample_nrp_view():
+ """Fixture providing sample NRP view."""
+ return [
+ {
+ "id": "nrp-1",
+ "available": True,
+ "slices": [],
+ "slos": [
+ {"metric-type": "one-way-bandwidth", "bound": 1500},
+ {"metric-type": "one-way-delay-maximum", "bound": 8},
+ ],
+ },
+ {
+ "id": "nrp-2",
+ "available": True,
+ "slices": [],
+ "slos": [
+ {"metric-type": "one-way-bandwidth", "bound": 500},
+ {"metric-type": "one-way-delay-maximum", "bound": 15},
+ ],
+ },
+ {
+ "id": "nrp-3",
+ "available": False,
+ "slices": [],
+ "slos": [
+ {"metric-type": "one-way-bandwidth", "bound": 2000},
+ {"metric-type": "one-way-delay-maximum", "bound": 5},
+ ],
+ },
+ ]
+
+
+@pytest.fixture
+def mock_app():
+ """Fixture providing mock Flask app context."""
+ app = Flask(__name__)
+ app.config = {
+ "NRP_ENABLED": False,
+ "PLANNER_ENABLED": False,
+ "SERVER_NAME": "localhost",
+ "APPLICATION_ROOT": "/",
+ "PREFERRED_URL_SCHEME": "http",
+ }
+ return app
+
+
+@pytest.fixture
+def app_context(mock_app):
+ """Fixture providing Flask application context."""
+ with mock_app.app_context():
+ yield mock_app
+
+
+class TestSloViability:
+ """Tests for slo_viability function."""
+
+ def test_slo_viability_meets_all_requirements(self):
+ """Test when NRP meets all SLO requirements."""
+ slice_slos = [
+ {"metric-type": "one-way-bandwidth", "bound": 1000},
+ {"metric-type": "one-way-delay-maximum", "bound": 10},
+ ]
+
+ nrp_slos = {
+ "slos": [
+ {"metric-type": "one-way-bandwidth", "bound": 1500},
+ {"metric-type": "one-way-delay-maximum", "bound": 8},
+ ]
+ }
+
+ viable, score = slo_viability(slice_slos, nrp_slos)
+
+ assert viable is True
+ assert score > 0
+
+ def test_slo_viability_fails_bandwidth_minimum(self):
+ """Test when NRP doesn't meet minimum bandwidth requirement."""
+ slice_slos = [{"metric-type": "one-way-bandwidth", "bound": 1000}]
+
+ nrp_slos = {
+ "slos": [
+ {
+ "metric-type": "one-way-bandwidth",
+ "bound": 500, # Less than required
+ }
+ ]
+ }
+
+ viable, score = slo_viability(slice_slos, nrp_slos)
+
+ assert viable is False
+ assert score == 0
+
+ def test_slo_viability_fails_delay_maximum(self):
+ """Test when NRP doesn't meet maximum delay requirement."""
+ slice_slos = [{"metric-type": "one-way-delay-maximum", "bound": 10}]
+
+ nrp_slos = {
+ "slos": [
+ {
+ "metric-type": "one-way-delay-maximum",
+ "bound": 15, # Greater than maximum allowed
+ }
+ ]
+ }
+
+ viable, score = slo_viability(slice_slos, nrp_slos)
+
+ assert viable is False
+ assert score == 0
+
+ def test_slo_viability_multiple_metrics_partial_failure(self):
+ """Test when one metric fails in a multi-metric comparison."""
+ slice_slos = [
+ {"metric-type": "one-way-bandwidth", "bound": 1000},
+ {"metric-type": "one-way-delay-maximum", "bound": 10},
+ ]
+
+ nrp_slos = {
+ "slos": [
+ {
+ "metric-type": "one-way-bandwidth",
+ "bound": 1500, # OK
+ },
+ {
+ "metric-type": "one-way-delay-maximum",
+ "bound": 15, # NOT OK
+ },
+ ]
+ }
+
+ viable, score = slo_viability(slice_slos, nrp_slos)
+
+ assert viable is False
+ assert score == 0
+
+ def test_slo_viability_flexibility_score_calculation(self):
+ """Test flexibility score calculation."""
+ slice_slos = [{"metric-type": "one-way-bandwidth", "bound": 1000}]
+
+ nrp_slos = {
+ "slos": [
+ {
+ "metric-type": "one-way-bandwidth",
+ "bound": 2000, # 100% better than requirement
+ }
+ ]
+ }
+
+ viable, score = slo_viability(slice_slos, nrp_slos)
+
+ assert viable is True
+ # Flexibility = (2000 - 1000) / 1000 = 1.0
+ assert score == 1.0
+
+ def test_slo_viability_empty_slos(self):
+ """Test with empty SLO list."""
+ slice_slos = []
+ nrp_slos = {"slos": []}
+
+ viable, score = slo_viability(slice_slos, nrp_slos)
+
+ assert viable is True
+ assert score == 0
+
+ def test_slo_viability_no_matching_metrics(self):
+ """Test when there are no matching metric types."""
+ slice_slos = [{"metric-type": "one-way-bandwidth", "bound": 1000}]
+
+ nrp_slos = {"slos": [{"metric-type": "two-way-bandwidth", "bound": 1500}]}
+
+ viable, score = slo_viability(slice_slos, nrp_slos)
+
+ # Should still return True as no metrics failed
+ assert viable is True
+ assert score == 0
+
+ def test_slo_viability_packet_loss_maximum_type(self):
+ """Test packet loss as maximum constraint type."""
+ slice_slos = [
+ {
+ "metric-type": "one-way-packet-loss",
+ "bound": 0.01, # 1% maximum acceptable
+ }
+ ]
+
+ nrp_slos = {
+ "slos": [
+ {
+ "metric-type": "one-way-packet-loss",
+ "bound": 0.005, # 0.5% NRP loss
+ }
+ ]
+ }
+
+ viable, score = slo_viability(slice_slos, nrp_slos)
+
+ assert viable is True
+ assert score > 0
+
+
+class TestMapper:
+ """Tests for mapper function."""
+
+ def test_mapper_with_nrp_disabled_and_planner_disabled(self, app_context, sample_ietf_intent):
+ """Test mapper when both NRP and Planner are disabled."""
+ app_context.config = {"NRP_ENABLED": False, "PLANNER_ENABLED": False}
+
+ payload = {"intent": sample_ietf_intent}
+
+ result = mapper(payload)
+
+ assert result == ([sample_ietf_intent], None)
+
+ @patch("src.mapper.main.Planner")
+ def test_mapper_with_planner_enabled(self, mock_planner_class, app_context, sample_ietf_intent):
+ """Test mapper when Planner is enabled."""
+ app_context.config = {"NRP_ENABLED": False, "PLANNER_ENABLED": True, "PLANNER_TYPE": "ENERGY"}
+
+ mock_planner_instance = MagicMock()
+ mock_planner_instance.planner.return_value = {"path": "node1->node2->node3"}
+ mock_planner_class.return_value = mock_planner_instance
+
+ 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)
+
+ @patch("src.mapper.main.realizer")
+ def test_mapper_with_nrp_enabled_finds_best_nrp(
+ self, mock_realizer, app_context, sample_ietf_intent, sample_nrp_view
+ ):
+ """Test mapper with NRP enabled finds the best NRP."""
+ app_context.config = {
+ "NRP_ENABLED": True,
+ "PLANNER_ENABLED": False,
+ }
+
+ mock_realizer.return_value = sample_nrp_view
+
+ 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")
+ assert result == ([sample_ietf_intent], None)
+
+ @patch("src.mapper.main.realizer")
+ def test_mapper_with_nrp_enabled_no_viable_candidates(self, mock_realizer, app_context, sample_ietf_intent):
+ """Test mapper when no viable NRPs are found."""
+ app_context.config = {"NRP_ENABLED": True, "PLANNER_ENABLED": False}
+
+ # All NRPs are unavailable
+ nrp_view = [
+ {
+ "id": "nrp-1",
+ "available": False,
+ "slices": [],
+ "slos": [{"metric-type": "one-way-bandwidth", "bound": 500}],
+ }
+ ]
+
+ mock_realizer.return_value = nrp_view
+
+ payload = {"intent": sample_ietf_intent}
+
+ result = mapper(payload)
+
+ assert result == ([sample_ietf_intent], None)
+
+ @patch("src.mapper.main.realizer")
+ def test_mapper_with_nrp_enabled_creates_new_nrp(self, mock_realizer, app_context, sample_ietf_intent):
+ """Test mapper creates new NRP when no suitable candidate exists."""
+ app_context.config = {"NRP_ENABLED": True, "PLANNER_ENABLED": False}
+
+ # No viable NRPs
+ nrp_view = []
+
+ mock_realizer.side_effect = [nrp_view, None] # First call returns empty, second for CREATE
+
+ 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"]
+ assert len(create_call) > 0
+
+ @patch("src.mapper.main.realizer")
+ def test_mapper_with_nrp_and_planner_both_enabled(
+ self, mock_realizer, app_context, sample_ietf_intent, sample_nrp_view
+ ):
+ """Test mapper when both NRP and Planner are enabled."""
+ app_context.config = {"NRP_ENABLED": True, "PLANNER_ENABLED": True, "PLANNER_TYPE": "ENERGY"}
+
+ mock_realizer.return_value = sample_nrp_view
+
+ with patch("src.mapper.main.Planner") as mock_planner_class:
+ mock_planner_instance = MagicMock()
+ mock_planner_instance.planner.return_value = {"path": "optimized_path"}
+ mock_planner_class.return_value = mock_planner_instance
+
+ payload = {"intent": sample_ietf_intent}
+ result = mapper(payload)
+
+ # Planner should be called and return the result
+ assert result == ([sample_ietf_intent], {"path": "optimized_path"})
+
+ @patch("src.mapper.main.realizer")
+ def test_mapper_updates_best_nrp_with_slice(self, mock_realizer, app_context, sample_ietf_intent, sample_nrp_view):
+ """Test mapper updates best NRP with new slice."""
+ app_context.config = {"NRP_ENABLED": True, "PLANNER_ENABLED": False}
+
+ mock_realizer.return_value = sample_nrp_view
+
+ 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"]
+ assert len(update_calls) > 0
+
+ @patch("src.mapper.main.realizer")
+ def test_mapper_extracts_slos_correctly(self, mock_realizer, app_context, sample_ietf_intent):
+ """Test that mapper correctly extracts SLOs from intent."""
+ app_context.config = {"NRP_ENABLED": True, "PLANNER_ENABLED": False}
+
+ mock_realizer.return_value = []
+
+ payload = {"intent": sample_ietf_intent}
+ mapper(payload)
+
+ # Verify the function processed the intent
+ assert mock_realizer.called
+
+ @patch("src.mapper.main.logging")
+ def test_mapper_logs_debug_info(self, mock_logging, app_context, sample_ietf_intent, sample_nrp_view):
+ """Test mapper logs debug information."""
+ app_context.config = {"NRP_ENABLED": True, "PLANNER_ENABLED": False}
+
+ with patch("src.mapper.main.realizer") as mock_realizer:
+ mock_realizer.return_value = sample_nrp_view
+
+ payload = {"intent": sample_ietf_intent}
+ mapper(payload)
+
+ # Verify debug logging was called
+ assert mock_logging.debug.called
+
+
+class TestMapperIntegration:
+ """Integration tests for mapper functionality."""
+
+ def test_mapper_complete_nrp_workflow(self, app_context, sample_ietf_intent, sample_nrp_view):
+ """Test complete NRP mapping workflow."""
+ app_context.config = {"NRP_ENABLED": True, "PLANNER_ENABLED": False}
+
+ with patch("src.mapper.main.realizer") as mock_realizer:
+ mock_realizer.return_value = sample_nrp_view
+
+ payload = {"intent": sample_ietf_intent}
+ result = mapper(payload)
+
+ # Verify the workflow sequence
+ assert mock_realizer.call_count >= 1
+ first_call = mock_realizer.call_args_list[0]
+ assert first_call[0][1] is True # need_nrp parameter
+ assert first_call[0][2] == "READ" # READ operation
+
+ def test_mapper_complete_planner_workflow(self, app_context, sample_ietf_intent):
+ """Test complete Planner workflow."""
+ app_context.config = {"NRP_ENABLED": False, "PLANNER_ENABLED": True, "PLANNER_TYPE": "ENERGY"}
+
+ expected_path = {"path": "node1->node2->node3", "cost": 10, "latency": 5}
+
+ with patch("src.mapper.main.Planner") as mock_planner_class:
+ mock_planner_instance = MagicMock()
+ mock_planner_instance.planner.return_value = expected_path
+ mock_planner_class.return_value = mock_planner_instance
+
+ payload = {"intent": sample_ietf_intent}
+ result = mapper(payload)
+
+ assert result == ([sample_ietf_intent], expected_path)
+ mock_planner_instance.planner.assert_called_once()
+
+ def test_mapper_with_invalid_nrp_response(self, app_context, sample_ietf_intent):
+ """Test mapper behavior with invalid NRP response."""
+ app_context.config = {"NRP_ENABLED": True, "PLANNER_ENABLED": False}
+
+ # Invalid NRP without expected fields
+ invalid_nrp = {
+ "id": "nrp-invalid"
+ # Missing 'available' and 'slos' fields
+ }
+
+ with patch("src.mapper.main.realizer") as mock_realizer:
+ mock_realizer.return_value = [invalid_nrp]
+
+ # Should handle gracefully
+ try:
+ payload = {"intent": sample_ietf_intent}
+ result = mapper(payload)
+ except (KeyError, TypeError):
+ # Expected to fail gracefully
+ pass
+
+ def test_mapper_with_missing_slos_in_intent(self, app_context):
+ """Test mapper behavior when intent has no SLOs."""
+ app_context.config = {"NRP_ENABLED": True, "PLANNER_ENABLED": False}
+
+ invalid_intent = {
+ "ietf-network-slice-service:network-slice-services": {
+ "slice-service": [{"id": "slice-1"}],
+ "slo-sle-templates": {
+ "slo-sle-template": [
+ {
+ "id": "profile1",
+ "slo-policy": {
+ # No metric-bound key
+ },
+ }
+ ]
+ },
+ }
+ }
+
+ payload = {"intent": invalid_intent}
+ try:
+ mapper(payload)
+ except (KeyError, TypeError):
+ # Expected behavior
+ pass
+
+
+class TestSloViabilityEdgeCases:
+ """Edge case tests for slo_viability function."""
+
+ def test_slo_viability_with_zero_bound(self):
+ """Test handling of zero bounds in SLO."""
+ slice_slos = [{"metric-type": "one-way-bandwidth", "bound": 0}]
+
+ nrp_slos = {"slos": [{"metric-type": "one-way-bandwidth", "bound": 100}]}
+
+ # Should handle zero division gracefully or fail as expected
+ try:
+ viable, score = slo_viability(slice_slos, nrp_slos)
+ except (ZeroDivisionError, ValueError):
+ pass
+
+ def test_slo_viability_with_very_large_bounds(self):
+ """Test handling of very large SLO bounds."""
+ slice_slos = [{"metric-type": "one-way-bandwidth", "bound": 1e10}]
+
+ nrp_slos = {"slos": [{"metric-type": "one-way-bandwidth", "bound": 2e10}]}
+
+ viable, score = slo_viability(slice_slos, nrp_slos)
+
+ assert viable is True
+ assert isinstance(score, (int, float))
+
+ def test_slo_viability_all_delay_types(self):
+ """Test handling of all delay metric types."""
+ delay_types = [
+ "one-way-delay-maximum",
+ "two-way-delay-maximum",
+ "one-way-delay-percentile",
+ "two-way-delay-percentile",
+ "one-way-delay-variation-maximum",
+ "two-way-delay-variation-maximum",
+ ]
+
+ for delay_type in delay_types:
+ slice_slos = [{"metric-type": delay_type, "bound": 10}]
+ nrp_slos = {"slos": [{"metric-type": delay_type, "bound": 8}]}
+
+ viable, score = slo_viability(slice_slos, nrp_slos)
+
+ assert viable is True
+ assert score >= 0
+
+
+class TestMapperSubmodules:
+ """Tests for mapper submodules: extract_sdp_info, process_connectivity, aggregate_monitoring, get_service_template."""
+
+ def test_extract_sdp_info(self):
+ from src.mapper.extract_sdp_info import extract_sdp_info
+
+ slice_service = {
+ "sdps": {
+ "sdp": [
+ {
+ "id": "sdp-1",
+ "sdp-ip-address": "10.0.0.1",
+ "service-match-criteria": {"match-criterion": [{"target-connection-group-id": "cg-1"}]},
+ }
+ ]
+ }
+ }
+ sdp, mc = extract_sdp_info("sdp-1", slice_service, "cg-1", None)
+ assert sdp["id"] == "sdp-1"
+ assert mc is not None
+
+ def test_process_connectivity(self):
+ from src.mapper.process_connnectivity import process_connectivity
+
+ slice_service = {
+ "sdps": {
+ "sdp": [{"id": "sdp-1", "sdp-ip-address": "10.0.0.1"}, {"id": "sdp-2", "sdp-ip-address": "10.0.0.2"}]
+ }
+ }
+ conn_construct = {"a2a-sdp": ["sdp-1", "sdp-2"]}
+ res = process_connectivity("cg-1", "ietf-vpn-common:any-to-any", conn_construct, "cc-1", slice_service)
+ assert isinstance(res, list)
+ assert len(res) == 2
+
+ def test_aggregate_monitoring(self, flask_app):
+ from src.mapper.aggregate_monitoring import aggregate_monitoring
+
+ flask_app.config["TELEMETRY_CACHE"] = {"slice-1": {"A-B": {"bandwidth": 1000, "latency": 5}}}
+ slo_template = {
+ "slo-policy": {
+ "metric-bound": [
+ {"metric-type": "ietf-network-slice-service:two-way-bandwidth", "bound": 500},
+ {"metric-type": "ietf-network-slice-service:two-way-delay-maximum", "bound": 10},
+ ]
+ }
+ }
+ with flask_app.app_context(), patch("src.mapper.aggregate_monitoring.upsert_telemetry"):
+ aggregate_monitoring("slice-1", slo_template)
+
+ def test_get_service_template_and_get_template(self):
+ from src.mapper.get_service_template import get_service_template
+ from src.mapper.get_template import get_template
+
+ available = [{"id": "tmpl-1", "slo-policy": {}}]
+ elem = {"slo-sle-template": "tmpl-1"}
+ res = get_service_template(elem, available)
+ assert res == {"id": "tmpl-1", "slo-policy": {}}
+
+ tmpl = get_template("tmpl-1", available)
+ assert tmpl["id"] == "tmpl-1"
+
+ def test_process_connectivity_hub_spoke_and_p2p(self):
+ from src.mapper.process_connnectivity import process_connectivity
+
+ slice_service = {
+ "sdps": {
+ "sdp": [{"id": "sdp-1", "sdp-ip-address": "10.0.0.1"}, {"id": "sdp-2", "sdp-ip-address": "10.0.0.2"}]
+ }
+ }
+
+ # Hub-Spoke
+ hs_construct = {"p2mp-sender-sdp": "sdp-1", "p2mp-receiver-sdp": ["sdp-2"]}
+ res_hs = process_connectivity("cg-1", "ietf-vpn-common:hub-spoke", hs_construct, "cc-1", slice_service)
+ assert len(res_hs) == 2
+ assert res_hs[0]["type"] == "sender"
+ assert res_hs[1]["type"] == "receiver"
+
+ # Point-to-Point
+ p2p_construct = {"p2p-sender-sdp": "sdp-1", "p2p-receiver-sdp": "sdp-2"}
+ res_p2p = process_connectivity("cg-1", "point-to-point", p2p_construct, "cc-1", slice_service)
+ assert len(res_p2p) == 2
+ assert res_p2p[0]["type"] == "sender"
+ assert res_p2p[1]["type"] == "receiver"
+
+
+class TestMapperMonitoring:
+ """Comprehensive tests for MONITOR action in mapper and aggregate_monitoring."""
+
+ def test_mapper_monitor_action(self, flask_app):
+ payload = {
+ "slice_id": "slice-mon-100",
+ "slo_sle_template": {
+ "slo-policy": {
+ "metric-bound": [
+ {"metric-type": "ietf-network-slice-service:two-way-bandwidth", "bound": 100},
+ {"metric-type": "ietf-network-slice-service:two-way-delay-maximum", "bound": 20},
+ ]
+ }
+ },
+ }
+ flask_app.config["TELEMETRY_CACHE"]["slice-mon-100"] = {"link-1": {"bandwidth": 150, "latency": 15}}
+
+ with flask_app.app_context(), patch("src.mapper.aggregate_monitoring.upsert_telemetry") as mock_upsert:
+ metrics = mapper(payload, action="MONITOR")
+ assert metrics["slice_id"] == "slice-mon-100"
+ assert metrics["slo_sle_compliance"]["is_compliant"] is True
+ assert metrics["slo_sle_compliance"]["violated_metrics"] == []
+ mock_upsert.assert_called_once()
+
+ def test_aggregate_monitoring_latency_and_bandwidth_violations(self, flask_app):
+ from src.mapper.aggregate_monitoring import aggregate_monitoring
+
+ slice_id = "slice-mon-violations"
+ slo_template = {
+ "slo-policy": {
+ "metric-bound": [
+ {"metric-type": "ietf-network-slice-service:two-way-bandwidth", "bound": 500},
+ {"metric-type": "ietf-network-slice-service:two-way-delay-maximum", "bound": 10},
+ ]
+ }
+ }
+
+ # Case 1: Latency violation only
+ flask_app.config["TELEMETRY_CACHE"][slice_id] = {"link-1": {"bandwidth": 600, "latency": 15}}
+ with flask_app.app_context(), patch("src.mapper.aggregate_monitoring.upsert_telemetry"):
+ res_lat = aggregate_monitoring(slice_id, slo_template)
+ assert res_lat["slo_sle_compliance"]["is_compliant"] is False
+ assert res_lat["slo_sle_compliance"]["violated_metrics"] == ["latency"]
+
+ # Case 2: Bandwidth violation only
+ flask_app.config["TELEMETRY_CACHE"][slice_id] = {"link-1": {"bandwidth": 400, "latency": 5}}
+ with flask_app.app_context(), patch("src.mapper.aggregate_monitoring.upsert_telemetry"):
+ res_bw = aggregate_monitoring(slice_id, slo_template)
+ assert res_bw["slo_sle_compliance"]["is_compliant"] is False
+ assert res_bw["slo_sle_compliance"]["violated_metrics"] == ["bandwidth"]
+
+ # Case 3: Both violated
+ flask_app.config["TELEMETRY_CACHE"][slice_id] = {"link-1": {"bandwidth": 300, "latency": 25}}
+ with flask_app.app_context(), patch("src.mapper.aggregate_monitoring.upsert_telemetry"):
+ res_both = aggregate_monitoring(slice_id, slo_template)
+ assert res_both["slo_sle_compliance"]["is_compliant"] is False
+ assert set(res_both["slo_sle_compliance"]["violated_metrics"]) == {"latency", "bandwidth"}
+
+ def test_aggregate_monitoring_empty_links_error(self, flask_app):
+ from src.mapper.aggregate_monitoring import aggregate_monitoring
+
+ flask_app.config["TELEMETRY_CACHE"]["slice-empty"] = {}
+ with flask_app.app_context():
+ with pytest.raises(Exception, match="No telemetry data available for slice"):
+ aggregate_monitoring("slice-empty", {})
+
+ def test_aggregate_monitoring_cache_not_initialized_error(self, flask_app):
+ from src.mapper.aggregate_monitoring import aggregate_monitoring
+
+ flask_app.config["TELEMETRY_CACHE"].pop("slice-missing", None)
+ with flask_app.app_context():
+ with pytest.raises(Exception, match="Telemetry cache for slice .* is not initialized"):
+ aggregate_monitoring("slice-missing", {})
+
+ def test_mapper_connection_groups_and_template_overrides(self, flask_app):
+ from src.mapper.main import mapper
+
+ flask_app.config["DUMMY_MODE"] = False
+
+ intent_full = {
+ "ietf-network-slice-service:network-slice-services": {
+ "slo-sle-templates": {
+ "slo-sle-template": [
+ {"id": "service-tmpl", "bound": 10},
+ {"id": "group-tmpl", "bound": 20},
+ {"id": "construct-tmpl", "bound": 30},
+ ]
+ },
+ "slice-service": [
+ {
+ "id": "slice-full-1",
+ "slo-sle-template": "service-tmpl",
+ "service-tags": {
+ "tag-type": {"ietf-network-slice-service:service": {"tag-type-value": ["L2"]}}
+ },
+ "sdps": {"sdp": [{"id": "sdp-1", "node-id": "N1"}, {"id": "sdp-2", "node-id": "N2"}]},
+ "connection-groups": {
+ "connection-group": [
+ {
+ "id": "cg-1",
+ "slo-sle-template": "group-tmpl",
+ "connectivity-type": "point-to-point",
+ "connectivity-construct": [
+ {
+ "id": "cc-1",
+ "slo-sle-template": "construct-tmpl",
+ "p2p-sender-sdp": "sdp-1",
+ "p2p-receiver-sdp": "sdp-2",
+ },
+ {"id": "cc-2", "p2p-sender-sdp": "sdp-1", "p2p-receiver-sdp": "sdp-2"},
+ ],
+ }
+ ]
+ },
+ }
+ ],
+ }
+ }
+
+ payload = {"intent": intent_full, "is_update": False}
+
+ with (
+ flask_app.app_context(),
+ patch("src.mapper.main.get_data_store", return_value=None),
+ patch("src.mapper.main.normalize_libyang_data", side_effect=lambda x: x),
+ patch("src.mapper.main.save_data") as mock_save_data,
+ ):
+ services, rules = mapper(payload, controller_type="RESTCONF")
+ assert len(services) == 1
+ assert services[0]["id"].startswith("slice-full-1-cg-1-cc-1")
+ assert services[0]["template"] == {"id": "construct-tmpl", "bound": 30}
+ assert services[0]["connectivity_type"] == "point-to-point"
+ mock_save_data.assert_called_once()
+
+ def test_mapper_non_p2p_and_group_template_inheritance(self, flask_app):
+ from src.mapper.main import mapper
+
+ flask_app.config["DUMMY_MODE"] = True
+
+ intent_non_p2p = {
+ "ietf-network-slice-service:network-slice-services": {
+ "slo-sle-templates": {
+ "slo-sle-template": [{"id": "service-tmpl", "bound": 10}, {"id": "group-tmpl", "bound": 20}]
+ },
+ "slice-service": [
+ {
+ "id": "slice-non-p2p",
+ "slo-sle-template": "service-tmpl",
+ "sdps": {"sdp": [{"id": "sdp-1", "node-id": "N1"}, {"id": "sdp-2", "node-id": "N2"}]},
+ "connection-groups": {
+ "connection-group": [
+ {
+ "id": "cg-1",
+ "slo-sle-template": "group-tmpl",
+ "connectivity-type": "ietf-vpn-common:any-to-any",
+ "connectivity-construct": [
+ {"id": "cc-1", "a2a-sdp": ["sdp-1", "sdp-2"]},
+ {"id": "cc-2", "a2a-sdp": ["sdp-1", "sdp-2"]},
+ ],
+ }
+ ]
+ },
+ }
+ ],
+ }
+ }
+
+ payload = {"intent": intent_non_p2p}
+
+ with (
+ flask_app.app_context(),
+ patch("src.mapper.main.get_data_store", return_value=None),
+ patch("src.mapper.main.normalize_libyang_data", side_effect=lambda x: x),
+ ):
+ services, _ = mapper(payload, controller_type="RESTCONF")
+ assert len(services) == 2
+ assert services[0]["template"] == {"id": "group-tmpl", "bound": 20}
+ assert services[1]["template"] == {"id": "group-tmpl", "bound": 20}
+
+ def test_mapper_empty_sdps_skipped(self, flask_app):
+ from src.mapper.main import mapper
+
+ flask_app.config["DUMMY_MODE"] = True
+
+ intent_empty_sdp = {
+ "ietf-network-slice-service:network-slice-services": {
+ "slice-service": [
+ {
+ "id": "slice-empty-sdps",
+ "connection-groups": {
+ "connection-group": [
+ {
+ "id": "cg-1",
+ "connectivity-type": "point-to-point",
+ "connectivity-construct": [{"id": "cc-1"}],
+ }
+ ]
+ },
+ }
+ ]
+ }
+ }
+
+ payload = {"intent": intent_empty_sdp}
+
+ with (
+ flask_app.app_context(),
+ patch("src.mapper.main.get_data_store", return_value=None),
+ patch("src.mapper.main.process_connectivity", return_value=[]),
+ ):
+ services, _ = mapper(payload, controller_type="RESTCONF")
+ assert services == []
diff --git a/src/tests/test_namespaces.py b/src/tests/test_namespaces.py
index 166fc4c266df0f93036447aad18a5b591c7029cd..5bd323b2da1acd451785eff7080f56f88f3c27b2 100644
--- a/src/tests/test_namespaces.py
+++ b/src/tests/test_namespaces.py
@@ -16,21 +16,20 @@
import io
import json
-import pytest
-from unittest.mock import patch, MagicMock
-
+from unittest.mock import patch
# =============================================================================
# 1. Tests for Basic Auth Enforcement Across Namespaces
# =============================================================================
+
def test_unauthenticated_request_rejected(client):
"""Test requests without Basic Auth headers return 401 Unauthorized."""
endpoints = [
"/tfs/slice",
"/ixia/slice",
"/e2e/slice",
- "/restconf/data/ietf-network-slice-service:network-slice-services"
+ "/restconf/data/ietf-network-slice-service:network-slice-services",
]
for endpoint in endpoints:
resp = client.get(endpoint)
@@ -41,6 +40,7 @@ def test_unauthenticated_request_rejected(client):
# 2. Tests for TFS Namespace (/tfs)
# =============================================================================
+
def test_tfs_slice_get(client, auth_headers, temp_sqlite_db):
"""Test GET /tfs/slice retrieves slices."""
resp = client.get("/tfs/slice", headers=auth_headers)
@@ -49,30 +49,28 @@ def test_tfs_slice_get(client, auth_headers, temp_sqlite_db):
def test_tfs_slice_post_file(client, auth_headers, sample_ietf_intent, temp_sqlite_db):
"""Test POST /tfs/slice with uploaded JSON file."""
- data = {
- 'file': (io.BytesIO(json.dumps(sample_ietf_intent).encode('utf-8')), 'intent.json')
- }
- with patch("src.main.send_controller", return_value=True), \
- patch("src.realizer.send_controller.send_controller", return_value=True):
+ data = {"file": (io.BytesIO(json.dumps(sample_ietf_intent).encode("utf-8")), "intent.json")}
+ with (
+ patch("src.main.send_controller", return_value=True),
+ patch("src.realizer.send_controller.send_controller", return_value=True),
+ ):
resp = client.post(
"/tfs/slice",
headers={"Authorization": auth_headers["Authorization"]},
data=data,
- content_type='multipart/form-data'
+ content_type="multipart/form-data",
)
assert resp.status_code in [200, 201]
def test_tfs_slice_post_invalid_file_extension(client, auth_headers):
"""Test POST /tfs/slice rejects non-JSON uploaded files."""
- data = {
- 'file': (io.BytesIO(b"hello world"), 'intent.txt')
- }
+ data = {"file": (io.BytesIO(b"hello world"), "intent.txt")}
resp = client.post(
"/tfs/slice",
headers={"Authorization": auth_headers["Authorization"]},
data=data,
- content_type='multipart/form-data'
+ content_type="multipart/form-data",
)
assert resp.status_code == 400
res_data = resp.get_json()
@@ -81,14 +79,12 @@ def test_tfs_slice_post_invalid_file_extension(client, auth_headers):
def test_tfs_slice_post_invalid_json_string(client, auth_headers):
"""Test POST /tfs/slice rejects malformed JSON string in form data."""
- data = {
- 'json_data': '{invalid_json: true'
- }
+ data = {"json_data": "{invalid_json: true"}
resp = client.post(
"/tfs/slice",
headers={"Authorization": auth_headers["Authorization"]},
data=data,
- content_type='multipart/form-data'
+ content_type="multipart/form-data",
)
assert resp.status_code == 400
res_data = resp.get_json()
@@ -99,6 +95,7 @@ def test_tfs_slice_post_invalid_json_string(client, auth_headers):
# 3. Tests for IXIA Namespace (/ixia)
# =============================================================================
+
def test_ixia_slice_get(client, auth_headers, temp_sqlite_db):
"""Test GET /ixia/slice retrieves slices."""
resp = client.get("/ixia/slice", headers=auth_headers)
@@ -107,16 +104,16 @@ def test_ixia_slice_get(client, auth_headers, temp_sqlite_db):
def test_ixia_slice_post_form_data(client, auth_headers, sample_ietf_intent, temp_sqlite_db):
"""Test POST /ixia/slice with JSON string in form data."""
- data = {
- 'json_data': json.dumps(sample_ietf_intent)
- }
- with patch("src.main.send_controller", return_value=True), \
- patch("src.realizer.send_controller.send_controller", return_value=True):
+ data = {"json_data": json.dumps(sample_ietf_intent)}
+ with (
+ patch("src.main.send_controller", return_value=True),
+ patch("src.realizer.send_controller.send_controller", return_value=True),
+ ):
resp = client.post(
"/ixia/slice",
headers={"Authorization": auth_headers["Authorization"]},
data=data,
- content_type='multipart/form-data'
+ content_type="multipart/form-data",
)
assert resp.status_code in [200, 201]
@@ -125,6 +122,7 @@ def test_ixia_slice_post_form_data(client, auth_headers, sample_ietf_intent, tem
# 4. Tests for E2E Namespace (/e2e)
# =============================================================================
+
def test_e2e_slice_get(client, auth_headers, temp_sqlite_db):
"""Test GET /e2e/slice retrieves slices."""
resp = client.get("/e2e/slice", headers=auth_headers)
@@ -139,25 +137,18 @@ def test_e2e_alerts_post(client, auth_headers, temp_sqlite_db):
"tapi-notification:notification": {
"uuid": "alert-uuid-123",
"notification-type": "ALARM_EVENT",
- "event-time-stamp": "2026-08-04T12:00:00Z"
+ "event-time-stamp": "2026-08-04T12:00:00Z",
}
}
]
}
- resp = client.post(
- "/e2e/alert",
- headers=auth_headers,
- data=json.dumps(alert_payload)
- )
+ resp = client.post("/e2e/alert", headers=auth_headers, data=json.dumps(alert_payload))
assert resp.status_code in [200, 201, 400]
def test_e2e_alerts_post_missing_uuid(client, auth_headers):
"""Test POST /e2e/alert handles alert payloads."""
- resp = client.get(
- "/e2e/alert",
- headers=auth_headers
- )
+ resp = client.get("/e2e/alert", headers=auth_headers)
assert resp.status_code in [200, 404]
@@ -165,36 +156,65 @@ def test_e2e_alerts_post_missing_uuid(client, auth_headers):
# 5. Tests for RESTCONF Namespace (/restconf)
# =============================================================================
+
def test_restconf_services_crud(client, auth_headers, sample_ietf_intent, temp_sqlite_db):
"""Test GET, POST, PUT operations on RESTCONF network slice services endpoint."""
# GET (empty DB)
- get_resp = client.get(
- "/restconf/data/ietf-network-slice-service:network-slice-services",
- headers=auth_headers
- )
+ get_resp = client.get("/restconf/data/ietf-network-slice-service:network-slice-services", headers=auth_headers)
assert get_resp.status_code in [200, 404]
# POST (create)
post_resp = client.post(
"/restconf/data/ietf-network-slice-service:network-slice-services",
headers=auth_headers,
- data=json.dumps(sample_ietf_intent)
+ data=json.dumps(sample_ietf_intent),
)
- assert post_resp.status_code in [200, 201, 500]
+ assert post_resp.status_code in [200, 201, 422, 500]
# PUT (update)
put_resp = client.put(
"/restconf/data/ietf-network-slice-service:network-slice-services",
headers=auth_headers,
- data=json.dumps(sample_ietf_intent)
+ data=json.dumps(sample_ietf_intent),
)
- assert put_resp.status_code in [200, 404, 500]
+ assert put_resp.status_code in [200, 404, 422, 500]
+
+
+def test_plural_and_singular_routes(client, auth_headers, temp_sqlite_db):
+ """Test that plural and singular routes work consistently across namespaces."""
+ # TFS
+ resp_slices = client.get("/tfs/slices", headers=auth_headers)
+ resp_slice = client.get("/tfs/slice", headers=auth_headers)
+ assert resp_slices.status_code == 200
+ assert resp_slice.status_code == 200
+ assert resp_slices.get_json()["data"] == resp_slice.get_json()["data"]
+
+ # IXIA
+ resp_ixia_slices = client.get("/ixia/slices", headers=auth_headers)
+ resp_ixia_slice = client.get("/ixia/slice", headers=auth_headers)
+ assert resp_ixia_slices.status_code == 200
+ assert resp_ixia_slice.status_code == 200
+
+ # E2E
+ resp_e2e_slices = client.get("/e2e/slices", headers=auth_headers)
+ resp_e2e_slice = client.get("/e2e/slice", headers=auth_headers)
+ assert resp_e2e_slices.status_code == 200
+ assert resp_e2e_slice.status_code == 200
+
+ # E2E alerts
+ resp_alerts = client.get("/e2e/alerts", headers=auth_headers)
+ resp_alert = client.get("/e2e/alert", headers=auth_headers)
+ assert resp_alerts.status_code in [200, 404]
+ assert resp_alert.status_code in [200, 404]
+
def test_delete_slice_endpoints(client, auth_headers):
"""Test DELETE endpoints on /tfs, /ixia, and /e2e controllers."""
- with patch("src.main.send_controller", return_value=True), \
- patch("src.realizer.send_controller.send_controller", return_value=True):
+ with (
+ patch("src.main.send_controller", return_value=True),
+ patch("src.realizer.send_controller.send_controller", return_value=True),
+ ):
res_tfs = client.delete("/tfs/slice/slice-1", headers=auth_headers)
assert res_tfs.status_code in [200, 204, 404, 500]
@@ -208,22 +228,17 @@ def test_delete_slice_endpoints(client, auth_headers):
def test_restconf_detailed_resources(client, auth_headers):
"""Test RESTCONF specific resource endpoints."""
# Delete all slice services
- del_all = client.delete(
- "/restconf/data/ietf-network-slice-service:network-slice-services",
- headers=auth_headers
- )
+ del_all = client.delete("/restconf/data/ietf-network-slice-service:network-slice-services", headers=auth_headers)
assert del_all.status_code in [200, 204, 404, 500]
# Specific slice service GET & DELETE
get_spec = client.get(
- "/restconf/data/ietf-network-slice-service:network-slice-services/slice-service=slice-123",
- headers=auth_headers
+ "/restconf/data/ietf-network-slice-service:network-slice-services/slice-service=slice-123", headers=auth_headers
)
assert get_spec.status_code in [200, 404]
del_spec = client.delete(
- "/restconf/data/ietf-network-slice-service:network-slice-services/slice-service=slice-123",
- headers=auth_headers
+ "/restconf/data/ietf-network-slice-service:network-slice-services/slice-service=slice-123", headers=auth_headers
)
assert del_spec.status_code in [200, 204, 404, 500]
@@ -232,6 +247,7 @@ def test_restconf_detailed_resources(client, auth_headers):
# 6. Extended Tests for IXIA and RESTCONF Namespaces
# =============================================================================
+
def test_ixia_namespace_extended(client, auth_headers):
"""Test IXIA delete all, get slice by id, and put (modify) slice by id."""
# DELETE all slices
@@ -246,11 +262,7 @@ def test_ixia_namespace_extended(client, auth_headers):
# PUT (modify) slice by id
with patch("src.api.main.Api.modify_flow", return_value=({"id": "slice-1"}, 200)):
- resp_put = client.put(
- "/ixia/slice/slice-1",
- headers=auth_headers,
- data=json.dumps({"intent": "modified"})
- )
+ resp_put = client.put("/ixia/slice/slice-1", headers=auth_headers, data=json.dumps({"intent": "modified"}))
assert resp_put.status_code in [200, 400, 404, 500]
@@ -259,8 +271,7 @@ def test_restconf_slice_services_extended(client, auth_headers):
# GET /slice-service
with patch("src.api.main.Api.get_slice_services", return_value=([], 200)):
res_get_list = client.get(
- "/restconf/data/ietf-network-slice-service:network-slice-services/slice-service",
- headers=auth_headers
+ "/restconf/data/ietf-network-slice-service:network-slice-services/slice-service", headers=auth_headers
)
assert res_get_list.status_code in [200, 404]
@@ -269,15 +280,14 @@ def test_restconf_slice_services_extended(client, auth_headers):
res_post_list = client.post(
"/restconf/data/ietf-network-slice-service:network-slice-services/slice-service",
headers=auth_headers,
- data=json.dumps({"id": "slice-1"})
+ data=json.dumps({"id": "slice-1"}),
)
assert res_post_list.status_code in [200, 201, 409, 500]
# DELETE /slice-service
with patch("src.api.main.Api.delete_slice_services", return_value=({}, 204)):
res_del_list = client.delete(
- "/restconf/data/ietf-network-slice-service:network-slice-services/slice-service",
- headers=auth_headers
+ "/restconf/data/ietf-network-slice-service:network-slice-services/slice-service", headers=auth_headers
)
assert res_del_list.status_code in [200, 204, 500]
@@ -286,7 +296,7 @@ def test_restconf_slice_services_extended(client, auth_headers):
res_put_spec = client.put(
"/restconf/data/ietf-network-slice-service:network-slice-services/slice-service=slice-1",
headers=auth_headers,
- data=json.dumps({"id": "slice-1"})
+ data=json.dumps({"id": "slice-1"}),
)
assert res_put_spec.status_code in [200, 201, 404, 500]
@@ -314,7 +324,7 @@ def test_restconf_slo_sle_templates_extended(client, auth_headers):
with patch("src.api.main.Api.get_slo_sle_templates", return_value=({"id": "tmpl-1"}, 200)):
res_get_spec = client.get(
"/restconf/data/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template=tmpl-1",
- headers=auth_headers
+ headers=auth_headers,
)
assert res_get_spec.status_code in [200, 404]
@@ -323,7 +333,7 @@ def test_restconf_slo_sle_templates_extended(client, auth_headers):
res_put_spec = client.put(
"/restconf/data/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template=tmpl-1",
headers=auth_headers,
- data=json.dumps({"id": "tmpl-1"})
+ data=json.dumps({"id": "tmpl-1"}),
)
assert res_put_spec.status_code in [200, 404]
@@ -331,7 +341,7 @@ def test_restconf_slo_sle_templates_extended(client, auth_headers):
with patch("src.api.main.Api.delete_slo_sle_templates", return_value=({}, 204)):
res_del_spec = client.delete(
"/restconf/data/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template=tmpl-1",
- headers=auth_headers
+ headers=auth_headers,
)
assert res_del_spec.status_code in [200, 204]
@@ -413,7 +423,7 @@ def test_restconf_telemetry_subscriptions_extended(client, auth_headers):
res_post = client.post(
"/restconf/operations/telemetry/subscription/cli-1",
headers=auth_headers,
- data=json.dumps({"slice_id": "slice-1", "frequency": 5})
+ data=json.dumps({"slice_id": "slice-1", "frequency": 5}),
)
assert res_post.status_code in [200, 201, 400, 500]
@@ -424,7 +434,9 @@ def test_restconf_telemetry_subscriptions_extended(client, auth_headers):
# GET specific subscription
with patch("src.api.main.Api.get_subscriptions", return_value=({}, 200)):
- res_get_spec = client.get("/restconf/operations/telemetry/subscription/cli-1/slice/slice-1", headers=auth_headers)
+ res_get_spec = client.get(
+ "/restconf/operations/telemetry/subscription/cli-1/slice/slice-1", headers=auth_headers
+ )
assert res_get_spec.status_code in [200, 500]
# PUT update subscription
@@ -432,13 +444,15 @@ def test_restconf_telemetry_subscriptions_extended(client, auth_headers):
res_put_spec = client.put(
"/restconf/operations/telemetry/subscription/cli-1/slice/slice-1",
headers=auth_headers,
- data=json.dumps({"frequency": 10})
+ data=json.dumps({"frequency": 10}),
)
assert res_put_spec.status_code in [200, 201, 400, 500]
# DELETE specific subscription
with patch("src.api.main.Api.delete_subscriptions", return_value=({}, 204)):
- res_del_spec = client.delete("/restconf/operations/telemetry/subscription/cli-1/slice/slice-1", headers=auth_headers)
+ res_del_spec = client.delete(
+ "/restconf/operations/telemetry/subscription/cli-1/slice/slice-1", headers=auth_headers
+ )
assert res_del_spec.status_code in [200, 204, 500]
@@ -446,7 +460,9 @@ def test_restconf_telemetry_stream_and_slice(client, auth_headers):
"""Test telemetry streaming endpoint and telemetry slice list / slice ID endpoints."""
# Stream endpoint
with patch("src.api.main.Api.sync_stream", return_value=iter(["data: {}\n\n"])):
- res_stream = client.get("/restconf/operations/telemetry/subscription/cli-1/slice/slice-1/stream", headers=auth_headers)
+ res_stream = client.get(
+ "/restconf/operations/telemetry/subscription/cli-1/slice/slice-1/stream", headers=auth_headers
+ )
assert res_stream.status_code in [200, 500]
# GET telemetry for all slices
@@ -459,3 +475,86 @@ def test_restconf_telemetry_stream_and_slice(client, auth_headers):
res_telem_spec = client.get("/restconf/operations/telemetry/slice/slice-1", headers=auth_headers)
assert res_telem_spec.status_code in [200, 404, 500]
+
+# =============================================================================
+# 7. Tests for Swagger Helpers (extract_json_payload)
+# =============================================================================
+
+
+def test_extract_json_payload_direct():
+ """Test extract_json_payload helper function under all branches."""
+ from unittest.mock import MagicMock
+
+ from swagger.helpers import extract_json_payload
+
+ # 1. Valid uploaded JSON file
+ req_file = MagicMock()
+ mock_file = MagicMock()
+ mock_file.filename = "intent.json"
+ req_file.files = {"file": io.BytesIO(b'{"key": "value"}')}
+ req_file.files["file"].filename = "intent.json"
+ req_file.form = {}
+ req_file.get_json.return_value = None
+ payload, err = extract_json_payload(req_file)
+ assert payload == {"key": "value"}
+ assert err is None
+
+ # 2. Invalid extension file
+ req_invalid_ext = MagicMock()
+ req_invalid_ext.files = {"file": io.BytesIO(b"data")}
+ req_invalid_ext.files["file"].filename = "intent.xml"
+ req_invalid_ext.form = {}
+ req_invalid_ext.get_json.return_value = None
+ payload, err = extract_json_payload(req_invalid_ext)
+ assert payload is None
+ assert err[1] == 400
+ assert err[0]["error"] == "Only JSON files allowed"
+
+ # 3. Malformed JSON file
+ req_corrupt_file = MagicMock()
+ req_corrupt_file.files = {"file": io.BytesIO(b"{bad json")}
+ req_corrupt_file.files["file"].filename = "intent.json"
+ req_corrupt_file.form = {}
+ req_corrupt_file.get_json.return_value = None
+ payload, err = extract_json_payload(req_corrupt_file)
+ assert payload is None
+ assert err[1] == 400
+ assert err[0]["error"] == "JSON file not valid"
+
+ # 4. Valid form json_data
+ req_form = MagicMock()
+ req_form.files = {}
+ req_form.form = {"json_data": '{"slice": "active"}'}
+ req_form.get_json.return_value = None
+ payload, err = extract_json_payload(req_form)
+ assert payload == {"slice": "active"}
+ assert err is None
+
+ # 5. Malformed form json_data
+ req_bad_form = MagicMock()
+ req_bad_form.files = {}
+ req_bad_form.form = {"json_data": "{invalid"}
+ req_bad_form.get_json.return_value = None
+ payload, err = extract_json_payload(req_bad_form)
+ assert payload is None
+ assert err[1] == 400
+ assert err[0]["error"] == "JSON file not valid"
+
+ # 6. JSON body
+ req_body = MagicMock()
+ req_body.files = {}
+ req_body.form = {}
+ req_body.get_json.return_value = {"body": True}
+ payload, err = extract_json_payload(req_body)
+ assert payload == {"body": True}
+ assert err is None
+
+ # 7. No data sent
+ req_empty = MagicMock()
+ req_empty.files = {}
+ req_empty.form = {}
+ req_empty.get_json.return_value = None
+ payload, err = extract_json_payload(req_empty)
+ assert payload is None
+ assert err[1] == 400
+ assert err[0]["error"] == "No data sent"
diff --git a/src/tests/test_nbi_processor.py b/src/tests/test_nbi_processor.py
index 29f715f6f18df8faf8d572ee6e864793cf997c32..90d7a30426c8260c880c541351d33271257b346d 100644
--- a/src/tests/test_nbi_processor.py
+++ b/src/tests/test_nbi_processor.py
@@ -14,58 +14,58 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
-import pytest
from unittest.mock import patch
+
+import pytest
+
from src.nbi_processor.detect_format import detect_format
from src.nbi_processor.main import nbi_processor
from src.nbi_processor.translator import translator
-
# ---------- Tests detect_format ----------
+
def test_detect_format_ietf():
data = {"ietf-network-slice-service:network-slice-services": {}}
assert detect_format(data) == "IETF"
+
def test_detect_format_3gpp_variants():
assert detect_format({"RANSliceSubnet1": {}}) == "3GPP"
assert detect_format({"NetworkSlice1": {}}) == "3GPP"
assert detect_format({"TopSliceSubnet1": {}}) == "3GPP"
assert detect_format({"CNSliceSubnet1": {}}) == "3GPP"
+
def test_detect_format_none():
assert detect_format({"foo": "bar"}) is None
# ---------- Fixtures ----------
+
@pytest.fixture
def ietf_intent():
return {"ietf-network-slice-service:network-slice-services": {"foo": "bar"}}
+
@pytest.fixture
def gpp_intent():
# Minimum structure consistent with translator
return {
- "RANSliceSubnet1": {
- "networkSliceSubnetRef": ["subnetA", "subnetB"]
- },
+ "RANSliceSubnet1": {"networkSliceSubnetRef": ["subnetA", "subnetB"]},
"subnetA": {
"EpTransport": ["EpTransport ep1", "EpTransport ep2"],
- "SliceProfileList": [{
- "RANSliceSubnetProfile": {
- "dLThptPerSliceSubnet": {
- "GuaThpt": 1,
- "MaxThpt": 2
- },
- "uLThptPerSliceSubnet": {
- "GuaThpt": 1,
- "MaxThpt": 2
- },
- "dLLatency": 20,
- "uLLatency": 20
+ "SliceProfileList": [
+ {
+ "RANSliceSubnetProfile": {
+ "dLThptPerSliceSubnet": {"GuaThpt": 1, "MaxThpt": 2},
+ "uLThptPerSliceSubnet": {"GuaThpt": 1, "MaxThpt": 2},
+ "dLLatency": 20,
+ "uLLatency": 20,
+ }
}
- }],
+ ],
},
"subnetB": {
"EpTransport": ["EpTransport ep3", "EpTransport ep4"],
@@ -86,8 +86,20 @@ def gpp_intent():
},
"EP_N2 epRef1": {"localAddress": "10.0.0.1", "remoteAddress": "11.1.1.1", "epTransportRef": "ep1"},
"EP_N2 epRef2": {"localAddress": "10.0.0.2", "remoteAddress": "11.1.1.2", "epTransportRef": "ep2"},
- "EpTransport ep3": {"qosProfile": "qosC", "EpApplicationRef": ["EP_N2 epRef3"], "logicalInterfaceInfo": {"logicalInterfaceType": "typeC", "logicalInterfaceId": "idC"}, "IpAddress": "3.3.3.3", "NextHopInfo": "NH3"},
- "EpTransport ep4": {"qosProfile": "qosD", "EpApplicationRef": ["EP_N2 epRef4"], "logicalInterfaceInfo": {"logicalInterfaceType": "typeD", "logicalInterfaceId": "idD"}, "IpAddress": "4.4.4.4", "NextHopInfo": "NH4"},
+ "EpTransport ep3": {
+ "qosProfile": "qosC",
+ "EpApplicationRef": ["EP_N2 epRef3"],
+ "logicalInterfaceInfo": {"logicalInterfaceType": "typeC", "logicalInterfaceId": "idC"},
+ "IpAddress": "3.3.3.3",
+ "NextHopInfo": "NH3",
+ },
+ "EpTransport ep4": {
+ "qosProfile": "qosD",
+ "EpApplicationRef": ["EP_N2 epRef4"],
+ "logicalInterfaceInfo": {"logicalInterfaceType": "typeD", "logicalInterfaceId": "idD"},
+ "IpAddress": "4.4.4.4",
+ "NextHopInfo": "NH4",
+ },
"EP_N2 epRef3": {"localAddress": "10.0.0.3", "remoteAddress": "11.1.1.3", "epTransportRef": "ep3"},
"EP_N2 epRef4": {"localAddress": "10.0.0.4", "remoteAddress": "11.1.1.4", "epTransportRef": "ep4"},
}
@@ -98,20 +110,24 @@ def fake_template():
# Minimum template for translator to work
return {
"ietf-network-slice-service:network-slice-services": {
- "slo-sle-templates": {
- "slo-sle-template": [
- {"id": "", "slo-policy": {"metric-bound": []}}
- ]
- },
+ "slo-sle-templates": {"slo-sle-template": [{"id": "", "slo-policy": {"metric-bound": []}}]},
"slice-service": [
{
"id": "",
"description": "",
"slo-sle-template": "",
- "sdps": {"sdp": [
- {"service-match-criteria": {"match-criterion": [{"match-type":[{"type":""}]}]}, "attachment-circuits": {"attachment-circuit": [{"sdp-peering": {}}]}},
- {"service-match-criteria": {"match-criterion": [{"match-type":[{"type":""}]}]}, "attachment-circuits": {"attachment-circuit": [{"sdp-peering": {}}]}}
- ]},
+ "sdps": {
+ "sdp": [
+ {
+ "service-match-criteria": {"match-criterion": [{"match-type": [{"type": ""}]}]},
+ "attachment-circuits": {"attachment-circuit": [{"sdp-peering": {}}]},
+ },
+ {
+ "service-match-criteria": {"match-criterion": [{"match-type": [{"type": ""}]}]},
+ "attachment-circuits": {"attachment-circuit": [{"sdp-peering": {}}]},
+ },
+ ]
+ },
"connection-groups": {"connection-group": [{}]},
}
],
@@ -121,11 +137,13 @@ def fake_template():
# ---------- Tests nbi_processor ----------
+
def test_nbi_processor_ietf(ietf_intent):
result = nbi_processor(ietf_intent)
assert isinstance(result, list)
assert result[0] == ietf_intent
+
@patch("src.nbi_processor.main.translator")
def test_nbi_processor_3gpp(mock_translator, gpp_intent):
mock_translator.return_value = {"ietf-network-slice-service:network-slice-services": {}}
@@ -134,10 +152,12 @@ def test_nbi_processor_3gpp(mock_translator, gpp_intent):
assert len(result) == 2 # Dos subnets procesados
assert all("ietf-network-slice-service:network-slice-services" in r for r in result)
+
def test_nbi_processor_unrecognized():
with pytest.raises(ValueError):
nbi_processor({"foo": "bar"})
+
def test_nbi_processor_empty():
with pytest.raises(ValueError):
nbi_processor({})
@@ -145,6 +165,7 @@ def test_nbi_processor_empty():
# ---------- Tests translator ----------
+
@patch("src.nbi_processor.translator.load_template")
def test_translator_basic(mock_load_template, gpp_intent, fake_template):
mock_load_template.return_value = fake_template
@@ -159,33 +180,34 @@ def test_translator_basic(mock_load_template, gpp_intent, fake_template):
assert "description" in slice_service
assert slice_service["slo-sle-template"] == "qosA" # viene del ep1
-import re
-import uuid
+import re
# ---------- Extra detect_format ----------
-@pytest.mark.parametrize("data", [
- None,
- [],
- "",
- 123,
-])
+
+@pytest.mark.parametrize(
+ "data",
+ [
+ None,
+ [],
+ "",
+ 123,
+ ],
+)
def test_detect_format_invalid_types(data):
assert detect_format(data if isinstance(data, dict) else {}) in (None, "IETF", "3GPP")
def test_detect_format_multiple_keys():
# If it has IETF and 3GPP, should prioritize IETF
- data = {
- "ietf-network-slice-service:network-slice-services": {},
- "RANSliceSubnet1": {}
- }
+ data = {"ietf-network-slice-service:network-slice-services": {}, "RANSliceSubnet1": {}}
assert detect_format(data) == "IETF"
# ---------- Extra nbi_processor ----------
+
def test_nbi_processor_gpp_missing_refs(gpp_intent):
# Removing networkSliceSubnetRef should cause ValueError in translator loop
broken = gpp_intent.copy()
@@ -196,12 +218,15 @@ def test_nbi_processor_gpp_missing_refs(gpp_intent):
# ---------- Extra translator ----------
+
@patch("src.nbi_processor.translator.load_template")
def test_translator_maps_metrics(mock_load_template, gpp_intent, fake_template):
mock_load_template.return_value = fake_template
result = translator(gpp_intent, "subnetA")
- metrics = result["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["slo-policy"]["metric-bound"]
+ metrics = result["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0][
+ "slo-policy"
+ ]["metric-bound"]
metric_types = {m["metric-type"] for m in metrics}
assert "one-way-delay-maximum" in metric_types
assert "one-way-bandwidth" in metric_types
@@ -212,9 +237,12 @@ def test_translator_empty_profile(mock_load_template, gpp_intent, fake_template)
mock_load_template.return_value = fake_template
gpp_intent["subnetA"]["SliceProfileList"] = [{}] # empty
result = translator(gpp_intent, "subnetA")
- metrics = result["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["slo-policy"]["metric-bound"]
+ metrics = result["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0][
+ "slo-policy"
+ ]["metric-bound"]
assert metrics == [] # should not add anything
+
@patch("src.nbi_processor.translator.load_template")
def test_translator_sdps_are_populated(mock_load_template, gpp_intent, fake_template):
mock_load_template.return_value = fake_template
diff --git a/src/tests/test_planner.py b/src/tests/test_planner.py
index 32837f50737d8268b0e2255395cd76b1e6f13110..0d2ee9108a84b95193b184005b38fc4a4abbef0e 100644
--- a/src/tests/test_planner.py
+++ b/src/tests/test_planner.py
@@ -14,21 +14,32 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
+from unittest.mock import MagicMock, patch
+
import pytest
-from unittest.mock import patch, MagicMock
import requests
-from src.planner.planner import Planner
-from src.planner.shortest_path import normalize_node_id, get_shortest_path
-from src.planner.energy_planner.energy import energy_planner, retrieve_energy, retrieve_topology
-from src.planner.hrat_planner.hrat import hrat_planner
+from src.api.main import Api
+from src.main import NSController
+from src.planner.change_scheduler_planner.change_scheduler import (
+ _find_link_info,
+ change_scheduler_planner,
+)
from src.planner.e2e_optical_planner.e2e_optical import e2e_optical_planner
-
+from src.planner.energy_planner.energy import (
+ energy_planner,
+ retrieve_energy,
+ retrieve_topology,
+)
+from src.planner.hrat_planner.hrat import hrat_planner
+from src.planner.planner import Planner
+from src.planner.shortest_path import get_shortest_path, normalize_node_id
# =============================================================================
# 1. Tests for main Planner Dispatcher (src/planner/planner.py)
# =============================================================================
+
def test_planner_dispatch_energy(flask_app, sample_ietf_intent):
"""Test Planner.planner dispatches to energy_planner when type is ENERGY."""
planner = Planner()
@@ -77,6 +88,7 @@ def test_planner_dispatch_invalid(flask_app, sample_ietf_intent):
# 2. Tests for Shortest Path Algorithm (src/planner/shortest_path.py)
# =============================================================================
+
def test_normalize_node_id():
"""Test normalization of node URN identifiers."""
assert normalize_node_id("urn:tfs:node:A") == "A"
@@ -93,15 +105,9 @@ def test_get_shortest_path_success():
{"node-id": "urn:tfs:node:C"},
],
"ietf-network-topology:link": [
- {
- "source": {"source-node": "urn:tfs:node:A"},
- "destination": {"dest-node": "urn:tfs:node:B"}
- },
- {
- "source": {"source-node": "urn:tfs:node:B"},
- "destination": {"dest-node": "urn:tfs:node:C"}
- }
- ]
+ {"source": {"source-node": "urn:tfs:node:A"}, "destination": {"dest-node": "urn:tfs:node:B"}},
+ {"source": {"source-node": "urn:tfs:node:B"}, "destination": {"dest-node": "urn:tfs:node:C"}},
+ ],
}
path, code = get_shortest_path(network, "urn:tfs:node:A", "urn:tfs:node:C", directed_graph=False)
assert code == 200
@@ -110,10 +116,7 @@ def test_get_shortest_path_success():
def test_get_shortest_path_missing_source():
"""Test error handling when source node is absent."""
- network = {
- "node": [{"node-id": "B"}],
- "ietf-network-topology:link": []
- }
+ network = {"node": [{"node-id": "B"}], "ietf-network-topology:link": []}
res, code = get_shortest_path(network, "A", "B")
assert code == 404
assert res == {"message": "Source node 'A' not found"}
@@ -121,10 +124,7 @@ def test_get_shortest_path_missing_source():
def test_get_shortest_path_missing_destination():
"""Test error handling when destination node is absent."""
- network = {
- "node": [{"node-id": "A"}],
- "ietf-network-topology:link": []
- }
+ network = {"node": [{"node-id": "A"}], "ietf-network-topology:link": []}
res, code = get_shortest_path(network, "A", "B")
assert code == 404
assert res == {"message": "Destination node 'B' not found"}
@@ -132,10 +132,7 @@ def test_get_shortest_path_missing_destination():
def test_get_shortest_path_no_path():
"""Test error handling when destination is disconnected from source."""
- network = {
- "node": [{"node-id": "A"}, {"node-id": "B"}],
- "ietf-network-topology:link": []
- }
+ network = {"node": [{"node-id": "A"}, {"node-id": "B"}], "ietf-network-topology:link": []}
res, code = get_shortest_path(network, "A", "B")
assert code == 404
assert res == {"message": "No path found"}
@@ -145,12 +142,7 @@ def test_get_shortest_path_directed():
"""Test shortest path on a directed graph."""
network = {
"node": [{"node-id": "A"}, {"node-id": "B"}],
- "ietf-network-topology:link": [
- {
- "source": {"source-node": "A"},
- "destination": {"dest-node": "B"}
- }
- ]
+ "ietf-network-topology:link": [{"source": {"source-node": "A"}, "destination": {"dest-node": "B"}}],
}
# Path A -> B should succeed
path, code = get_shortest_path(network, "A", "B", directed_graph=True)
@@ -167,6 +159,7 @@ def test_get_shortest_path_directed():
# 3. Tests for Energy Planner (src/planner/energy_planner/energy.py)
# =============================================================================
+
def test_retrieve_energy_and_topology(flask_app):
"""Test metric and topology dataset loading functions."""
energy = retrieve_energy()
@@ -180,14 +173,7 @@ def test_energy_planner_invalid_nodes(flask_app):
"""Test energy planner returns None when source/dest nodes are outside allowed set."""
intent = {
"ietf-network-slice-service:network-slice-services": {
- "slice-service": [{
- "sdps": {
- "sdp": [
- {"node-id": "NODE_X"},
- {"node-id": "NODE_Y"}
- ]
- }
- }]
+ "slice-service": [{"sdps": {"sdp": [{"node-id": "NODE_X"}, {"node-id": "NODE_Y"}]}}]
}
}
with flask_app.app_context():
@@ -219,6 +205,7 @@ def test_energy_planner_pce_external(flask_app, sample_ietf_intent):
# 4. Tests for HRAT Planner (src/planner/hrat_planner/hrat.py)
# =============================================================================
+
@patch("requests.post")
def test_hrat_planner_create_success(mock_post):
"""Test HRAT create action success path."""
@@ -265,6 +252,7 @@ def test_hrat_planner_http_error(mock_post):
# 5. Tests for E2E Optical Planner (src/planner/e2e_optical_planner/e2e_optical.py)
# =============================================================================
+
@patch("requests.post")
def test_e2e_optical_planner_create_success(mock_post):
"""Test E2E Optical planner path creation success."""
@@ -305,3 +293,151 @@ def test_e2e_optical_planner_failure(mock_post):
mock_post.side_effect = requests.exceptions.Timeout("Timed out")
res_timeout = e2e_optical_planner(intent={}, ip="10.0.0.1", action="create")
assert res_timeout is None
+
+
+# =============================================================================
+# 6. Tests for Change Scheduler Planner
+# =============================================================================
+
+
+@pytest.fixture
+def change_scheduler_sample_network():
+ return {
+ "node": [
+ {"node-id": "urn:tfs:node:xrv11", "ietf-l3-unicast-topology:l3-node-attributes": {"name": "xrv11"}},
+ {"node-id": "urn:tfs:node:xrv12", "ietf-l3-unicast-topology:l3-node-attributes": {"name": "xrv12"}},
+ {"node-id": "urn:tfs:node:xrv13", "ietf-l3-unicast-topology:l3-node-attributes": {"name": "xrv13"}},
+ {"node-id": "urn:tfs:node:xrv14", "ietf-l3-unicast-topology:l3-node-attributes": {"name": "xrv14"}},
+ {"node-id": "urn:tfs:node:xrv15", "ietf-l3-unicast-topology:l3-node-attributes": {"name": "xrv15"}},
+ ],
+ "ietf-network-topology:link": [
+ {
+ "link-id": "xrv11-Gi0/0/0/1-xrv12-Gi0/0/0/1",
+ "source": {"source-node": "urn:tfs:node:xrv11"},
+ "destination": {"dest-node": "urn:tfs:node:xrv12"},
+ },
+ {
+ "link-id": "xrv12-Gi0/0/0/1-xrv13-Gi0/0/0/1",
+ "source": {"source-node": "urn:tfs:node:xrv12"},
+ "destination": {"dest-node": "urn:tfs:node:xrv13"},
+ },
+ {
+ "link-id": "xrv13-Gi0/0/0/1-xrv14-Gi0/0/0/1",
+ "source": {"source-node": "urn:tfs:node:xrv13"},
+ "destination": {"dest-node": "urn:tfs:node:xrv14"},
+ },
+ {
+ "link-id": "xrv11-Gi0/0/0/1-xrv15-Gi0/0/0/1",
+ "source": {"source-node": "urn:tfs:node:xrv11"},
+ "destination": {"dest-node": "urn:tfs:node:xrv15"},
+ },
+ {
+ "link-id": "xrv15-Gi0/0/0/1-xrv14-Gi0/0/0/1",
+ "source": {"source-node": "urn:tfs:node:xrv15"},
+ "destination": {"dest-node": "urn:tfs:node:xrv14"},
+ },
+ ],
+ }
+
+
+def test_find_link_info(change_scheduler_sample_network):
+ underlay_links = change_scheduler_sample_network["ietf-network-topology:link"]
+ src_node, link_id = _find_link_info("xrv13", "xrv14", underlay_links)
+ assert src_node == "urn:tfs:node:xrv13"
+ assert link_id == "xrv13-Gi0/0/0/1-xrv14-Gi0/0/0/1"
+
+ src_node_unk, link_id_unk = _find_link_info("A", "B", underlay_links)
+ assert src_node_unk == "A"
+ assert link_id_unk == "A-B"
+
+
+def test_change_scheduler_planner_success(flask_app, change_scheduler_sample_network):
+ with flask_app.app_context():
+ flask_app.config["DUMMY_MODE"] = False
+ with (
+ patch("src.planner.change_scheduler_planner.change_scheduler.get_shortest_path") as mock_sp,
+ patch("requests.post") as mock_post,
+ ):
+ mock_sp.return_value = (["xrv11", "xrv15", "xrv14"], 200)
+
+ mock_response = MagicMock()
+ mock_response.status_code = 200
+ mock_response.headers = {"content-type": "application/json"}
+ mock_response.json.return_value = {"status": "SCHEDULED"}
+ mock_post.return_value = mock_response
+
+ res = change_scheduler_planner(
+ "slice-1",
+ current_path=["xrv11", "xrv12", "xrv13", "xrv14"],
+ network=change_scheduler_sample_network,
+ change_scheduler_url="http://127.0.0.1:8090/change-scheduler/request",
+ )
+
+ assert res["slice_id"] == "slice-1"
+ assert res["new_path"] == ["xrv11", "xrv15", "xrv14"]
+
+ payload = res["request_payload"]
+ assert "ietf-tvr-topology:topology-schedule" in payload
+ schedule = payload["ietf-tvr-topology:topology-schedule"]
+
+ assert "links" in schedule
+ assert "nodes" in schedule
+
+ link_availabilities = [link["available"]["default-link-available"] for link in schedule["links"]]
+ assert "false" in link_availabilities
+ assert "true" in link_availabilities
+
+ node_availabilities = {
+ node["node-id"]: node["available"]["default-node-available"] for node in schedule["nodes"]
+ }
+ assert node_availabilities.get("xrv12") == "false"
+ assert node_availabilities.get("xrv13") == "false"
+ assert node_availabilities.get("xrv15") == "true"
+
+ mock_post.assert_called_once()
+ call_url = mock_post.call_args[0][0]
+ assert call_url == "http://127.0.0.1:8090/change-scheduler/request"
+
+
+def test_change_scheduler_planner_no_services():
+ with pytest.raises(Exception) as exc:
+ change_scheduler_planner("nonexistent-slice", current_path=None)
+ assert "Invalid current service path" in str(exc.value)
+
+
+def test_change_scheduler_planner_service_path_failure(flask_app):
+ with pytest.raises(Exception) as exc:
+ change_scheduler_planner("slice-1", current_path=[])
+ assert "Invalid current service path" in str(exc.value)
+
+
+def test_monitoring_automatic_reconfig_change_scheduler(flask_app):
+ with flask_app.app_context():
+ flask_app.config["DUMMY_MODE"] = False
+ flask_app.config["PLANNER_TYPE"] = "CHANGE_SCHEDULER"
+ with (
+ patch("src.main.realizer") as mock_realizer,
+ patch("src.main.mapper") as mock_mapper,
+ patch("src.main.NSController.nsc") as mock_nsc,
+ ):
+ mock_realizer.return_value = None
+ mock_mapper.return_value = {"slo_sle_compliance": {"is_compliant": False}}
+ mock_nsc.return_value = {"slice_id": "slice-auto", "status": "VIABLE"}
+
+ nsc = NSController(controller_type="TFS")
+ res = nsc.monitoring("slice-auto", {"slo": "test"})
+
+ assert res.get("reconfig_result") == {"slice_id": "slice-auto", "status": "VIABLE"}
+ mock_nsc.assert_called_once()
+
+
+def test_planner_class_change_scheduler():
+ with patch("src.planner.planner.change_scheduler_planner") as mock_planner:
+ mock_planner.return_value = {"slice_id": "slice-999", "new_path": ["A", "B"]}
+
+ p = Planner()
+ res = p.planner("slice-999", type="CHANGE_SCHEDULER", is_update=True)
+
+ assert res == {"slice_id": "slice-999", "new_path": ["A", "B"]}
+ mock_planner.assert_called_once_with("slice-999", current_path=None, network=None)
+
diff --git a/src/tests/test_realizer.py b/src/tests/test_realizer.py
index 26d9e79d35f9cef3b16270127321d6ccce5d3855..3c81cc2333ca208cfb89399e7af4a3467e3f0158 100644
--- a/src/tests/test_realizer.py
+++ b/src/tests/test_realizer.py
@@ -14,19 +14,21 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
+from unittest.mock import MagicMock, patch
+
import pytest
-from unittest.mock import patch, MagicMock
+
+from src.realizer.get_metrics import get_metrics
from src.realizer.main import realizer
+from src.realizer.nrp_handler import nrp_handler
from src.realizer.select_way import select_way
from src.realizer.send_controller import send_controller
-from src.realizer.nrp_handler import nrp_handler
-from src.realizer.get_metrics import get_metrics
-
# =============================================================================
# 1. Tests for Realizer Entrypoint (src/realizer/main.py)
# =============================================================================
+
def test_realizer_create_with_nrp():
"""Test realizer CREATE flow when need_nrp is True."""
with patch("src.realizer.main.nrp_handler") as mock_nrp:
@@ -36,20 +38,23 @@ def test_realizer_create_with_nrp():
mock_nrp.assert_called_once_with("READ", {"id": "nrp-1"})
-@pytest.mark.parametrize("rule_type,expected_way", [
- ("XR_AGENT_ACTIVATE_TRANSCEIVER", "L3oWDM"),
- ("PROVISION_MEDIA_CHANNEL_AND_CONFIG_VPNL3", "L3oWDM"),
- ("PROVISION_MEDIA_CHANNEL_AND_CONFIG_VPNL2", "L2oWDM"),
- ("PROVISION_MEDIA_CHANNEL", "OPTIC"),
- ("CONFIG_VPNL3", "L3VPN"),
- ("CONFIG_VPNL2", "L2VPN"),
- ("DEACTIVATE_XR_AGENT_TRANSCEIVER", "DEL_L3oWDM"),
- ("DEPROVISION_OPTICAL_RESOURCE_AND_REMOVE_VPNL3", "DEL_L3oWDM"),
- ("DEPROVISION_OPTICAL_RESOURCE_AND_REMOVE_VPNL2", "DEL_L2oWDM"),
- ("DEPROVISION_OPTICAL_RESOURCE", "DEL_OPTIC"),
- ("REMOVE_VPNL3", "DEL_L3VPN"),
- ("REMOVE_VPNL2", "DEL_L2VPN"),
-])
+@pytest.mark.parametrize(
+ "rule_type,expected_way",
+ [
+ ("XR_AGENT_ACTIVATE_TRANSCEIVER", "L3oWDM"),
+ ("PROVISION_MEDIA_CHANNEL_AND_CONFIG_VPNL3", "L3oWDM"),
+ ("PROVISION_MEDIA_CHANNEL_AND_CONFIG_VPNL2", "L2oWDM"),
+ ("PROVISION_MEDIA_CHANNEL", "OPTIC"),
+ ("CONFIG_VPNL3", "L3VPN"),
+ ("CONFIG_VPNL2", "L2VPN"),
+ ("DEACTIVATE_XR_AGENT_TRANSCEIVER", "DEL_L3oWDM"),
+ ("DEPROVISION_OPTICAL_RESOURCE_AND_REMOVE_VPNL3", "DEL_L3oWDM"),
+ ("DEPROVISION_OPTICAL_RESOURCE_AND_REMOVE_VPNL2", "DEL_L2oWDM"),
+ ("DEPROVISION_OPTICAL_RESOURCE", "DEL_OPTIC"),
+ ("REMOVE_VPNL3", "DEL_L3VPN"),
+ ("REMOVE_VPNL2", "DEL_L2VPN"),
+ ],
+)
def test_realizer_create_e2e_rules(rule_type, expected_way):
"""Test E2E rule resolution maps action types to correct realization ways."""
rules = []
@@ -103,13 +108,12 @@ def test_realizer_create_service_tag_way(sample_ietf_intent):
def test_realizer_monitor_success(flask_app):
"""Test realizer MONITOR action succeeds when service_id and service path are retrieved."""
- payload = {
- "slice_id": "slice-1"
- }
- 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:
-
+ payload = {"slice_id": "slice-1"}
+ 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_service_path.return_value = (["A", "B"], 200)
@@ -124,12 +128,11 @@ def test_realizer_monitor_success(flask_app):
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"
- }
- with patch("src.realizer.main.get_data_by_slice_id") as mock_db, \
- patch("src.realizer.main.tfs_connector") as mock_tfs_cls:
-
+ payload = {"slice_id": "slice-1"}
+ 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_service_path.return_value = ([], 500)
@@ -142,13 +145,11 @@ def test_realizer_monitor_service_path_failure(flask_app):
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"
- }
+ 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"}
+ {"service_id": "svc-2", "slice_id": "slice-multi"},
]
with flask_app.app_context():
@@ -160,6 +161,7 @@ def test_realizer_monitor_multiple_services_failure(flask_app):
# 2. Tests for Select Way Dispatcher (src/realizer/select_way.py)
# =============================================================================
+
@patch("src.realizer.select_way.tfs")
@patch("src.realizer.select_way.ixia")
@patch("src.realizer.select_way.e2e")
@@ -187,6 +189,7 @@ def test_select_way_dispatchers(mock_restconf, mock_e2e, mock_ixia, mock_tfs):
# 3. Tests for Send Controller Dispatcher (src/realizer/send_controller.py)
# =============================================================================
+
def test_send_controller_dummy_mode(flask_app):
"""Test send_controller returns True when DUMMY_MODE is enabled."""
flask_app.config["DUMMY_MODE"] = True
@@ -230,6 +233,7 @@ def test_send_controller_non_dummy(mock_rc, mock_e2e, mock_ixia, mock_tfs, flask
# 4. Tests for NRP Handler (src/realizer/nrp_handler.py)
# =============================================================================
+
def test_nrp_handler_operations(tmp_path, monkeypatch):
"""Test NRP handler database READ, CREATE, UPDATE operations."""
nrp_file = tmp_path / "nrp_ddbb.json"
@@ -253,6 +257,7 @@ def test_nrp_handler_operations(tmp_path, monkeypatch):
# 5. Tests for Get Metrics Streamer (src/realizer/get_metrics.py)
# =============================================================================
+
def test_get_metrics(flask_app):
"""Test telemetry metrics stream initialization and cache skipping."""
flask_app.config["TELEMETRY_CACHE"] = {}
@@ -290,6 +295,7 @@ def test_get_metrics(flask_app):
# 6. Tests for RESTCONF Service Types & Builders
# =============================================================================
+
class TestRestconfServiceTypesAndBuilders:
"""Full coverage unit tests for RESTCONF service types (l2vpn, l3vpn) and builders."""
@@ -312,14 +318,10 @@ class TestRestconfServiceTypesAndBuilders:
"sdp": {
"id": "sdp-1",
"node-id": "N1",
- "attachment-circuits": {
- "attachment-circuit": [
- {"ac-node-id": "R1", "ac-tp-id": "Eth1"}
- ]
- }
+ "attachment-circuits": {"attachment-circuit": [{"ac-node-id": "R1", "ac-tp-id": "Eth1"}]},
}
}
- ]
+ ],
}
res = l2vpn(intent)
assert res is not None
@@ -339,12 +341,17 @@ class TestRestconfServiceTypesAndBuilders:
"node-id": "N1",
"attachment-circuits": {
"attachment-circuit": [
- {"ac-node-id": "R1", "ac-tp-id": "Eth1", "ac-ipv4-address": "10.0.0.1", "ac-ipv4-prefix-length": 24}
+ {
+ "ac-node-id": "R1",
+ "ac-tp-id": "Eth1",
+ "ac-ipv4-address": "10.0.0.1",
+ "ac-ipv4-prefix-length": 24,
+ }
]
- }
+ },
}
}
- ]
+ ],
}
res = l3vpn(intent)
assert res is not None
@@ -352,7 +359,9 @@ class TestRestconfServiceTypesAndBuilders:
assert len(res["ietf-l3vpn-svc:l3vpn-svc"]["sites"]["site"]) == 1
def test_initialize_structure(self):
- from src.realizer.restconf.service_types.builders.initialize_structure import initialize_structure
+ from src.realizer.restconf.service_types.builders.initialize_structure import (
+ initialize_structure,
+ )
l2_struct = initialize_structure("vpn-l2", "point-to-point", layer_type="l2")
assert "ietf-l2vpn-svc:l2vpn-svc" in l2_struct
@@ -362,7 +371,9 @@ class TestRestconfServiceTypesAndBuilders:
assert "ietf-l3vpn-svc:l3vpn-svc" in l3_struct
def test_create_network_access_roles_and_layers(self):
- from src.realizer.restconf.service_types.builders.create_network_access import create_network_access
+ from src.realizer.restconf.service_types.builders.create_network_access import (
+ create_network_access,
+ )
sdp_sender = {"type": "sender", "sdp": {"id": "sdp-1"}}
sdp_receiver = {"type": "receiver", "sdp": {"id": "sdp-2"}}
@@ -388,9 +399,14 @@ class TestRestconfServiceTypesAndBuilders:
create_network_access(sdp_any, {"id": "v-1"}, "point-to-point", "R1", "Eth1", "l4")
def test_configure_match_criteria_variants(self):
- from src.realizer.restconf.service_types.builders.configure_match_criteria import configure_match_criteria
+ from src.realizer.restconf.service_types.builders.configure_match_criteria import (
+ configure_match_criteria,
+ )
- net_access = {"service": {"qos": {"qos-classification-policy": {"rule": []}}}, "connection": {"tagged-interface": {"dot1q-vlan-tagged": {}}}}
+ net_access = {
+ "service": {"qos": {"qos-classification-policy": {"rule": []}}},
+ "connection": {"tagged-interface": {"dot1q-vlan-tagged": {}}},
+ }
site = {}
# None match criteria
@@ -403,11 +419,20 @@ class TestRestconfServiceTypesAndBuilders:
# L3 vlan match criteria -> static routing protocol
sdp_vlan = {
"match_criteria": {"index": 1, "match-type": [{"type": "vlan", "vlan": [100]}]},
- "sdp": {"attachment-circuits": {"attachment-circuit": [{"ac-ipv4-address": "10.0.0.1", "ac-ipv4-prefix-length": 24}]}}
+ "sdp": {
+ "attachment-circuits": {
+ "attachment-circuit": [{"ac-ipv4-address": "10.0.0.1", "ac-ipv4-prefix-length": 24}]
+ }
+ },
}
configure_match_criteria(net_access, site, sdp_vlan, "l3")
assert "routing-protocols" in site
- assert site["routing-protocols"]["routing-protocol"][0]["static"]["cascaded-lan-prefixes"]["ipv4-lan-prefixes"][0]["lan-tag"] == 100
+ assert (
+ site["routing-protocols"]["routing-protocol"][0]["static"]["cascaded-lan-prefixes"]["ipv4-lan-prefixes"][0][
+ "lan-tag"
+ ]
+ == 100
+ )
# L2 vlan match criteria
configure_match_criteria(net_access, site, sdp_vlan, "l2")
@@ -417,29 +442,31 @@ class TestRestconfServiceTypesAndBuilders:
sdp_dscp = {"match_criteria": {"index": 2, "match-type": [{"type": "dscp", "dscp": [46]}]}}
configure_match_criteria(net_access, site, sdp_dscp, "l3")
- sdp_src_ip = {"match_criteria": {"index": 3, "match-type": [{"type": "source-ip-prefix", "source-ip-prefix": ["192.168.1.0/24"]}]}}
+ sdp_src_ip = {
+ "match_criteria": {
+ "index": 3,
+ "match-type": [{"type": "source-ip-prefix", "source-ip-prefix": ["192.168.1.0/24"]}],
+ }
+ }
configure_match_criteria(net_access, site, sdp_src_ip, "l3")
- sdp_dst_ip = {"match_criteria": {"index": 4, "match-type": [{"type": "destination-ip-prefix", "destination-ip-prefix": ["10.1.1.0/24"]}]}}
+ sdp_dst_ip = {
+ "match_criteria": {
+ "index": 4,
+ "match-type": [{"type": "destination-ip-prefix", "destination-ip-prefix": ["10.1.1.0/24"]}],
+ }
+ }
configure_match_criteria(net_access, site, sdp_dst_ip, "l3")
sdp_unknown = {"match_criteria": {"index": 5, "match-type": [{"type": "invalid-type", "invalid-type": [1]}]}}
configure_match_criteria(net_access, site, sdp_unknown, "l3")
def test_configure_slos_and_apply_metric_constraint(self):
- from src.realizer.restconf.service_types.builders.configure_slos import configure_slos
-
- net_access_l2 = {
- "service": {
- "qos": {
- "qos-profile": {
- "classes": {
- "class": [{"class-id": "qos-realtime"}]
- }
- }
- }
- }
- }
+ from src.realizer.restconf.service_types.builders.configure_slos import (
+ configure_slos,
+ )
+
+ net_access_l2 = {"service": {"qos": {"qos-profile": {"classes": {"class": [{"class-id": "qos-realtime"}]}}}}}
intent_full = {
"id": "vpn-1",
"template": {
@@ -450,27 +477,17 @@ class TestRestconfServiceTypesAndBuilders:
{"metric-type": "two-way-bandwidth", "bound": 10, "metric-unit": "Mbps"},
{"metric-type": "two-way-delay-maximum", "bound": 20},
{"metric-type": "two-way-delay-variation-maximum", "bound": 5},
- {"metric-type": "two-way-packet-loss", "bound": 0.01}
- ]
+ {"metric-type": "two-way-packet-loss", "bound": 0.01},
+ ],
}
- }
+ },
}
configure_slos(net_access_l2, intent_full, "l2")
assert net_access_l2["service"]["svc-mtu"] == 1400
assert net_access_l2["service"]["svc-bandwidth"]["bandwidth"][0]["cir"] == 10_000_000
# Test L3 constraints and defaults
- net_access_l3 = {
- "service": {
- "qos": {
- "qos-profile": {
- "classes": {
- "class": [{"class-id": "qos-realtime"}]
- }
- }
- }
- }
- }
+ net_access_l3 = {"service": {"qos": {"qos-profile": {"classes": {"class": [{"class-id": "qos-realtime"}]}}}}}
intent_units = {
"id": "vpn-2",
"template": {
@@ -480,36 +497,34 @@ class TestRestconfServiceTypesAndBuilders:
{"metric-type": "two-way-bandwidth", "bound": 500, "metric-unit": "kbps"},
{"metric-type": "two-way-bandwidth", "bound": 100, "metric-unit": "bps"},
{"metric-type": "two-way-delay-maximum", "bound": 15},
- {"metric-type": "two-way-delay-variation-maximum", "bound": 3}
+ {"metric-type": "two-way-delay-variation-maximum", "bound": 3},
]
}
- }
+ },
}
configure_slos(net_access_l3, intent_units, "l3")
assert net_access_l3["service"]["svc-mtu"] == 1500
- assert net_access_l3["service"]["qos"]["qos-profile"]["classes"]["class"][0]["bandwidth"]["guaranteed-bw-percent"] == 0
+ assert (
+ net_access_l3["service"]["qos"]["qos-profile"]["classes"]["class"][0]["bandwidth"]["guaranteed-bw-percent"]
+ == 0
+ )
class TestRestconfConnect:
"""Full coverage tests for src/realizer/restconf/restconf_connect.py."""
def test_restconf_connect_l2vpn_success(self, flask_app):
- from src.realizer.restconf.restconf_connect import restconf_connect, _slice_manager
+ from src.realizer.restconf.restconf_connect import (
+ restconf_connect,
+ )
flask_app.config["SDN_CONTROLLER_TYPE"] = "TFS"
- requests_payload = {
- "services": [
- {
- "ietf-l2vpn-svc:l2vpn-svc": {"vpn-services": {}}
- }
- ]
- }
+ requests_payload = {"services": [{"ietf-l2vpn-svc:l2vpn-svc": {"vpn-services": {}}}]}
mock_resp = MagicMock()
mock_resp.ok = True
- with flask_app.app_context(), \
- patch("src.realizer.restconf.restconf_connect.tfs_connector") as mock_conn_cls:
+ with flask_app.app_context(), patch("src.realizer.restconf.restconf_connect.tfs_connector") as mock_conn_cls:
mock_conn = MagicMock()
mock_conn.nbi_post.return_value = mock_resp
mock_conn_cls.return_value = mock_conn
@@ -518,22 +533,24 @@ class TestRestconfConnect:
assert res == mock_resp
def test_restconf_connect_l3vpn_frr_branch(self, flask_app):
- from src.realizer.restconf.restconf_connect import restconf_connect, _slice_manager
+ from src.realizer.restconf.restconf_connect import (
+ _slice_manager,
+ restconf_connect,
+ )
flask_app.config["SDN_CONTROLLER_TYPE"] = "TFS"
flask_app.config["DATAPLANE_SUPPORT"] = "FRR"
- l3_service = {
- "ietf-l3vpn-svc:l3vpn-svc": {}
- }
+ l3_service = {"ietf-l3vpn-svc:l3vpn-svc": {}}
mock_resp = MagicMock()
mock_resp.ok = True
- with flask_app.app_context(), \
- patch("src.realizer.restconf.restconf_connect.safe_get", return_value=46), \
- patch("src.realizer.restconf.restconf_connect.tfs_connector") as mock_conn_cls:
-
+ with (
+ flask_app.app_context(),
+ patch("src.realizer.restconf.restconf_connect.safe_get", return_value=46),
+ patch("src.realizer.restconf.restconf_connect.tfs_connector") as mock_conn_cls,
+ ):
mock_conn = MagicMock()
mock_conn.nbi_post.return_value = mock_resp
mock_conn_cls.return_value = mock_conn
@@ -544,26 +561,32 @@ class TestRestconfConnect:
assert res_429[1] == 429
# Case 2: Assign slot fails -> 429
- with patch.object(_slice_manager, "is_full", return_value=False), \
- patch.object(_slice_manager, "assign_slot", return_value=None):
+ with (
+ patch.object(_slice_manager, "is_full", return_value=False),
+ patch.object(_slice_manager, "assign_slot", return_value=None),
+ ):
res_no_slot = restconf_connect({"services": [l3_service]}, "10.0.0.1")
assert res_no_slot[1] == 429
# Case 3: FRR command execution raises Exception -> 500
mock_frr = MagicMock()
mock_frr.execute_commands.side_effect = Exception("SSH failure")
- with patch.object(_slice_manager, "is_full", return_value=False), \
- patch.object(_slice_manager, "assign_slot", return_value=1), \
- patch("src.realizer.restconf.restconf_connect.frr_connector", return_value=mock_frr):
+ with (
+ patch.object(_slice_manager, "is_full", return_value=False),
+ patch.object(_slice_manager, "assign_slot", return_value=1),
+ patch("src.realizer.restconf.restconf_connect.frr_connector", return_value=mock_frr),
+ ):
res_500 = restconf_connect({"services": [l3_service]}, "10.0.0.1")
assert res_500[1] == 500
# Case 4: FRR success -> proceed to nbi_post and return mock_resp
mock_frr_ok = MagicMock()
mock_frr_ok.execute_commands.return_value = None
- with patch.object(_slice_manager, "is_full", return_value=False), \
- patch.object(_slice_manager, "assign_slot", return_value=1), \
- patch("src.realizer.restconf.restconf_connect.frr_connector", return_value=mock_frr_ok):
+ with (
+ patch.object(_slice_manager, "is_full", return_value=False),
+ patch.object(_slice_manager, "assign_slot", return_value=1),
+ patch("src.realizer.restconf.restconf_connect.frr_connector", return_value=mock_frr_ok),
+ ):
res_ok = restconf_connect({"services": [l3_service]}, "10.0.0.1")
assert res_ok == mock_resp
@@ -583,8 +606,7 @@ class TestRestconfConnect:
mock_resp_fail.status_code = 502
mock_resp_fail.text = "Bad Gateway"
- with flask_app.app_context(), \
- patch("src.realizer.restconf.restconf_connect.tfs_connector") as mock_conn_cls:
+ with flask_app.app_context(), patch("src.realizer.restconf.restconf_connect.tfs_connector") as mock_conn_cls:
mock_conn = MagicMock()
mock_conn.nbi_post.return_value = mock_resp_fail
mock_conn_cls.return_value = mock_conn
@@ -601,7 +623,9 @@ class TestTfsConnector:
conn = tfs_connector()
mock_get_resp = MagicMock()
- mock_get_resp.iter_lines.return_value = [b'']
+ mock_get_resp.iter_lines.return_value = [
+ b''
+ ]
mock_post_resp = MagicMock()
mock_post_resp.text = "OK"
@@ -648,11 +672,7 @@ class TestTfsConnector:
conn = tfs_connector()
mock_resp = MagicMock()
mock_resp.json.return_value = [
- {
- "ietf-network:networks": {
- "network": [{"network-id": "urn:tfs:network:admin", "nodes": []}]
- }
- }
+ {"ietf-network:networks": {"network": [{"network-id": "urn:tfs:network:admin", "nodes": []}]}}
]
with patch("requests.get", return_value=mock_resp):
@@ -685,14 +705,16 @@ class TestTfsConnector:
{"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"}}}
+ {"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}"):
+ 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"]
@@ -728,3 +750,466 @@ class TestTfsConnector:
# Nested in subscription-result
nested = {"ietf-subscribed-notifications:subscription-result": {"stream": {"uri": "http://stream.com/nested"}}}
assert conn._extract_stream_uri(nested, "http://base") == "http://stream.com/nested"
+
+
+# =============================================================================
+# 9. Tests for E2E Orchestrator (src/realizer/e2e/)
+# =============================================================================
+
+
+class TestE2ERealizer:
+ """Unit tests for E2E main, connect, and service types."""
+
+ def test_e2e_dispatch(self):
+ from src.realizer.e2e.main import e2e
+
+ with (
+ patch("src.realizer.e2e.main.l3ipowdm_slice", return_value=[{"req": "l3owdm"}]),
+ patch("src.realizer.e2e.main.del_l3ipowdm_slice", return_value={"req": "del"}),
+ ):
+ assert e2e({}, way="L3oWDM", rules={"actions": []}) == [{"req": "l3owdm"}]
+ assert e2e({}, way="DEL_L3oWDM", response=[], rules={"actions": []}) == {"req": "del"}
+ assert e2e({}, way="UNSUPPORTED") is None
+
+ def test_e2e_connect(self, monkeypatch):
+ from src.realizer.e2e.e2e_connect import e2e_connect
+
+ mock_conn = MagicMock()
+ mock_conn.ipowdm_post.return_value = MagicMock(status_code=200)
+ mock_conn.ipowdm_put.return_value = MagicMock(status_code=200)
+ monkeypatch.setattr("src.realizer.e2e.e2e_connect.tfs_connector", lambda: mock_conn)
+ monkeypatch.setenv("E2E_OPTICAL_IP", "10.10.10.10")
+
+ payload_with_uuid = {
+ "services": [
+ {
+ "service_id": {"service_uuid": {"uuid": "slice-e2e-123"}},
+ }
+ ]
+ }
+ res = e2e_connect(payload_with_uuid, "10.0.0.1", is_update=False)
+ assert res.status_code == 200
+ mock_conn.ipowdm_post.assert_called_once_with("10.10.10.10", "slice-e2e-123", payload_with_uuid)
+
+ # Update mode
+ res_update = e2e_connect(payload_with_uuid, "10.0.0.1", is_update=True, old_service_id="old-123")
+ assert res_update.status_code == 200
+ mock_conn.ipowdm_put.assert_called_once()
+
+ def test_l3ipowdm_slice_builders(self):
+ from src.realizer.e2e.service_types.l3ipowdm_slice import l3ipowdm_slice
+
+ assert l3ipowdm_slice(None) == []
+ assert l3ipowdm_slice({}) == []
+
+ rules = {
+ "network-slice-uuid": "slice-uuid-001",
+ "actions": [
+ {
+ "type": "CREATE_OPTICAL_SLICE",
+ "content": {
+ "tenant-uuid": "tenant-001",
+ "service-interface-point": [{"uuid": "sip-1"}, {"uuid": "sip-2"}],
+ "node": [
+ {"node-id": "node-1", "owned-node-edge-point": [{"media-channel-node-edge-point-spec": {}}]}
+ ],
+ "link": [{"link-id": "link-1"}],
+ },
+ },
+ {
+ "type": "PROVISION_MEDIA_CHANNEL_OLS_PATH",
+ "tenant-uuid": "tenant-001",
+ "content": {
+ "src-sip-uuid": "sip-src",
+ "dest-sip-uuid": "dest-sip",
+ "direction": "UNIDIRECTIONAL",
+ "bandwidth-ghz": 50,
+ "ols-path-uuid": "ols-001",
+ "layer-protocol-name": "PHOTONIC_MEDIA",
+ "layer-protocol-qualifier": "TAPI_PHOTONIC_MEDIA",
+ "lower-frequency-mhz": 193100000,
+ "upper-frequency-mhz": 193150000,
+ "link-uuid-path": ["link-1"],
+ "adjustment-granularity": "G_50GHZ",
+ "grid-type": "FLEX",
+ },
+ },
+ {
+ "type": "XR_AGENT_ACTIVATE_TRANSCEIVER",
+ "content": {"components": [{"name": "transceiver-1"}]},
+ },
+ {
+ "type": "CONFIG_VPNL3",
+ "controller_uuid": "ctrl-001",
+ "content": {
+ "src-node-uuid": "src-node",
+ "src-ip-address": "10.0.0.1",
+ "src-ip-mask": 24,
+ "src-vlan-id": 100,
+ "dest1-node-uuid": "dest-node",
+ "dest1-ip-address": "10.0.0.2",
+ "dest1-ip-mask": 24,
+ "dest1-vlan-id": 100,
+ },
+ },
+ ],
+ }
+
+ with patch("src.realizer.e2e.service_types.l3ipowdm_slice.load_template") as mock_tmpl:
+ mock_tmpl.side_effect = lambda path: {
+ "tapi-common:context": {
+ "service-interface-point": [{}, {}],
+ "tapi-topology:topology-context": {"topology": [{"node": [], "link": []}]},
+ "name": [{"value": ""}],
+ },
+ "services": [
+ {
+ "service_id": {"service_uuid": {"uuid": ""}},
+ "service_config": {"config_rules": [{"tapi_lsp": {"rule_set": {}}}]},
+ }
+ ],
+ "endpoint_id": {"device_id": {"device_uuid": {"uuid": ""}}},
+ "rule_set": {},
+ }
+ results = l3ipowdm_slice(rules)
+ assert len(results) >= 3
+
+ def test_del_l3ipowdm_slice_webui_and_nbi(self, flask_app):
+ from src.realizer.e2e.service_types.del_l3ipowdm_slice import del_l3ipowdm_slice
+
+ ietf_intent = {
+ "ietf-network-slice-service:network-slice-services": {
+ "slice-service": [
+ {
+ "id": "slice-del-123",
+ "sdps": {
+ "sdp": [
+ {
+ "attachment-circuits": {
+ "attachment-circuit": [{"sdp-peering": {"peer-sap-id": "R1"}}]
+ },
+ "service-match-criteria": {"match-criterion": [{"match-type": [{"vlan": [100]}]}]},
+ "node-id": "node-1",
+ "sdp-ip-address": "192.168.1.1",
+ },
+ {
+ "attachment-circuits": {
+ "attachment-circuit": [{"sdp-peering": {"peer-sap-id": "R2"}}]
+ },
+ "service-match-criteria": {"match-criterion": [{"match-type": [{"vlan": [100]}]}]},
+ "node-id": "node-2",
+ "sdp-ip-address": "192.168.1.2",
+ },
+ ]
+ },
+ }
+ ]
+ }
+ }
+ with flask_app.app_context():
+ flask_app.config["UPLOAD_TYPE"] = "WEBUI"
+ with patch("src.realizer.e2e.service_types.del_l3ipowdm_slice.load_template") as mock_tmpl:
+ mock_tmpl.return_value = {
+ "services": [
+ {
+ "service_id": {"service_uuid": {"uuid": ""}},
+ "service_endpoint_ids": [
+ {"device_id": {"device_uuid": {}}, "endpoint_uuid": {}},
+ {"device_id": {"device_uuid": {}}, "endpoint_uuid": {}},
+ ],
+ "service_constraints": [],
+ "service_config": {
+ "config_rules": [
+ {},
+ {"custom": {"resource_value": {}, "resource_key": ""}},
+ {"custom": {"resource_value": {}, "resource_key": ""}},
+ ]
+ },
+ }
+ ]
+ }
+ res_webui = del_l3ipowdm_slice(ietf_intent, [{"id": "slice-del-123", "requirements": ["r1"]}])
+ assert res_webui["service_id"]["service_uuid"]["uuid"] == "slice-del-123"
+
+ flask_app.config["UPLOAD_TYPE"] = "NBI"
+ with patch("src.realizer.e2e.service_types.del_l3ipowdm_slice.load_template") as mock_tmpl:
+ mock_tmpl.return_value = {
+ "ietf-l2vpn-svc:vpn-service": [
+ {
+ "vpn-id": "",
+ "site": [
+ {"site-network-access": {"interface": {}}},
+ {"site-network-access": {"interface": {}}},
+ ],
+ }
+ ]
+ }
+ res_nbi = del_l3ipowdm_slice(ietf_intent, [])
+ assert res_nbi["ietf-l2vpn-svc:vpn-service"][0]["vpn-id"] == "slice-del-123"
+
+
+# =============================================================================
+# 10. Tests for IXIA Controller (src/realizer/ixia/)
+# =============================================================================
+
+
+class TestIxiaRealizer:
+ """Unit tests for IXIA main, connect, and helpers."""
+
+ def test_ixia_intent_parsing(self):
+ from src.realizer.ixia.main import ixia
+
+ intent = {
+ "ietf-network-slice-service:network-slice-services": {
+ "slo-sle-templates": {
+ "slo-sle-template": [
+ {
+ "description": "v1-low-latency",
+ "sle-policy": {"reliability": 99.9},
+ "slo-policy": {
+ "metric-bound": [
+ {"metric-type": "one-way-bandwidth", "bound": 1000},
+ {"metric-type": "one-way-delay-maximum", "bound": 10.5},
+ {"metric-type": "one-way-delay-variation-maximum", "bound": 2.0},
+ ]
+ },
+ }
+ ]
+ },
+ "slice-service": [
+ {
+ "sdps": {
+ "sdp": [
+ {
+ "attachment-circuits": {
+ "attachment-circuit": [{"sdp-peering": {"peer-sap-id": "192.168.1.10"}}]
+ },
+ "service-match-criteria": {"match-criterion": [{"match-type": [{"vlan": [200]}]}]},
+ },
+ {
+ "attachment-circuits": {
+ "attachment-circuit": [{"sdp-peering": {"peer-sap-id": "192.168.1.20"}}]
+ },
+ },
+ ]
+ }
+ }
+ ],
+ }
+ }
+ res = ixia(intent)
+ assert res["src_node_ip"] == "192.168.1.10"
+ assert res["dst_node_ip"] == "192.168.1.20"
+ assert res["vlan_id"] == 200
+ assert res["bandwidth"] == 1000
+ assert res["latency"] == 10.5
+ assert res["tolerance"] == 2.0
+ assert res["latency_version"] == "v1-low-latency"
+ assert res["reliability"] == 99.9
+
+ def test_ixia_connect(self):
+ from src.realizer.ixia.ixia_connect import ixia_connect
+
+ with patch("src.realizer.ixia.ixia_connect.NEII_controller") as mock_ctrl_cls:
+ mock_inst = MagicMock()
+ mock_inst.nscNEII.return_value = {"status": "ok"}
+ mock_ctrl_cls.return_value = mock_inst
+
+ res = ixia_connect({"services": [{"src_node_ip": "1.1.1.1"}]}, "10.0.0.5")
+ assert res == {"status": "ok"}
+ mock_inst.nscNEII.assert_called_once()
+
+ def test_neii_controller_helpers(self):
+ from src.realizer.ixia.helpers.NEII_V4 import NEII_controller
+
+ ctrl = NEII_controller("10.0.0.5")
+ assert ctrl.ixia_ip == "10.0.0.5"
+
+ with patch("src.realizer.ixia.helpers.NEII_V4.automatizacion") as mock_auto:
+ mock_auto.obtener_informacion_puerto.return_value = {"profiles": []}
+ mock_auto.envio_peticion.return_value = "OK"
+
+ json_data = {
+ "src_node_ip": "192.168.1.1",
+ "dst_node_ip": "192.168.1.2",
+ "vlan_id": 100,
+ "bandwidth": 5000,
+ "latency": 10,
+ "tolerance": 2,
+ "latency_version": "2",
+ "reliability": 99,
+ }
+ res = ctrl.nscNEII(json_data)
+ assert res is not None
+
+ def test_automatizacion_static_methods(self):
+ from src.realizer.ixia.helpers.automatizacion_ne2v4 import automatizacion
+
+ delay_none = automatizacion.añadir_configuracion_puerto_delay(10, "1")
+ assert delay_none["ethernetDelay"]["pdvMode"] == "NONE"
+
+ delay_gauss = automatizacion.añadir_configuracion_puerto_delay(10, "gauss", 3)
+ assert delay_gauss["ethernetDelay"]["pdvMode"] == "GAUSSIAN"
+
+ delay_internet = automatizacion.añadir_configuracion_puerto_delay(10, "internet", 3)
+ assert delay_internet["ethernetDelay"]["pdvMode"] == "INTERNET"
+
+ ipv4_cfg = automatizacion.añadir_configuracion_puerto_ipv4("c0a80101", "c0a80102", "4")
+ assert "profiles" in ipv4_cfg
+
+ vlan_cfg = automatizacion.añadir_configuracion_VLAN(100)
+ assert "profiles" in vlan_cfg
+
+ drop_cfg = automatizacion.añadir_configuración_packetDrop(5, 100)
+ assert drop_cfg["packetDrop"]["enabled"] is True
+
+ policer_cfg = automatizacion.añadir_configuracion_policer(1000)
+ assert policer_cfg["policer"]["excessBitRate"] == 1000
+
+ shaper_cfg = automatizacion.añadir_configuracion_shaper(2000)
+ assert shaper_cfg["shaper"]["bitRate"] == 2000
+
+
+# =============================================================================
+# 11. Tests for TFS Controller and Services (src/realizer/tfs/)
+# =============================================================================
+
+
+class TestTfsRealizer:
+ """Unit tests for TFS main, connect, and service types."""
+
+ def test_tfs_dispatch(self):
+ from src.realizer.tfs.main import tfs
+
+ with (
+ patch("src.realizer.tfs.main.tfs_l2vpn", return_value={"type": "l2"}),
+ patch("src.realizer.tfs.main.tfs_l3vpn", return_value={"type": "l3"}),
+ ):
+ assert tfs({}, way="L2") == {"type": "l2"}
+ assert tfs({}, way="L3") == {"type": "l3"}
+ assert tfs({}, way="UNKNOWN") == {"type": "l2"}
+
+ def test_tfs_connect(self, flask_app):
+ from src.realizer.tfs.tfs_connect import tfs_connect
+
+ with flask_app.app_context():
+ flask_app.config["UPLOAD_TYPE"] = "WEBUI"
+ flask_app.config["TFS_L2VPN_SUPPORT"] = False
+ with patch("src.realizer.tfs.tfs_connect.tfs_connector") as mock_conn_cls:
+ mock_inst = MagicMock()
+ mock_inst.webui_post.return_value = MagicMock(status_code=200)
+ mock_conn_cls.return_value = mock_inst
+
+ res = tfs_connect({"services": []}, "10.0.0.1")
+ assert res.status_code == 200
+
+ def test_tfs_l2vpn_and_l3vpn_generation(self, flask_app):
+ from src.realizer.tfs.service_types.tfs_l2vpn import tfs_l2vpn
+ from src.realizer.tfs.service_types.tfs_l3vpn import tfs_l3vpn
+
+ intent = {
+ "ietf-network-slice-service:network-slice-services": {
+ "slice-service": [
+ {
+ "id": "slice-tfs-100",
+ "sdps": {
+ "sdp": [
+ {
+ "attachment-circuits": {
+ "attachment-circuit": [{"sdp-peering": {"peer-sap-id": "R1"}}]
+ },
+ "service-match-criteria": {"match-criterion": [{"match-type": [{"vlan": [100]}]}]},
+ "node-id": "node-1",
+ },
+ {
+ "attachment-circuits": {
+ "attachment-circuit": [{"sdp-peering": {"peer-sap-id": "R2"}}]
+ },
+ "service-match-criteria": {"match-criterion": [{"match-type": [{"vlan": [100]}]}]},
+ "node-id": "node-2",
+ },
+ ]
+ },
+ }
+ ]
+ }
+ }
+ with flask_app.app_context():
+ flask_app.config["UPLOAD_TYPE"] = "NBI"
+ with patch("src.realizer.tfs.service_types.tfs_l2vpn.load_template") as mock_l2_tmpl:
+ mock_l2_tmpl.return_value = {
+ "ietf-l2vpn-svc:vpn-service": [
+ {
+ "vpn-id": "",
+ "site": [
+ {"site-network-access": {"interface": {}}},
+ {"site-network-access": {"interface": {}}},
+ ],
+ }
+ ]
+ }
+ res_l2 = tfs_l2vpn(intent, [])
+ assert res_l2 is not None
+
+ with patch("src.realizer.tfs.service_types.tfs_l3vpn.load_template") as mock_l3_tmpl:
+ mock_l3_tmpl.return_value = {
+ "ietf-l3vpn-svc:l3vpn-svc": {
+ "vpn-services": {"vpn-service": [{}]},
+ "sites": {
+ "site": [
+ {
+ "locations": {"location": [{}]},
+ "devices": {"device": [{}]},
+ "site-network-accesses": {
+ "site-network-access": [
+ {
+ "vpn-attachment": {},
+ "service": {
+ "qos": {
+ "qos-profile": {
+ "classes": {"class": [{"latency": {}, "bandwidth": {}}]}
+ }
+ }
+ },
+ }
+ ]
+ },
+ }
+ ]
+ },
+ }
+ }
+ res_l3 = tfs_l3vpn(
+ intent,
+ [
+ {
+ "id": "slice-tfs-100",
+ "requirements": [{"constraint_type": "availability[%]", "constraint_value": 99}],
+ }
+ ],
+ )
+ assert res_l3 is not None
+
+
+# =============================================================================
+# 12. Tests for Realizer Reconfiguration Action (src/realizer/main.py)
+# =============================================================================
+
+
+def test_realizer_reconfig_action(flask_app):
+ """Test realizer RECONFIG action successfully returns path and topology."""
+ with flask_app.app_context():
+ flask_app.config["RESTCONF_IP"] = "127.0.0.1"
+ with (
+ patch("src.realizer.main.get_data_by_slice_id", return_value=[{"service_id": "svc-reconfig-1"}]),
+ patch("src.realizer.main.tfs_connector") as mock_conn_cls,
+ ):
+ mock_inst = MagicMock()
+ mock_inst.get_service_path.return_value = (["node-a", "node-b"], 200)
+ mock_inst.get_network_topology.return_value = ({"network-id": "admin"}, 200)
+ mock_conn_cls.return_value = mock_inst
+
+ res = realizer({"slice_id": "slice-reconfig-1"}, action="RECONFIG")
+ assert res["slice_id"] == "slice-reconfig-1"
+ assert res["service_path"] == ["node-a", "node-b"]
+ assert res["network_topology"] == {"network-id": "admin"}
diff --git a/src/tests/test_utils.py b/src/tests/test_utils.py
index 0a975c159821de6095655ce9c0abbe9d03cc8be9..db9514e6b17b309bfea460e12a1d7366023cb841 100644
--- a/src/tests/test_utils.py
+++ b/src/tests/test_utils.py
@@ -15,12 +15,15 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
import json
+
import pytest
-from src.utils.load_template import load_template
+from flask import Flask
+
+from src.utils.build_response import build_response
from src.utils.dump_templates import dump_templates
+from src.utils.load_template import load_template
from src.utils.send_response import send_response
-from src.utils.build_response import build_response
-from flask import Flask
+
@pytest.fixture
def tmp_json_file(tmp_path):
@@ -48,6 +51,7 @@ def test_load_template_invalid(tmp_path):
assert result["success"] is False
assert "Template loading error" in result["error"]
+
def test_dump_templates_enabled(monkeypatch, tmp_path):
"""Should correctly dump multiple JSON files into src/templates when DUMP_TEMPLATES is enabled."""
templates_dir = tmp_path / "src" / "templates"
@@ -70,6 +74,7 @@ def test_dump_templates_enabled(monkeypatch, tmp_path):
assert file_path.exists()
assert json.loads(file_path.read_text()) == data
+
def test_dump_templates_disabled(monkeypatch, tmp_path):
"""Should not write anything into src/templates when DUMP_TEMPLATES is disabled."""
templates_dir = tmp_path / "src" / "templates"
@@ -86,6 +91,7 @@ def test_dump_templates_disabled(monkeypatch, tmp_path):
for name in ["nbi_template.json", "ietf_template.json", "realizer_template.json"]:
assert not (templates_dir / name).exists()
+
def test_send_response_success():
"""Should return success=True and code=200 if the result is True."""
resp, code = send_response(True, data={"k": "v"})
@@ -103,6 +109,7 @@ def test_send_response_error():
assert resp["data"] is None
assert "failure" in resp["error"]
+
def ietf_intent():
"""Valid simplified IETF intent."""
return {
@@ -113,15 +120,11 @@ def ietf_intent():
"id": "qos1",
"slo-policy": {
"metric-bound": [
- {
- "metric-type": "one-way-bandwidth",
- "metric-unit": "kbps",
- "bound": 1000
- }
+ {"metric-type": "one-way-bandwidth", "metric-unit": "kbps", "bound": 1000}
],
"availability": 99.9,
- "mtu": 1500
- }
+ "mtu": 1500,
+ },
}
]
},
@@ -134,28 +137,14 @@ def ietf_intent():
"id": "CU",
"sdp-ip-address": "10.0.0.1",
"service-match-criteria": {
- "match-criterion": [{
- "match-type": [
- {
- "type": "vlan",
- "vlan": [100]
- }
- ]
- }]
+ "match-criterion": [{"match-type": [{"type": "vlan", "vlan": [100]}]}]
},
},
{
"id": "DU",
"sdp-ip-address": "10.0.0.2",
"service-match-criteria": {
- "match-criterion": [{
- "match-type": [
- {
- "type": "vlan",
- "vlan": [100]
- }
- ]
- }]
+ "match-criterion": [{"match-type": [{"type": "vlan", "vlan": [100]}]}]
},
},
]
@@ -183,7 +172,9 @@ def test_build_response_ok():
# Validate constraints
requirements = slice_data["requirements"]
- assert any(r["constraint_type"] == "one-way-bandwidth[kbps]" and r["constraint_value"] == "1000" for r in requirements)
+ assert any(
+ r["constraint_type"] == "one-way-bandwidth[kbps]" and r["constraint_value"] == "1000" for r in requirements
+ )
assert any(r["constraint_type"] == "availability[%]" and r["constraint_value"] == "99.9" for r in requirements)
assert any(r["constraint_type"] == "mtu[bytes]" and r["constraint_value"] == "1500" for r in requirements)
@@ -191,7 +182,9 @@ def test_build_response_ok():
def test_build_response_empty_policy():
"""Should return a list without constraints if slo-policy is empty."""
intent = ietf_intent()
- intent["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["slo-policy"] = {}
+ intent["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0][
+ "slo-policy"
+ ] = {}
response = []
result = build_response(intent, response)
@@ -257,4 +250,4 @@ class TestSafeGet:
assert safe_get(data, ["a", 0, "b", "c"]) == 42
assert safe_get(data, ["a", 1, "b", "c"]) is None
assert safe_get(data, ["x", "y"]) is None
- assert safe_get(None, ["a"]) is None
\ No newline at end of file
+ assert safe_get(None, ["a"]) is None
diff --git a/src/tests/test_webui.py b/src/tests/test_webui.py
index 61a4b4b6f7a9f7edf525d080e3799f67557f86dc..bfdf4ef70642c8a692e9e819ac663a15cb91da62 100644
--- a/src/tests/test_webui.py
+++ b/src/tests/test_webui.py
@@ -1,6 +1,7 @@
import json
+from unittest.mock import MagicMock, patch
+
import pytest
-from unittest.mock import patch, MagicMock
from flask import Flask
@@ -15,6 +16,7 @@ def webui_app():
app.config["DUMMY_MODE"] = True
from src.webui.gui import gui_bp
+
app.register_blueprint(gui_bp)
return app
@@ -29,6 +31,7 @@ class TestWebUIHelpers:
def test_safe_int(self):
import src.webui.gui as gui_module
+
__safe_int = getattr(gui_module, "__safe_int")
assert __safe_int("10") == 10
@@ -41,6 +44,7 @@ class TestWebUIHelpers:
def test_build_request_ietf(self):
import src.webui.gui as gui_module
+
__build_request_ietf = getattr(gui_module, "__build_request_ietf")
res = __build_request_ietf(
@@ -51,7 +55,7 @@ class TestWebUIHelpers:
latency="10",
tolerance="2",
latency_version="gaussian",
- reliability="99"
+ reliability="99",
)
assert isinstance(res, dict)
slice_svc = res["ietf-network-slice-service:network-slice-services"]["slice-service"][0]
@@ -60,14 +64,11 @@ class TestWebUIHelpers:
def test_build_request(self):
import src.webui.gui as gui_module
+
__build_request = getattr(gui_module, "__build_request")
res = __build_request(
- ip_version="IPv4",
- src_node_ip="10.0.0.1",
- dst_node_ip="10.0.0.2",
- vlan_id="100",
- bandwidth="500"
+ ip_version="IPv4", src_node_ip="10.0.0.1", dst_node_ip="10.0.0.2", vlan_id="100", bandwidth="500"
)
assert res["ip_version"] == "IPv4"
assert res["src_node_ip"] == "10.0.0.1"
@@ -75,6 +76,7 @@ class TestWebUIHelpers:
def test_datos_json_file_not_found(self):
import src.webui.gui as gui_module
+
__datos_json = getattr(gui_module, "__datos_json")
with patch("builtins.open", side_effect=FileNotFoundError):
@@ -100,17 +102,11 @@ class TestWebUIRoutes:
resp_get = webui_client.get("/webui/login")
assert resp_get.status_code == 200
- resp_post = webui_client.post("/webui/login", data={
- "username": "admin",
- "password": "admin"
- })
+ resp_post = webui_client.post("/webui/login", data={"username": "admin", "password": "admin"})
assert resp_post.status_code in [200, 302]
def test_login_post_invalid(self, webui_client):
- resp_post = webui_client.post("/webui/login", data={
- "username": "wrong",
- "password": "bad"
- })
+ resp_post = webui_client.post("/webui/login", data={"username": "wrong", "password": "bad"})
assert resp_post.status_code == 200
assert b"Credenciales incorrectas" in resp_post.data
@@ -128,24 +124,25 @@ class TestWebUIRoutes:
assert resp_get.status_code == 200
# POST form submit in DUMMY_MODE
- resp_post = client.post("/webui/dev", data={
- "ip_version": "IPv4",
- "src_node_ipv4": "10.0.0.1",
- "dst_node_ipv4": "10.0.0.2",
- "vlan_id": "100",
- "bandwidth_intent": "1000",
- "latency_intent": "10"
- })
+ resp_post = client.post(
+ "/webui/dev",
+ data={
+ "ip_version": "IPv4",
+ "src_node_ipv4": "10.0.0.1",
+ "dst_node_ipv4": "10.0.0.2",
+ "vlan_id": "100",
+ "bandwidth_intent": "1000",
+ "latency_intent": "10",
+ },
+ )
assert resp_post.status_code == 200
# POST form submit with DUMMY_MODE = False
webui_app.config["DUMMY_MODE"] = False
with patch("src.webui.gui.NEII_controller") as mock_neii:
- resp_post_neii = client.post("/webui/dev", data={
- "ip_version": "IPv4",
- "src_node_ipv4": "10.0.0.1",
- "dst_node_ipv4": "10.0.0.2"
- })
+ resp_post_neii = client.post(
+ "/webui/dev", data={"ip_version": "IPv4", "src_node_ipv4": "10.0.0.1", "dst_node_ipv4": "10.0.0.2"}
+ )
assert resp_post_neii.status_code == 200
assert mock_neii.return_value.nscNEII.called
@@ -158,13 +155,16 @@ class TestWebUIRoutes:
mock_resp.raise_for_status.return_value = None
with patch("requests.post", return_value=mock_resp):
- resp_post = webui_client.post("/webui/generate/tfs", data={
- "src_node_ip": "10.0.0.1",
- "dst_node_ip": "10.0.0.2",
- "vlan_id": "100",
- "latency_intent": "10",
- "bandwidth_intent": "1000"
- })
+ resp_post = webui_client.post(
+ "/webui/generate/tfs",
+ data={
+ "src_node_ip": "10.0.0.1",
+ "dst_node_ip": "10.0.0.2",
+ "vlan_id": "100",
+ "latency_intent": "10",
+ "bandwidth_intent": "1000",
+ },
+ )
assert resp_post.status_code in [200, 302]
def test_generate_ixia_get_and_post(self, webui_client):
@@ -176,69 +176,86 @@ class TestWebUIRoutes:
mock_resp.raise_for_status.return_value = None
with patch("requests.post", return_value=mock_resp):
- resp_post = webui_client.post("/webui/generate/ixia", data={
- "src_node_ip": "10.0.0.1",
- "dst_node_ip": "10.0.0.2",
- "vlan_id": "100",
- "latency_intent": "10",
- "bandwidth_intent": "1000",
- "tolerance_intent": "2",
- "reliability": "99"
- })
+ resp_post = webui_client.post(
+ "/webui/generate/ixia",
+ data={
+ "src_node_ip": "10.0.0.1",
+ "dst_node_ip": "10.0.0.2",
+ "vlan_id": "100",
+ "latency_intent": "10",
+ "bandwidth_intent": "1000",
+ "tolerance_intent": "2",
+ "reliability": "99",
+ },
+ )
assert resp_post.status_code in [200, 302]
def test_generate_ixia_error(self, webui_client):
import requests
+
with patch("requests.post", side_effect=requests.RequestException("Conn error")):
- resp_post = webui_client.post("/webui/generate/ixia", data={
- "src_node_ip": "10.0.0.1",
- "dst_node_ip": "10.0.0.2",
- "vlan_id": "100",
- "latency_intent": "10",
- "bandwidth_intent": "1000",
- "tolerance_intent": "2",
- "reliability": "99"
- })
+ resp_post = webui_client.post(
+ "/webui/generate/ixia",
+ data={
+ "src_node_ip": "10.0.0.1",
+ "dst_node_ip": "10.0.0.2",
+ "vlan_id": "100",
+ "latency_intent": "10",
+ "bandwidth_intent": "1000",
+ "tolerance_intent": "2",
+ "reliability": "99",
+ },
+ )
assert resp_post.status_code == 200
assert b"Intent Generation Error" in resp_post.data
def test_search_get_and_post_filters(self, webui_client):
- sample_slice = [{
- "controller": "TFS",
- "intent": {
- "ietf-network-slice-service:network-slice-services": {
- "slice-service": [{
- "sdps": {
- "sdp": [
- {
- "sdp-ip-address": "10.0.0.1",
- "service-match-criteria": {
- "match-criterion": [{"match-type": [{"vlan": [100]}]}]
- }
- },
+ sample_slice = [
+ {
+ "controller": "TFS",
+ "intent": {
+ "ietf-network-slice-service:network-slice-services": {
+ "slice-service": [
+ {
+ "sdps": {
+ "sdp": [
+ {
+ "sdp-ip-address": "10.0.0.1",
+ "service-match-criteria": {
+ "match-criterion": [{"match-type": [{"vlan": [100]}]}]
+ },
+ },
+ {
+ "sdp-ip-address": "10.0.0.2",
+ "service-match-criteria": {
+ "match-criterion": [{"match-type": [{"vlan": [100]}]}]
+ },
+ },
+ ]
+ }
+ }
+ ],
+ "slo-sle-templates": {
+ "slo-sle-template": [
{
- "sdp-ip-address": "10.0.0.2",
- "service-match-criteria": {
- "match-criterion": [{"match-type": [{"vlan": [100]}]}]
+ "slo-policy": {
+ "metric-bound": [
+ {"metric-type": "one-way-bandwidth", "bound": 1000, "metric-unit": "Mbps"},
+ {"metric-type": "one-way-delay-maximum", "bound": 10, "metric-unit": "ms"},
+ {
+ "metric-type": "one-way-delay-variation-maximum",
+ "bound": 2,
+ "metric-unit": "ms",
+ },
+ ]
}
}
]
- }
- }],
- "slo-sle-templates": {
- "slo-sle-template": [{
- "slo-policy": {
- "metric-bound": [
- {"metric-type": "one-way-bandwidth", "bound": 1000, "metric-unit": "Mbps"},
- {"metric-type": "one-way-delay-maximum", "bound": 10, "metric-unit": "ms"},
- {"metric-type": "one-way-delay-variation-maximum", "bound": 2, "metric-unit": "ms"}
- ]
- }
- }]
+ },
}
- }
+ },
}
- }]
+ ]
mock_get_resp = MagicMock()
mock_get_resp.json.return_value = sample_slice
@@ -249,17 +266,20 @@ class TestWebUIRoutes:
assert resp_get.status_code == 200
# Test POST search filters
- for opt, val in [("Source IP", "10.0.0.1"), ("Destiny IP", "10.0.0.2"), ("Controller", "TFS"), ("VLAN", 100)]:
- resp_post = webui_client.post("/webui/search", data={
- "search_option": opt,
- "search_value": str(val)
- })
+ for opt, val in [
+ ("Source IP", "10.0.0.1"),
+ ("Destiny IP", "10.0.0.2"),
+ ("Controller", "TFS"),
+ ("VLAN", 100),
+ ]:
+ resp_post = webui_client.post("/webui/search", data={"search_option": opt, "search_value": str(val)})
assert resp_post.status_code == 200
data = json.loads(resp_post.data)
assert "result" in data
def test_search_request_exception(self, webui_client):
import requests
+
with patch("requests.get", side_effect=requests.RequestException("API error")):
resp = webui_client.get("/webui/search")
assert resp.status_code == 200
@@ -271,10 +291,6 @@ class TestWebUIRoutes:
assert "result" in data
def test_update_ips_route(self, webui_client, tmp_path):
- with patch("os.path.exists", return_value=False), \
- patch("src.webui.gui.SRC_PATH", str(tmp_path)):
- resp = webui_client.post("/webui/update_ips", json={
- "tfs_ip": "10.10.10.10",
- "ixia_ip": "10.10.10.20"
- })
+ with patch("os.path.exists", return_value=False), patch("src.webui.gui.SRC_PATH", str(tmp_path)):
+ resp = webui_client.post("/webui/update_ips", json={"tfs_ip": "10.10.10.10", "ixia_ip": "10.10.10.20"})
assert resp.status_code == 200
diff --git a/src/utils/build_response.py b/src/utils/build_response.py
index ccaee3ccfb2524db87200695cc5691339f665984..21e20d578cd01cda1eba2fc7578f022020bc434e 100644
--- a/src/utils/build_response.py
+++ b/src/utils/build_response.py
@@ -1,122 +1,155 @@
-# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
-
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-
-# http://www.apache.org/licenses/LICENSE-2.0
-
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-# This file is an original contribution from Telefonica Innovación Digital S.L.
-
-from .safe_get import safe_get
-
-def build_response(intent, response, controller_type = None):
- """
- Build a structured response from network slice intent.
-
- Extracts key information from an IETF network slice intent and formats it
- into a standardized response structure with slice details and QoS requirements.
-
- Args:
- intent (dict): IETF network slice service intent containing:
- - slice-service: Service configuration with SDPs and IDs
- - slo-sle-templates: QoS policy templates
- response (list): Existing response list to append to
- controller_type (str, optional): Type of controller managing the slice.
- Defaults to None
-
- Returns:
- list: Updated response list with appended slice information containing:
- - id: Slice service identifier
- - source: Source service delivery point ID
- - destination: Destination service delivery point ID
- - vlan: VLAN identifier from match criteria
- - requirements: List of QoS constraint dictionaries with:
- * constraint_type: Metric type and unit (e.g., "latency[ms]")
- * constraint_value: Bound value as string
-
- Notes:
- - Extracts metric bounds from SLO policy (bandwidth, delay, jitter, etc.)
- - Includes availability and MTU if specified in SLO policy
- - Assumes point-to-point topology with exactly 2 SDPs
- - VLAN extracted from first SDP's first match criterion
- """
-
- id = safe_get(intent, ["ietf-network-slice-service:network-slice-services","slice-service",0,"id"])
-
- # 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 cc.get("p2mp-sdp"):
- p2mp_sender = cc.get("p2mp-sdp", {}).get("root-sdp-id")
- p2mp_receivers = cc.get("p2mp-sdp", {}).get("leaf-sdp-id", [])
-
- 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:
- return response
-
- qos_requirements = []
-
- # Populate response with QoS requirements and VLAN from intent
- slo_policy = safe_get(intent, ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "service-slo-sle-policy", "slo-policy"])
- if slo_policy is None:
- slo_policy = safe_get(intent, ["ietf-network-slice-service:network-slice-services", "slo-sle-templates", "slo-sle-template", 0, "slo-policy"])
- if slo_policy is None:
- slo_policy = {}
-
- # Process metrics
- for metric in slo_policy.get("metric-bound", []):
- constraint_type = f"{metric['metric-type']}[{metric['metric-unit']}]"
- constraint_value = str(metric["bound"])
- qos_requirements.append({
- "constraint_type": constraint_type,
- "constraint_value": constraint_value
- })
-
- # Availability
- if "availability" in slo_policy:
- qos_requirements.append({
- "constraint_type": "availability[%]",
- "constraint_value": str(slo_policy["availability"])
- })
-
- # MTU
- if "mtu" in slo_policy:
- qos_requirements.append({
- "constraint_type": "mtu[bytes]",
- "constraint_value": str(slo_policy["mtu"])
- })
- response.append({
- "id": id,
- "source": source,
- "destination": destination,
- "vlan": vlan,
- "requirements": qos_requirements,
- })
- return response
\ No newline at end of file
+# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
+
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+
+# http://www.apache.org/licenses/LICENSE-2.0
+
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# This file is an original contribution from Telefonica Innovación Digital S.L.
+
+from typing import Any
+
+from .safe_get import safe_get
+
+
+def _extract_endpoints(intent: dict[str, Any]) -> tuple[str | None, str | None]:
+ """Extract source and destination endpoints from intent connection-groups or SDPs."""
+ slice_service = (
+ safe_get(intent, ["ietf-network-slice-service:network-slice-services", "slice-service", 0])
+ or {}
+ )
+
+ # Check connection constructs (P2MP sender/receiver)
+ 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:
+ p2mp_sender = cc.get("p2mp-sender-sdp") or safe_get(cc, ["p2mp-sdp", "root-sdp-id"])
+ p2mp_receivers = cc.get("p2mp-receiver-sdp") or safe_get(cc, ["p2mp-sdp", "leaf-sdp-id"], [])
+
+ if p2mp_sender and p2mp_receivers:
+ destination = p2mp_receivers[0] if isinstance(p2mp_receivers, list) and p2mp_receivers else None
+ return p2mp_sender, destination
+
+ # Fallback to standard SDP list
+ source = safe_get(slice_service, ["sdps", "sdp", 0, "id"]) or safe_get(slice_service, ["sdps", "sdp", 0, "node-id"])
+ destination = safe_get(slice_service, ["sdps", "sdp", 1, "id"]) or safe_get(slice_service, ["sdps", "sdp", 1, "node-id"])
+ return source, destination
+
+
+def _extract_vlan(intent: dict[str, Any]) -> int | None:
+ """Extract VLAN ID from the first match criterion of the first SDP."""
+ return 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,
+ ],
+ )
+
+
+def _extract_qos_requirements(intent: dict[str, Any]) -> list[dict[str, str]]:
+ """Extract QoS metric constraints, availability, and MTU from SLO policy."""
+ slo_policy = safe_get(
+ intent,
+ [
+ "ietf-network-slice-service:network-slice-services",
+ "slice-service",
+ 0,
+ "service-slo-sle-policy",
+ "slo-policy",
+ ],
+ )
+ if slo_policy is None:
+ slo_policy = safe_get(
+ intent,
+ [
+ "ietf-network-slice-service:network-slice-services",
+ "slo-sle-templates",
+ "slo-sle-template",
+ 0,
+ "slo-policy",
+ ],
+ )
+ if not isinstance(slo_policy, dict):
+ return []
+
+ requirements: list[dict[str, str]] = []
+
+ for metric in slo_policy.get("metric-bound", []):
+ constraint_type = f"{metric.get('metric-type', '')}[{metric.get('metric-unit', '')}]"
+ constraint_value = str(metric.get("bound", ""))
+ requirements.append({
+ "constraint_type": constraint_type,
+ "constraint_value": constraint_value,
+ })
+
+ if "availability" in slo_policy:
+ requirements.append({
+ "constraint_type": "availability[%]",
+ "constraint_value": str(slo_policy["availability"]),
+ })
+
+ if "mtu" in slo_policy:
+ requirements.append({
+ "constraint_type": "mtu[bytes]",
+ "constraint_value": str(slo_policy["mtu"]),
+ })
+
+ return requirements
+
+
+def build_response(
+ intent: dict[str, Any],
+ response: list[dict[str, Any]],
+ controller_type: str | None = None,
+) -> list[dict[str, Any]]:
+ """
+ Build a structured response entry from network slice intent and append it to `response`.
+
+ Args:
+ intent (dict[str, Any]): IETF network slice intent.
+ response (list[dict[str, Any]]): Existing list of response objects.
+ controller_type (str, optional): Controller type managing the slice.
+
+ Returns:
+ list[dict[str, Any]]: Updated response list.
+ """
+ slice_id = safe_get(
+ intent,
+ ["ietf-network-slice-service:network-slice-services", "slice-service", 0, "id"],
+ )
+ source, destination = _extract_endpoints(intent)
+
+ if not slice_id or not source or not destination:
+ return response
+
+ vlan = _extract_vlan(intent)
+ qos_requirements = _extract_qos_requirements(intent)
+
+ response.append({
+ "id": slice_id,
+ "source": source,
+ "destination": destination,
+ "vlan": vlan,
+ "requirements": qos_requirements,
+ })
+ return response
\ No newline at end of file
diff --git a/src/utils/dump_templates.py b/src/utils/dump_templates.py
index 2c516fdea7a1148856ce1593af4e15a9c8688631..0acfec20ef29f3a9b8afa41a9e9356025c0b861e 100644
--- a/src/utils/dump_templates.py
+++ b/src/utils/dump_templates.py
@@ -1,64 +1,50 @@
-# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
-
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-
-# http://www.apache.org/licenses/LICENSE-2.0
-
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-# This file is an original contribution from Telefonica Innovación Digital S.L.
-
-import json, os
-from src.config.constants import TEMPLATES_PATH
-from flask import current_app
-
-def dump_templates(nbi_file, ietf_file, realizer_file):
- """
- Dump multiple template files as JSON for debugging and analysis.
-
- This utility function saves network slice templates at different processing
- stages to disk for inspection, debugging, and documentation purposes.
- Only executes if DUMP_TEMPLATES configuration flag is enabled.
-
- Args:
- nbi_file (dict): Northbound Interface template - original user/API request
- ietf_file (dict): IETF-standardized network slice intent format
- realizer_file (dict): Controller-specific realization template
-
- Returns:
- None
-
- Notes:
- - Controlled by DUMP_TEMPLATES configuration flag
- - Files saved to TEMPLATES_PATH directory
- - Output files:
- * nbi_template.json - Original NBI request
- * ietf_template.json - Standardized IETF format
- * realizer_template.json - Controller-specific format
- - JSON formatted with 2-space indentation for readability
- - Silently returns if DUMP_TEMPLATES is False
-
- Raises:
- IOError: If unable to write to TEMPLATES_PATH directory
- """
- if not current_app.config["DUMP_TEMPLATES"]:
- return
-
- # Map template content to output filenames
- templates = {
- "nbi_template.json": nbi_file,
- "ietf_template.json": ietf_file,
- "realizer_template.json": realizer_file,
- }
-
- # Write each template to disk
- for filename, content in templates.items():
- path = os.path.join(TEMPLATES_PATH, filename)
- with open(path, "w") as f:
- json.dump(content, f, indent=2)
\ No newline at end of file
+# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
+
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+
+# http://www.apache.org/licenses/LICENSE-2.0
+
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# This file is an original contribution from Telefonica Innovación Digital S.L.
+
+import json
+from pathlib import Path
+from typing import Any
+
+from flask import current_app
+
+from src.config.constants import TEMPLATES_PATH
+
+
+def dump_templates(nbi_file: Any, ietf_file: Any, realizer_file: Any) -> None:
+ """
+ Dump multiple template files as JSON for debugging and analysis.
+
+ Args:
+ nbi_file (Any): Northbound Interface template.
+ ietf_file (Any): Standardized IETF format.
+ realizer_file (Any): Controller-specific format.
+ """
+ if not current_app.config.get("DUMP_TEMPLATES", False):
+ return
+
+ templates: dict[str, Any] = {
+ "nbi_template.json": nbi_file,
+ "ietf_template.json": ietf_file,
+ "realizer_template.json": realizer_file,
+ }
+
+ templates_dir = Path(TEMPLATES_PATH)
+ templates_dir.mkdir(parents=True, exist_ok=True)
+
+ for filename, content in templates.items():
+ file_path = templates_dir / filename
+ with file_path.open("w", encoding="utf-8") as f:
+ json.dump(content, f, indent=2)
\ No newline at end of file
diff --git a/src/utils/load_template.py b/src/utils/load_template.py
index 71c16b83d5081184ba2fc40f5d28cfc477a11dbf..a8c25828ea13b04354f2e15885a6e686364275a5 100644
--- a/src/utils/load_template.py
+++ b/src/utils/load_template.py
@@ -1,42 +1,50 @@
-# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
-
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-
-# http://www.apache.org/licenses/LICENSE-2.0
-
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-# This file is an original contribution from Telefonica Innovación Digital S.L.
-
-import logging, json
-from .send_response import send_response
-
-def load_template(dir_t):
- """
- Load and process JSON templates for different network slice formats.
-
- Args:
- dir_t (str): Path to the template file
-
- Returns:
- dict: Parsed JSON template
- """
- try:
- with open(dir_t, "r") as source:
- template = json.loads(
- source.read()
- .replace("\t", "")
- .replace("\n", "")
- .replace("'", '"')
- .strip()
- )
- return template
- except Exception as e:
- logging.error(f"Template loading error: {e}")
- return send_response(False, code=500, message=f"Template loading error: {e}")
\ No newline at end of file
+# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
+
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+
+# http://www.apache.org/licenses/LICENSE-2.0
+
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# This file is an original contribution from Telefonica Innovación Digital S.L.
+
+import json
+import logging
+from pathlib import Path
+from typing import Any
+
+from .send_response import send_response
+
+logger = logging.getLogger(__name__)
+
+
+def load_template(template_path: str | Path) -> dict[str, Any] | tuple[dict[str, Any], int]:
+ """
+ Load and parse a JSON template file for network slice configurations.
+
+ Args:
+ template_path (str | Path): Filepath to the JSON template.
+
+ Returns:
+ dict[str, Any] | tuple[dict[str, Any], int]: Parsed JSON dictionary or error response tuple.
+ """
+ try:
+ path = Path(template_path)
+ with path.open("r", encoding="utf-8") as source:
+ raw_content = (
+ source.read()
+ .replace("\t", "")
+ .replace("\n", "")
+ .replace("'", '"')
+ .strip()
+ )
+ return json.loads(raw_content)
+ except (OSError, json.JSONDecodeError) as e:
+ logger.error("Template loading error: %s", e)
+ return send_response(False, code=500, message=f"Template loading error: {e}")
\ No newline at end of file
diff --git a/src/utils/safe_get.py b/src/utils/safe_get.py
index 7fad43342abbfb2452991533a8c6fb2d7c1ac610..92775a171511211ccfd73c102a48b476abfeff6a 100644
--- a/src/utils/safe_get.py
+++ b/src/utils/safe_get.py
@@ -14,20 +14,26 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
-def safe_get(dct, keys):
+from collections.abc import Sequence
+from typing import Any
+
+
+def safe_get(target: Any, keys: Sequence[Any], default: Any = None) -> Any:
"""
- Safely retrieves a nested value from a dictionary or list.
+ Safely retrieve a nested value from a dictionary or list.
+
Args:
- dct (dict or list): The dictionary or list to traverse.
- keys (list): A list of keys (for dicts) or indices (for lists) to follow.
+ target (Any): The dictionary or list to traverse.
+ keys (Sequence[Any]): Keys (for dicts) or indices (for lists) to traverse in order.
+ default (Any, optional): Fallback value if the path cannot be resolved. Defaults to None.
+
Returns:
- The value found at the nested location, or None if any key/index is not found.
+ Any: The value found at the nested location, or `default` if any key/index is missing.
"""
+ current = target
for key in keys:
- if isinstance(dct, dict) and key in dct:
- dct = dct[key]
- elif isinstance(dct, list) and isinstance(key, int) and key < len(dct):
- dct = dct[key]
+ if isinstance(current, dict) and key in current or isinstance(current, list) and isinstance(key, int) and 0 <= key < len(current):
+ current = current[key]
else:
- return None
- return dct
+ return default
+ return current
diff --git a/src/utils/send_response.py b/src/utils/send_response.py
index 35b40163d4fa5bc92e1207ec7f8efc6dcd344019..827aa8b1e03d362b6b59cb419215c14a3b5fd12e 100644
--- a/src/utils/send_response.py
+++ b/src/utils/send_response.py
@@ -1,54 +1,66 @@
-# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
-
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-
-# http://www.apache.org/licenses/LICENSE-2.0
-
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-# This file is an original contribution from Telefonica Innovación Digital S.L.
-
-import logging, inspect
-
-def send_response(result, message=None, code=None, data=None):
- """
- Generate and send a standardized API response.
-
- Args:
- result (bool): Indicates success or failure. Defaults to None.
- message (str, optional): Message (success or error). Defaults to None.
- code (int, optional): HTTP code (default 200 for success, 400 for error). Defaults to None.
- data (dict, optional): Additional payload. Defaults to None.
-
- Returns:
- tuple: (response_dict, http_status_code)
- """
-
- frame = inspect.currentframe().f_back
- filename = frame.f_code.co_filename
- lineno = frame.f_lineno
-
- if result:
- code = code or 200
- response = {
- "success": True,
- "data": data or {},
- "error": None,
- }
- else:
- code = code or 400
- error_info = f"{message or 'An error occurred while processing the request.'} (File: {filename}, Line: {lineno})"
- logging.warning(f"Request failed. Reason: {message}")
- response = {
- "success": False,
- "data": None,
- "error": error_info,
- }
-
- return response, code
\ No newline at end of file
+# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
+
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+
+# http://www.apache.org/licenses/LICENSE-2.0
+
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# This file is an original contribution from Telefonica Innovación Digital S.L.
+
+import inspect
+import logging
+from typing import Any
+
+logger = logging.getLogger(__name__)
+
+
+def send_response(
+ result: bool,
+ message: str | None = None,
+ code: int | None = None,
+ data: Any = None,
+) -> tuple[dict[str, Any], int]:
+ """
+ Generate a standardized API response tuple (response_dict, http_status_code).
+
+ Args:
+ result (bool): Indicates success (True) or failure (False).
+ message (str, optional): Descriptive message or error reason.
+ code (int, optional): HTTP status code (defaults to 200 on success, 400 on error).
+ data (Any, optional): Data payload to include in response.
+
+ Returns:
+ tuple[dict[str, Any], int]: (response_payload, http_status_code)
+ """
+ frame = inspect.currentframe()
+ caller_frame = frame.f_back if frame else None
+ filename = caller_frame.f_code.co_filename if caller_frame else "unknown"
+ lineno = caller_frame.f_lineno if caller_frame else 0
+
+ if result:
+ status_code = code or 200
+ response = {
+ "success": True,
+ "data": data if data is not None else {},
+ "error": None,
+ }
+ return response, status_code
+
+ status_code = code or 400
+ error_message = message or "An error occurred while processing the request."
+ logger.warning("Request failed. Reason: %s (File: %s, Line: %s)", error_message, filename, lineno)
+
+ response = {
+ "success": False,
+ "data": None,
+ "error": error_message,
+ }
+ return response, status_code
+
\ No newline at end of file
diff --git a/src/utils/slice_manager.py b/src/utils/slice_manager.py
index 9581817ded7397ebdc41d33c2cc6d510ab5aa4ad..5fa25c70bb8932e3e3bf15739215a772c36770ea 100644
--- a/src/utils/slice_manager.py
+++ b/src/utils/slice_manager.py
@@ -1,65 +1,70 @@
-# Copyright 2022-2026 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
+# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
+
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+
+# http://www.apache.org/licenses/LICENSE-2.0
+
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# This file is an original contribution from Telefonica Innovación Digital S.L.
+
+from typing import Final
+
+
+class SliceManager:
+ """
+ Manages available Policy-Based Routing (PBR) slices and their DSCP assignments.
+ Slices are 1-indexed. The slice with the highest index is always the default.
+ """
+
+ TOTAL_SLICES: Final[int] = 3
+ DEFAULT_SLICE: Final[int] = TOTAL_SLICES # SLICE-3 is always default
+
+ def __init__(self) -> None:
+ # 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:
+ """Check if all available assignable slots are occupied."""
+ return all(dscp is not None for dscp in self._slots.values())
\ No newline at end of file
diff --git a/src/webui/gui.py b/src/webui/gui.py
index 933b8cfb7a710ecbca58cf1100157f03a380858d..d16a22cd9b26902bc93b6d66a669c3f90e5fbfa3 100644
--- a/src/webui/gui.py
+++ b/src/webui/gui.py
@@ -14,107 +14,132 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
-import json, logging, uuid
-import requests
+import json
+import logging
import os
+import uuid
+from pathlib import Path
+from typing import Any
+
import pandas as pd
-from flask import render_template, request, jsonify, redirect, url_for, session, Blueprint
-from src.config.constants import SRC_PATH, NSC_PORT, TEMPLATES_PATH
+import requests
+from flask import (
+ Blueprint,
+ current_app,
+ jsonify,
+ redirect,
+ render_template,
+ request,
+ session,
+ url_for,
+)
+
+from src.config.constants import NSC_PORT, SRC_PATH, TEMPLATES_PATH
from src.realizer.ixia.helpers.NEII_V4 import NEII_controller
-from flask import current_app
-gui_bp = Blueprint('gui', __name__, template_folder=os.path.join(SRC_PATH, 'webui', 'templates'), static_folder=os.path.join(SRC_PATH, 'webui', 'static'), static_url_path='/webui/static')
+gui_bp = Blueprint(
+ "gui",
+ __name__,
+ template_folder=str(Path(SRC_PATH) / "webui" / "templates"),
+ static_folder=str(Path(SRC_PATH) / "webui" / "static"),
+ static_url_path="/webui/static",
+)
+
+USERNAME = "admin"
+PASSWORD = "admin"
-#Variables for dev accessing
-USERNAME = 'admin'
-PASSWORD = 'admin'
-enter=False
-def __safe_int(value):
+def __safe_int(value: Any) -> int | float | None:
"""
Safely convert a string or numeric input to int or float.
-
+
Args:
- value (str|int|float): The input value to convert.
-
+ value (Any): The input value to convert.
+
Returns:
- int|float|None: The converted integer or float value, or None if conversion fails.
+ int | float | None: Converted numeric value or None on failure.
"""
try:
if isinstance(value, str):
- value = value.strip().replace(',', '.')
+ value = value.strip().replace(",", ".")
number = float(value)
return int(number) if number.is_integer() else number
except (ValueError, TypeError, AttributeError):
return None
-def __build_request_ietf(src_node_ip=None, dst_node_ip=None, vlan_id=None, bandwidth=None, latency=None, tolerance=0, latency_version=None, reliability=None):
- """
- Build an IETF-compliant network slice request formm from inputs.
-
- Args: IPs, VLAN, bandwidth, latency, reliability, etc.
-
- Returns: dict representing the JSON request.
- """
- # Open and read the template file
- with open(os.path.join(TEMPLATES_PATH, 'ietf_template_empty.json'), 'r') as source:
- # Clean up the JSON template
- template = source.read().replace('\t', '').replace('\n', '').replace("'", '"').strip()
- request = json.loads(template)
- request["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["id"] = f"qos-profile-{uuid.uuid4()}"
+def __build_request_ietf(
+ src_node_ip: str | None = None,
+ dst_node_ip: str | None = None,
+ vlan_id: Any = None,
+ bandwidth: Any = None,
+ latency: Any = None,
+ tolerance: Any = 0,
+ latency_version: str | None = None,
+ reliability: Any = None,
+) -> dict[str, Any]:
+ """Build an IETF-compliant network slice request dictionary from user inputs."""
+ template_file = Path(TEMPLATES_PATH) / "ietf_template_empty.json"
+ with template_file.open("r", encoding="utf-8") as source:
+ template = source.read().replace("\t", "").replace("\n", "").replace("'", '"').strip()
+ request_data: dict[str, Any] = json.loads(template)
+
+ slo_template = request_data["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]
+ slo_template["id"] = f"qos-profile-{uuid.uuid4()}"
+
+ slice_service = request_data["ietf-network-slice-service:network-slice-services"]["slice-service"][0]
+ slice_service["id"] = f"slice-service-{uuid.uuid4()}"
+ slice_service["slo-sle-template"] = slo_template["id"]
- # Generate unique slice service ID and description
- request["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["id"] = f"slice-service-{uuid.uuid4()}"
- request["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["slo-sle-template"] = request["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["id"]
-
# Configure Source SDP
- request["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][0]["node-id"] = "source-node" #Pendiente de rellenar
- request["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][0]["sdp-ip-address"] = src_node_ip
- request["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][0]["service-match-criteria"]["match-criterion"][0]["match-type"][0]["type"] = "vlan"
- request["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][0]["service-match-criteria"]["match-criterion"][0]["match-type"][0]["vlan"] = [vlan_id]
- request["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][0]["attachment-circuits"]["attachment-circuit"][0]["ac-ipv4-address"] = "" # Pendiente de rellenar
- request["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][0]["attachment-circuits"]["attachment-circuit"][0]["sdp-peering"]["peer-sap-id"] = src_node_ip
+ sdp_src = slice_service["sdps"]["sdp"][0]
+ sdp_src["node-id"] = "source-node"
+ sdp_src["sdp-ip-address"] = src_node_ip
+ sdp_src["service-match-criteria"]["match-criterion"][0]["match-type"][0]["type"] = "vlan"
+ sdp_src["service-match-criteria"]["match-criterion"][0]["match-type"][0]["vlan"] = [vlan_id]
+ sdp_src["attachment-circuits"]["attachment-circuit"][0]["ac-ipv4-address"] = ""
+ sdp_src["attachment-circuits"]["attachment-circuit"][0]["sdp-peering"]["peer-sap-id"] = src_node_ip
# Configure Destination SDP
- request["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][1]["node-id"] = "destination-node"
- request["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][1]["sdp-ip-address"] = dst_node_ip
- request["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][1]["service-match-criteria"]["match-criterion"][0]["match-type"][0]["type"] = "vlan"
- request["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][1]["service-match-criteria"]["match-criterion"][0]["match-type"][0]["vlan"] = [vlan_id]
- request["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][1]["attachment-circuits"]["attachment-circuit"][0]["ac-ipv4-address"] = ""# Pendiente de rellenar
- request["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][1]["attachment-circuits"]["attachment-circuit"][0]["sdp-peering"]["peer-sap-id"] = dst_node_ip
-
- # Configure Connection Group and match-criteria
- request["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["connection-groups"]["connection-group"][0]["id"] = "source-node_destination-node" #Pendiente de rellenar
- request["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][0]["service-match-criteria"]["match-criterion"][0]["target-connection-group-id"] = "" #Pendiente de rellenar
- request["ietf-network-slice-service:network-slice-services"]["slice-service"][0]["sdps"]["sdp"][1]["service-match-criteria"]["match-criterion"][0]["target-connection-group-id"] = "" #Pendiente de rellenar
-
- # Populate template with SLOs (currently supporting QoS profile, latency and bandwidth)
+ sdp_dst = slice_service["sdps"]["sdp"][1]
+ sdp_dst["node-id"] = "destination-node"
+ sdp_dst["sdp-ip-address"] = dst_node_ip
+ sdp_dst["service-match-criteria"]["match-criterion"][0]["match-type"][0]["type"] = "vlan"
+ sdp_dst["service-match-criteria"]["match-criterion"][0]["match-type"][0]["vlan"] = [vlan_id]
+ sdp_dst["attachment-circuits"]["attachment-circuit"][0]["ac-ipv4-address"] = ""
+ sdp_dst["attachment-circuits"]["attachment-circuit"][0]["sdp-peering"]["peer-sap-id"] = dst_node_ip
+
+ # Configure Connection Group
+ slice_service["connection-groups"]["connection-group"][0]["id"] = "source-node_destination-node"
+ sdp_src["service-match-criteria"]["match-criterion"][0]["target-connection-group-id"] = ""
+ sdp_dst["service-match-criteria"]["match-criterion"][0]["target-connection-group-id"] = ""
+
+ # Populate template with SLOs
if bandwidth:
- request["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["slo-policy"]["metric-bound"].append({
+ slo_template["slo-policy"]["metric-bound"].append({
"metric-type": "one-way-bandwidth",
"metric-unit": "Mbps",
- "bound": __safe_int(bandwidth)
+ "bound": __safe_int(bandwidth),
})
if latency:
- request["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["slo-policy"]["metric-bound"].append({
+ slo_template["slo-policy"]["metric-bound"].append({
"metric-type": "one-way-delay-maximum",
"metric-unit": "milliseconds",
- "bound": __safe_int(latency)
+ "bound": __safe_int(latency),
})
- # Configure gaussian latency or internet if specified
if latency_version:
- request["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["description"] = latency_version
- if tolerance:
- request["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["slo-policy"]["metric-bound"].append( {
- "metric-type": "one-way-delay-variation-maximum",
- "metric-unit": "milliseconds",
- "bound": __safe_int(tolerance)
- })
+ slo_template["description"] = latency_version
+ if tolerance:
+ slo_template["slo-policy"]["metric-bound"].append({
+ "metric-type": "one-way-delay-variation-maximum",
+ "metric-unit": "milliseconds",
+ "bound": __safe_int(tolerance),
+ })
if reliability:
- request["ietf-network-slice-service:network-slice-services"]["slo-sle-templates"]["slo-sle-template"][0]["sle-policy"]["reliability"] = __safe_int(reliability)
+ slo_template["sle-policy"]["reliability"] = __safe_int(reliability)
+
+ return request_data
- return request
def __build_request(ip_version=None, src_node_ip=None, dst_node_ip=None, src_node_ipv6=None, dst_node_ipv6=None,
vlan_id=None, bandwidth=None, latency=None, tolerance=0, latency_version=None,
@@ -174,7 +199,7 @@ def __datos_json():
dataframe =pd.DataFrame(rows)
except FileNotFoundError:
dataframe =pd.DataFrame()
- except ValueError as e:
+ except ValueError:
dataframe =pd.DataFrame()
return dataframe
@@ -411,52 +436,45 @@ def search():
print(f"Error procesando slice: {e}")
- import pandas as pd
dataframe = pd.DataFrame(rows)
- def format_attributes(attributes):
- formatted_attrs = []
- for attr in attributes:
- formatted_attrs.append(attr)
- if formatted_attrs:
- return formatted_attrs
- else:
- return None
-
- dataframe['attributes'] = dataframe['attributes'].apply([format_attributes])
-
- if request.method == 'POST':
- search_option = request.form.get('search_option')
- search_value = request.form.get('search_value')
- if search_option == 'Source IP':
- results = dataframe[dataframe['Source IP'] == search_value]
- elif search_option == 'Destiny IP':
- results = dataframe[dataframe['Destiny IP'] == search_value]
- elif search_option == 'Controller':
- results = dataframe[dataframe['Controller'] == search_value]
- elif search_option == 'VLAN':
- results = dataframe[dataframe['VLAN'] == search_value]
+ def format_attributes(attributes: list[str]) -> list[str] | None:
+ return list(attributes) if attributes else None
+
+ dataframe["attributes"] = dataframe["attributes"].apply([format_attributes])
+
+ if request.method == "POST":
+ search_option = request.form.get("search_option")
+ search_value = request.form.get("search_value")
+ if search_option == "Source IP":
+ results = dataframe[dataframe["Source IP"] == search_value]
+ elif search_option == "Destiny IP":
+ results = dataframe[dataframe["Destiny IP"] == search_value]
+ elif search_option == "Controller":
+ results = dataframe[dataframe["Controller"] == search_value]
+ elif search_option == "VLAN":
+ results = dataframe[dataframe["VLAN"] == search_value]
else:
results = dataframe
- result_html = results.to_html(classes='table table-striped')
- return jsonify({'result': result_html})
+ result_html = results.to_html(classes="table table-striped")
+ return jsonify({"result": result_html})
+
+ dataframe_html = dataframe.to_html(classes="table table-striped")
+ return render_template("search.html", dataframe_html=dataframe_html)
- dataframe_html = dataframe.to_html(classes='table table-striped')
- return render_template('search.html', dataframe_html=dataframe_html)
-@gui_bp.route('/webui/login', methods=['GET', 'POST'])
+@gui_bp.route("/webui/login", methods=["GET", "POST"])
def login():
- global enter
- if request.method == 'POST':
- username = request.form['username']
- password = request.form['password']
+ if request.method == "POST":
+ username = request.form["username"]
+ password = request.form["password"]
if username == USERNAME and password == PASSWORD:
- session['enter']=True
- return redirect(url_for('gui.develop'))
- else:
- return render_template('login.html', error="Credenciales incorrectas")
-
- return render_template('login.html')
+ session["enter"] = True
+ return redirect(url_for("gui.develop"))
+ return render_template("login.html", error="Credenciales incorrectas")
+
+ return render_template("login.html")
+
@gui_bp.route('/webui/reset', methods=['POST'])
def reset():
diff --git a/swagger/E2E_namespace.py b/swagger/E2E_namespace.py
index b9f91cdc73582584c1177e130089be01c63e303f..8c5747008ffe5454ca3d7af2f0b43dc4fe0be026 100644
--- a/swagger/E2E_namespace.py
+++ b/swagger/E2E_namespace.py
@@ -1,11 +1,11 @@
# Copyright 2022-2026 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
-
+#
+# 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.
@@ -14,249 +14,262 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
+"""E2E Orchestrator namespace definitions and resource controllers."""
+
+from __future__ import annotations
+
+from typing import Any
+
from flask import request
from flask_restx import Namespace, Resource, fields, reqparse
+
+from src.api.main import Api as E2EHandler
from src.main import NSController
-from src.api.main import Api
-import json
-from swagger.models.create_models import create_gpp_nrm_28541_model, create_ietf_network_slice_nbi_yang_model
+from swagger.helpers import extract_json_payload
+from swagger.models.create_models import (
+ create_gpp_nrm_28541_model,
+ create_ietf_network_slice_nbi_yang_model,
+)
e2e_ns = Namespace(
"E2E",
- description="Operations related to transport network slices with E2E Orchestrator"
+ description="Operations related to transport network slices with E2E Orchestrator",
)
-
# 3GPP NRM TS28.541 Data models
gpp_network_slice_request_model = create_gpp_nrm_28541_model(e2e_ns)
# IETF draft-ietf-teas-ietf-network-slice-nbi-yang Data models
-
slice_ddbb_model, slice_response_model = create_ietf_network_slice_nbi_yang_model(e2e_ns)
-# Alert Data Model
-alarm_info_model = e2e_ns.model("AlarmInfo", {
- "is-alarmed": fields.Boolean(description="Is alarmed", required=True),
- "perceived-severity": fields.String(description="Perceived severity", required=True),
- "probable-cause": fields.String(description="Probable cause", required=True),
- "threshold-low-breached": fields.Float(description="Threshold low breached", required=False),
- "threshold-high-breached": fields.Float(description="Threshold high breached", required=False)
-})
-
-additional_info_model = e2e_ns.model("AdditionalInfo", {
- "service-id": fields.String(description="Service ID", required=False),
- "kpi-name": fields.String(description="KPI name", required=False),
- "measured-value": fields.Float(description="Measured value", required=False)
-})
-
-tapi_notification_model = e2e_ns.model("TapiNotification", {
- "uuid": fields.String(description="UUID", required=True),
- "notification-type": fields.String(description="Notification type", required=True),
- "event-time-stamp": fields.String(description="Event timestamp", required=True),
- "target-object-type": fields.String(description="Target object type", required=False),
- "layer-protocol-name": fields.String(description="Layer protocol name", required=False),
- "alarm-info": fields.Nested(alarm_info_model, required=False),
- "additional-info": fields.Nested(additional_info_model, required=False)
-})
-
-tapi_notification_item_model = e2e_ns.model("TapiNotificationItem", {
- "tapi-notification:notification": fields.Nested(tapi_notification_model, required=True)
-})
-
-alert_model = e2e_ns.model("Alert", {
- "tapi-notification:notification-context": fields.List(fields.Nested(tapi_notification_item_model), required=True)
-})
+# Alert Data Models
+alarm_info_model = e2e_ns.model(
+ "AlarmInfo",
+ {
+ "is-alarmed": fields.Boolean(description="Is alarmed", required=True),
+ "perceived-severity": fields.String(description="Perceived severity", required=True),
+ "probable-cause": fields.String(description="Probable cause", required=True),
+ "threshold-low-breached": fields.Float(description="Threshold low breached", required=False),
+ "threshold-high-breached": fields.Float(description="Threshold high breached", required=False),
+ },
+)
+
+additional_info_model = e2e_ns.model(
+ "AdditionalInfo",
+ {
+ "service-id": fields.String(description="Service ID", required=False),
+ "kpi-name": fields.String(description="KPI name", required=False),
+ "measured-value": fields.Float(description="Measured value", required=False),
+ },
+)
+
+tapi_notification_model = e2e_ns.model(
+ "TapiNotification",
+ {
+ "uuid": fields.String(description="UUID", required=True),
+ "notification-type": fields.String(description="Notification type", required=True),
+ "event-time-stamp": fields.String(description="Event timestamp", required=True),
+ "target-object-type": fields.String(description="Target object type", required=False),
+ "layer-protocol-name": fields.String(description="Layer protocol name", required=False),
+ "alarm-info": fields.Nested(alarm_info_model, required=False),
+ "additional-info": fields.Nested(additional_info_model, required=False),
+ },
+)
+
+tapi_notification_item_model = e2e_ns.model(
+ "TapiNotificationItem",
+ {
+ "tapi-notification:notification": fields.Nested(tapi_notification_model, required=True),
+ },
+)
+
+alert_model = e2e_ns.model(
+ "Alert",
+ {
+ "tapi-notification:notification-context": fields.List(
+ fields.Nested(tapi_notification_item_model), required=True
+ ),
+ },
+)
upload_parser = reqparse.RequestParser()
-upload_parser.add_argument('file', location='files', type='FileStorage', help="File to upload")
-upload_parser.add_argument('json_data', location='form', help="JSON Data in string format")
+upload_parser.add_argument("file", location="files", type="FileStorage", help="File to upload")
+upload_parser.add_argument("json_data", location="form", help="JSON Data in string format")
-# Namespace Controllers
+
+# =============================================================================
+# Namespace Resource Controllers
+# =============================================================================
+
+
+@e2e_ns.route("/slices")
@e2e_ns.route("/slice")
class E2ESliceList(Resource):
- @e2e_ns.doc(summary="Return all transport network slices", description="Returns all transport network slices from the slice controller.")
+ """Resource for collection-level slice operations on E2E controller."""
+
+ @e2e_ns.doc(
+ summary="Return all transport network slices",
+ description="Returns all transport network slices from the slice controller.",
+ )
@e2e_ns.response(200, "Slices returned", slice_ddbb_model)
@e2e_ns.response(404, "Transport network slices not found")
@e2e_ns.response(500, "Internal server error")
- def get(self):
- """Retrieve all slices"""
+ def get(self) -> tuple[dict[str, Any] | list[Any], int]:
+ """Retrieve all slices."""
controller = NSController(controller_type="E2E")
- data, code = Api(controller).get_flows()
- return data, code
-
- @e2e_ns.doc(summary="Submit a transport network slice request", description="This endpoint allows clients to submit transport network slice requests using a JSON payload.")
- @e2e_ns.response(201,"Slice created successfully", slice_response_model)
- @e2e_ns.response(200, "No service to process.")
+ return E2EHandler(controller).get_flows()
+
+ @e2e_ns.doc(
+ summary="Submit a transport network slice request",
+ description="This endpoint allows clients to submit transport network slice requests using a JSON payload.",
+ )
+ @e2e_ns.response(201, "Slice created successfully", slice_response_model)
+ @e2e_ns.response(422, "Unprocessable entity")
@e2e_ns.response(400, "Invalid request format")
@e2e_ns.response(500, "Internal server error")
@e2e_ns.expect(upload_parser)
- def post(self):
- """Submit a new slice request with a file"""
-
- json_data = None
-
- # Try to get the JSON data from the uploaded file
- uploaded_file = request.files.get('file')
- if uploaded_file:
- if not uploaded_file.filename.endswith('.json'):
- return {
- "success": False,
- "data": None,
- "error": "Only JSON files allowed"
- }, 400
-
- try:
- json_data = json.load(uploaded_file) # Convert file to JSON
- except json.JSONDecodeError:
- return {
- "success": False,
- "data": None,
- "error": "JSON file not valid"
- }, 400
-
- # If no file was uploaded, try to get the JSON data from the form
- if json_data is None:
- raw_json = request.form.get('json_data')
- if raw_json:
- try:
- json_data = json.loads(raw_json) # Convert string to JSON
- except json.JSONDecodeError:
- return {
- "success": False,
- "data": None,
- "error": "JSON file not valid"
- }, 400
-
- # If no JSON data was found, return an error
- if json_data is None:
- return {
- "success": False,
- "data": None,
- "error": "No data sent"
- }, 400
-
- # Process the JSON data with the NSController
+ def post(self) -> tuple[dict[str, Any], int]:
+ """Submit a new slice request with a file or form payload."""
+ json_data, err_resp = extract_json_payload(request)
+ if err_resp is not None:
+ return err_resp
+
controller = NSController(controller_type="E2E")
- data, code = Api(controller).add_flow(json_data)
- return data, code
-
- @e2e_ns.doc(summary="Delete all transport network slices", description="Deletes all transport network slices from the slice controller.")
+ return E2EHandler(controller).add_flow(json_data) # type: ignore[arg-type]
+
+ @e2e_ns.doc(
+ summary="Delete all transport network slices",
+ description="Deletes all transport network slices from the slice controller.",
+ )
@e2e_ns.response(204, "All transport network slices deleted successfully.")
@e2e_ns.response(500, "Internal server error")
- def delete(self):
- """Delete all slices"""
+ def delete(self) -> tuple[dict[str, Any], int]:
+ """Delete all slices."""
controller = NSController(controller_type="E2E")
- data, code = Api(controller).delete_flows()
- return data, code
+ return E2EHandler(controller).delete_flows()
+@e2e_ns.route("/slices/")
@e2e_ns.route("/slice/")
@e2e_ns.doc(params={"slice_id": "The ID of the slice to retrieve or modify"})
class E2ESlice(Resource):
- @e2e_ns.doc(summary="Return a specific transport network slice", description="Returns specific information related to a slice by providing its id")
+ """Resource for individual slice operations on E2E controller."""
+
+ @e2e_ns.doc(
+ summary="Return a specific transport network slice",
+ description="Returns specific information related to a slice by providing its id",
+ )
@e2e_ns.response(200, "Slice returned", slice_ddbb_model)
@e2e_ns.response(404, "Transport network slice not found.")
@e2e_ns.response(500, "Internal server error")
- def get(self, slice_id):
- """Retrieve a specific slice"""
+ def get(self, slice_id: str) -> tuple[dict[str, Any] | list[Any], int]:
+ """Retrieve a specific slice."""
controller = NSController(controller_type="E2E")
- data, code = Api(controller).get_flows(slice_id)
- return data, code
+ return E2EHandler(controller).get_flows(slice_id)
- @e2e_ns.doc(summary="Delete a specific transport network slice", description="Deletes a specific transport network slice from the slice controller based on the provided `slice_id`.")
+ @e2e_ns.doc(
+ summary="Delete a specific transport network slice",
+ description="Deletes a specific transport network slice from the slice controller based on the provided `slice_id`.",
+ )
@e2e_ns.response(204, "Transport network slice deleted successfully.")
@e2e_ns.response(404, "Transport network slice not found.")
@e2e_ns.response(500, "Internal server error")
- def delete(self, slice_id):
- """Delete a slice"""
+ def delete(self, slice_id: str) -> tuple[dict[str, Any], int]:
+ """Delete a slice."""
controller = NSController(controller_type="E2E")
- data, code = Api(controller).delete_flows(slice_id)
- return data, code
+ return E2EHandler(controller).delete_flows(slice_id)
@e2e_ns.expect(slice_ddbb_model, validate=True)
- @e2e_ns.doc(summary="Modify a specific transport network slice", description="Returns a specific slice that has been modified")
+ @e2e_ns.doc(
+ summary="Modify a specific transport network slice",
+ description="Returns a specific slice that has been modified",
+ )
@e2e_ns.response(200, "Slice modified", slice_response_model)
@e2e_ns.response(404, "Transport network slice not found.")
@e2e_ns.response(500, "Internal server error")
- def put(self, slice_id):
- """Modify a slice"""
+ def put(self, slice_id: str) -> tuple[dict[str, Any], int]:
+ """Modify a slice."""
json_data = request.get_json()
controller = NSController(controller_type="E2E")
- data, code = Api(controller).modify_flow(slice_id, json_data)
- return data, code
+ return E2EHandler(controller).modify_flow(slice_id, json_data)
+@e2e_ns.route("/alerts")
@e2e_ns.route("/alert")
class E2EAlertList(Resource):
+ """Resource for alert collection operations on E2E controller."""
+
@e2e_ns.doc(summary="Return all alerts", description="Returns all alerts received.")
@e2e_ns.response(200, "Alerts returned")
@e2e_ns.response(500, "Internal server error")
- def get(self):
- """Retrieve all alerts"""
+ def get(self) -> tuple[dict[str, Any] | list[Any], int]:
+ """Retrieve all alerts."""
controller = NSController(controller_type="E2E")
- data, code = Api(controller).get_alerts()
- return data, code
+ return E2EHandler(controller).get_alerts()
@e2e_ns.doc(summary="Receive alerts", description="This endpoint allows E2E to receive alerts.")
@e2e_ns.response(201, "Alert saved successfully")
@e2e_ns.response(400, "Invalid alert format")
@e2e_ns.response(500, "Internal server error")
@e2e_ns.expect(alert_model, validate=True)
- def post(self):
- """Receive an alert"""
+ def post(self) -> tuple[dict[str, Any], int]:
+ """Receive an alert."""
json_data = request.get_json()
if not json_data:
- return {
- "success": False,
- "data": None,
- "error": "No alert data sent"
- }, 400
+ return {"success": False, "data": None, "error": "No alert data sent"}, 400
controller = NSController(controller_type="E2E")
- data, code = Api(controller).receive_alert(json_data)
- return data, code
+ return E2EHandler(controller).receive_alert(json_data)
@e2e_ns.doc(summary="Delete all alerts", description="Deletes all alerts.")
@e2e_ns.response(204, "All alerts deleted successfully.")
@e2e_ns.response(500, "Internal server error")
- def delete(self):
- """Delete all alerts"""
+ def delete(self) -> tuple[dict[str, Any], int]:
+ """Delete all alerts."""
controller = NSController(controller_type="E2E")
- data, code = Api(controller).delete_alerts()
- return data, code
+ return E2EHandler(controller).delete_alerts()
+@e2e_ns.route("/alerts/")
@e2e_ns.route("/alert/")
@e2e_ns.doc(params={"alert_id": "The UUID of the alert to retrieve, modify, or delete"})
class E2EAlertDetail(Resource):
- @e2e_ns.doc(summary="Return a specific alert", description="Returns specific information related to an alert by providing its UUID")
+
+ """Resource for single alert operations on E2E controller."""
+
+ @e2e_ns.doc(
+ summary="Return a specific alert",
+ description="Returns specific information related to an alert by providing its UUID",
+ )
@e2e_ns.response(200, "Alert returned")
@e2e_ns.response(404, "Alert not found.")
@e2e_ns.response(500, "Internal server error")
- def get(self, alert_id):
- """Retrieve a specific alert"""
+ def get(self, alert_id: str) -> tuple[dict[str, Any] | list[Any], int]:
+ """Retrieve a specific alert."""
controller = NSController(controller_type="E2E")
- data, code = Api(controller).get_alerts(alert_id)
- return data, code
+ return E2EHandler(controller).get_alerts(alert_id)
- @e2e_ns.doc(summary="Delete a specific alert", description="Deletes a specific alert based on the provided `alert_id`.")
+ @e2e_ns.doc(
+ summary="Delete a specific alert",
+ description="Deletes a specific alert based on the provided `alert_id`.",
+ )
@e2e_ns.response(204, "Alert deleted successfully.")
@e2e_ns.response(404, "Alert not found.")
@e2e_ns.response(500, "Internal server error")
- def delete(self, alert_id):
- """Delete an alert"""
+ def delete(self, alert_id: str) -> tuple[dict[str, Any], int]:
+ """Delete an alert."""
controller = NSController(controller_type="E2E")
- data, code = Api(controller).delete_alerts(alert_id)
- return data, code
+ return E2EHandler(controller).delete_alerts(alert_id)
@e2e_ns.expect(alert_model, validate=True)
- @e2e_ns.doc(summary="Modify a specific alert", description="Modifies a specific alert and returns the updated alert data")
+ @e2e_ns.doc(
+ summary="Modify a specific alert",
+ description="Modifies a specific alert and returns the updated alert data",
+ )
@e2e_ns.response(200, "Alert modified")
@e2e_ns.response(404, "Alert not found.")
@e2e_ns.response(500, "Internal server error")
- def put(self, alert_id):
- """Modify an alert"""
+ def put(self, alert_id: str) -> tuple[dict[str, Any], int]:
+ """Modify an alert."""
json_data = request.get_json()
controller = NSController(controller_type="E2E")
- data, code = Api(controller).modify_alert(alert_id, json_data)
- return data, code
-
+ return E2EHandler(controller).modify_alert(alert_id, json_data)
diff --git a/swagger/helpers.py b/swagger/helpers.py
new file mode 100644
index 0000000000000000000000000000000000000000..66ea5db5b348c5695386adb293fb5bf6933eae42
--- /dev/null
+++ b/swagger/helpers.py
@@ -0,0 +1,64 @@
+# Copyright 2022-2026 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.
+
+"""Helper utilities for Swagger namespaces and request parsing."""
+
+from __future__ import annotations
+
+import json
+import logging
+from typing import Any
+
+from flask import Request
+
+logger = logging.getLogger(__name__)
+
+
+def extract_json_payload(req: Request) -> tuple[dict[str, Any] | None, tuple[dict[str, Any], int] | None]:
+ """Extract and validate JSON payload from multipart file upload, form data, or JSON body.
+
+ Args:
+ req: Incoming Flask request.
+
+ Returns:
+ A tuple of (parsed_json_dict, None) on success, or
+ (None, (error_response_dict, status_code)) on failure.
+ """
+ uploaded_file = req.files.get("file")
+ if uploaded_file:
+ filename = uploaded_file.filename or ""
+ if not filename.endswith(".json"):
+ return None, ({"success": False, "data": None, "error": "Only JSON files allowed"}, 400)
+
+ try:
+ return json.load(uploaded_file), None
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
+ logger.debug("Failed to decode uploaded JSON file: %s", exc)
+ return None, ({"success": False, "data": None, "error": "JSON file not valid"}, 400)
+
+ raw_json = req.form.get("json_data")
+ if raw_json:
+ try:
+ return json.loads(raw_json), None
+ except json.JSONDecodeError as exc:
+ logger.debug("Failed to decode json_data form field: %s", exc)
+ return None, ({"success": False, "data": None, "error": "JSON file not valid"}, 400)
+
+ json_body = req.get_json(silent=True)
+ if json_body is not None:
+ return json_body, None
+
+ return None, ({"success": False, "data": None, "error": "No data sent"}, 400)
diff --git a/swagger/ixia_namespace.py b/swagger/ixia_namespace.py
index 12f122b97c5449b065ce096a2d98075bc21164f8..31b1d640e05929c76c861b162a2b335ba0ff8fbf 100644
--- a/swagger/ixia_namespace.py
+++ b/swagger/ixia_namespace.py
@@ -14,16 +14,26 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
+"""IXIA NEII namespace definitions and resource controllers."""
+
+from __future__ import annotations
+
+from typing import Any
+
from flask import request
from flask_restx import Namespace, Resource, reqparse
+
+from src.api.main import Api as IxiaHandler
from src.main import NSController
-from src.api.main import Api
-import json
-from swagger.models.create_models import create_gpp_nrm_28541_model, create_ietf_network_slice_nbi_yang_model
+from swagger.helpers import extract_json_payload
+from swagger.models.create_models import (
+ create_gpp_nrm_28541_model,
+ create_ietf_network_slice_nbi_yang_model,
+)
ixia_ns = Namespace(
"ixia",
- description="Operations related to transport network slices with IXIA NEII"
+ description="Operations related to transport network slices with IXIA NEII",
)
# 3GPP NRM TS28.541 Data models
@@ -33,98 +43,103 @@ gpp_network_slice_request_model = create_gpp_nrm_28541_model(ixia_ns)
slice_ddbb_model, slice_response_model = create_ietf_network_slice_nbi_yang_model(ixia_ns)
upload_parser = reqparse.RequestParser()
-upload_parser.add_argument('file', location='files', type='FileStorage', help="Archivo a subir")
-upload_parser.add_argument('json_data', location='form', help="Datos JSON en formato string")
+upload_parser.add_argument("file", location="files", type="FileStorage", help="File to upload")
+upload_parser.add_argument("json_data", location="form", help="JSON Data in string format")
+
+# =============================================================================
+# Namespace Resource Controllers
+# =============================================================================
-# Namespace Controllers
+
+@ixia_ns.route("/slices")
@ixia_ns.route("/slice")
class IxiaSliceList(Resource):
- @ixia_ns.doc(summary="Return all transport network slices", description="Returns all transport network slices from the slice controller.")
+ """Resource for collection-level slice operations on IXIA controller."""
+
+ @ixia_ns.doc(
+ summary="Return all transport network slices",
+ description="Returns all transport network slices from the slice controller.",
+ )
@ixia_ns.response(200, "Slices returned", slice_ddbb_model)
@ixia_ns.response(404, "Transport network slices not found")
@ixia_ns.response(500, "Internal server error")
- def get(self):
- """Retrieve all slices"""
+ def get(self) -> tuple[dict[str, Any] | list[Any], int]:
+ """Retrieve all slices."""
controller = NSController(controller_type="IXIA")
- data, code = Api(controller).get_flows()
- return data, code
-
- @ixia_ns.doc(summary="Submit a transport network slice request", description="This endpoint allows clients to submit transport network slice requests using a JSON payload.")
+ return IxiaHandler(controller).get_flows()
+
+ @ixia_ns.doc(
+ summary="Submit a transport network slice request",
+ description="This endpoint allows clients to submit transport network slice requests using a JSON payload.",
+ )
@ixia_ns.response(201, "Slice created successfully", slice_response_model)
- @ixia_ns.response(200, "No service to process.")
+ @ixia_ns.response(422, "Unprocessable entity")
@ixia_ns.response(400, "Invalid request format")
@ixia_ns.response(500, "Internal server error")
@ixia_ns.expect(upload_parser)
- def post(self):
- """Submit a new slice request with a file"""
- json_data = None
-
- uploaded_file = request.files.get('file')
- if uploaded_file:
- if not uploaded_file.filename.endswith('.json'):
- return {"success": False, "data": None, "error": "Only JSON files allowed"}, 400
- try:
- json_data = json.load(uploaded_file)
- except json.JSONDecodeError:
- return {"success": False, "data": None, "error": "JSON file not valid"}, 400
-
- if json_data is None:
- raw_json = request.form.get('json_data')
- if raw_json:
- try:
- json_data = json.loads(raw_json)
- except json.JSONDecodeError:
- return {"success": False, "data": None, "error": "JSON file not valid"}, 400
-
- if json_data is None:
- return {"success": False, "data": None, "error": "No data sent"}, 400
+ def post(self) -> tuple[dict[str, Any], int]:
+ """Submit a new slice request with a file or form payload."""
+ json_data, err_resp = extract_json_payload(request)
+ if err_resp is not None:
+ return err_resp
controller = NSController(controller_type="IXIA")
- data, code = Api(controller).add_flow(json_data)
- return data, code
-
- @ixia_ns.doc(summary="Delete all transport network slices", description="Deletes all transport network slices from the slice controller.")
+ return IxiaHandler(controller).add_flow(json_data) # type: ignore[arg-type]
+
+ @ixia_ns.doc(
+ summary="Delete all transport network slices",
+ description="Deletes all transport network slices from the slice controller.",
+ )
@ixia_ns.response(204, "All transport network slices deleted successfully.")
@ixia_ns.response(500, "Internal server error")
- def delete(self):
- """Delete all slices"""
+ def delete(self) -> tuple[dict[str, Any], int]:
+ """Delete all slices."""
controller = NSController(controller_type="IXIA")
- data, code = Api(controller).delete_flows()
- return data, code
+ return IxiaHandler(controller).delete_flows()
+@ixia_ns.route("/slices/")
@ixia_ns.route("/slice/")
@ixia_ns.doc(params={"slice_id": "The ID of the slice to retrieve or modify"})
class IxiaSlice(Resource):
- @ixia_ns.doc(summary="Return a specific transport network slice", description="Returns specific information related to a slice by providing its id")
+
+ """Resource for individual slice operations on IXIA controller."""
+
+ @ixia_ns.doc(
+ summary="Return a specific transport network slice",
+ description="Returns specific information related to a slice by providing its id",
+ )
@ixia_ns.response(200, "Slice returned", slice_ddbb_model)
@ixia_ns.response(404, "Transport network slice not found.")
@ixia_ns.response(500, "Internal server error")
- def get(self, slice_id):
- """Retrieve a specific slice"""
+ def get(self, slice_id: str) -> tuple[dict[str, Any] | list[Any], int]:
+ """Retrieve a specific slice."""
controller = NSController(controller_type="IXIA")
- data, code = Api(controller).get_flows(slice_id)
- return data, code
+ return IxiaHandler(controller).get_flows(slice_id)
- @ixia_ns.doc(summary="Delete a specific transport network slice", description="Deletes a specific transport network slice from the slice controller based on the provided `slice_id`.")
+ @ixia_ns.doc(
+ summary="Delete a specific transport network slice",
+ description="Deletes a specific transport network slice from the slice controller based on the provided `slice_id`.",
+ )
@ixia_ns.response(204, "Transport network slice deleted successfully.")
@ixia_ns.response(404, "Transport network slice not found.")
@ixia_ns.response(500, "Internal server error")
- def delete(self, slice_id):
- """Delete a slice"""
+ def delete(self, slice_id: str) -> tuple[dict[str, Any], int]:
+ """Delete a slice."""
controller = NSController(controller_type="IXIA")
- data, code = Api(controller).delete_flows(slice_id)
- return data, code
+ return IxiaHandler(controller).delete_flows(slice_id)
@ixia_ns.expect(slice_ddbb_model, validate=True)
- @ixia_ns.doc(summary="Modify a specific transport network slice", description="Returns a specific slice that has been modified")
+ @ixia_ns.doc(
+ summary="Modify a specific transport network slice",
+ description="Returns a specific slice that has been modified",
+ )
@ixia_ns.response(200, "Slice modified", slice_ddbb_model)
@ixia_ns.response(404, "Transport network slice not found.")
@ixia_ns.response(500, "Internal server error")
- def put(self, slice_id):
- """Modify a slice"""
+ def put(self, slice_id: str) -> tuple[dict[str, Any], int]:
+ """Modify a slice."""
json_data = request.get_json()
controller = NSController(controller_type="IXIA")
- data, code = Api(controller).modify_flow(slice_id, json_data)
- return data, code
\ No newline at end of file
+ return IxiaHandler(controller).modify_flow(slice_id, json_data)
diff --git a/swagger/models/create_models.py b/swagger/models/create_models.py
index 6c2a3f95dd206e32dc577afc2fc9fb7328f510b8..e8ee29ffa3e2c663b598f4aef1924a58620725a6 100644
--- a/swagger/models/create_models.py
+++ b/swagger/models/create_models.py
@@ -14,34 +14,38 @@
#This file is an original contribution from Telefonica Innovación Digital S.L.
-from flask_restx import fields
+"""Swagger model builders for 3GPP NRM TS28.541 and IETF Network Slice NBI YANG."""
-def create_gpp_nrm_28541_model(slice_ns):
- # 3GPP NRM TS28.541 Data models
+from __future__ import annotations
+
+from flask_restx import Model, Namespace, fields
+
+
+def create_gpp_nrm_28541_model(slice_ns: Namespace) -> Model:
+ """Create and register 3GPP NRM TS28.541 models within given namespace.
+
+ Args:
+ slice_ns: Target Flask-RESTX namespace.
+
+ Returns:
+ The root NetworkSliceRequest model.
+ """
logical_interface_info_model = slice_ns.model(
"LogicalInterfaceInfo",
{
- "logicalInterfaceType": fields.String(
- description="Type of logical interface", example="VLAN"
- ),
- "logicalInterfaceId": fields.String(
- description="Identifier of the logical interface", example="300"
- ),
+ "logicalInterfaceType": fields.String(description="Type of logical interface", example="VLAN"),
+ "logicalInterfaceId": fields.String(description="Identifier of the logical interface", example="300"),
},
)
ep_transport_model = slice_ns.model(
"EpTransport",
{
- "IpAddress": fields.String(
- description="IP address of the endpoint", example="100.1.1.1"
- ),
+ "IpAddress": fields.String(description="IP address of the endpoint", example="100.1.1.1"),
"logicalInterfaceInfo": fields.Nested(
logical_interface_info_model, description="Logical interface details"
),
- "NextHopInfo": fields.String(
- description="Next hop information", example="100.1.1.254"
- ),
+ "NextHopInfo": fields.String(description="Next hop information", example="100.1.1.254"),
"qosProfile": fields.String(description="QoS profile", example="5QI100"),
"EpApplicationRef": fields.List(
fields.String,
@@ -54,12 +58,8 @@ def create_gpp_nrm_28541_model(slice_ns):
slice_profile_model = slice_ns.model(
"SliceProfile",
{
- "sliceProfileId": fields.String(
- description="ID of the slice profile", example="TopId"
- ),
- "pLMNInfoList": fields.Raw(
- description="PLMN information list (nullable)", example=None
- ),
+ "sliceProfileId": fields.String(description="ID of the slice profile", example="TopId"),
+ "pLMNInfoList": fields.Raw(description="PLMN information list (nullable)", example=None),
"TopSliceSubnetProfile": fields.Nested(
slice_ns.model(
"TopSliceSubnetProfile",
@@ -68,12 +68,8 @@ def create_gpp_nrm_28541_model(slice_ns):
slice_ns.model(
"DLThpt",
{
- "GuaThpt": fields.Integer(
- description="Guaranteed throughput", example=200
- ),
- "MaxThpt": fields.Integer(
- description="Maximum throughput", example=400
- ),
+ "GuaThpt": fields.Integer(description="Guaranteed throughput", example=200),
+ "MaxThpt": fields.Integer(description="Maximum throughput", example=400),
},
),
description="Downlink throughput details",
@@ -82,22 +78,14 @@ def create_gpp_nrm_28541_model(slice_ns):
slice_ns.model(
"ULThpt",
{
- "GuaThpt": fields.Integer(
- description="Guaranteed throughput", example=200
- ),
- "MaxThpt": fields.Integer(
- description="Maximum throughput", example=400
- ),
+ "GuaThpt": fields.Integer(description="Guaranteed throughput", example=200),
+ "MaxThpt": fields.Integer(description="Maximum throughput", example=400),
},
),
description="Uplink throughput details",
),
- "dLLatency": fields.Integer(
- description="Downlink latency", example=20
- ),
- "uLLatency": fields.Integer(
- description="Uplink latency", example=20
- ),
+ "dLLatency": fields.Integer(description="Downlink latency", example=20),
+ "uLLatency": fields.Integer(description="Uplink latency", example=20),
},
),
description="Top slice subnet profile details",
@@ -108,21 +96,11 @@ def create_gpp_nrm_28541_model(slice_ns):
subnet_model = slice_ns.model(
"Subnet",
{
- "operationalState": fields.String(
- description="Operational state of the subnet", example=""
- ),
- "administrativeState": fields.String(
- description="Administrative state of the subnet", example=""
- ),
- "nsInfo": fields.Raw(
- description="Network slice information (object)", example={}
- ),
- "managedFunctionRef": fields.List(
- fields.Raw, description="Managed function references", example=[]
- ),
- "networkSliceSubnetType": fields.String(
- description="Type of the subnet", example="TOP_SLICESUBNET"
- ),
+ "operationalState": fields.String(description="Operational state of the subnet", example=""),
+ "administrativeState": fields.String(description="Administrative state of the subnet", example=""),
+ "nsInfo": fields.Raw(description="Network slice information (object)", example={}),
+ "managedFunctionRef": fields.List(fields.Raw, description="Managed function references", example=[]),
+ "networkSliceSubnetType": fields.String(description="Type of the subnet", example="TOP_SLICESUBNET"),
"SliceProfileList": fields.List(
fields.Nested(slice_profile_model),
description="List of slice profiles for the subnet",
@@ -142,21 +120,11 @@ def create_gpp_nrm_28541_model(slice_ns):
subnet_model,
description="Top-level slice details",
),
- "TopSliceSubnet1": fields.Nested(
- subnet_model, description="Details of the top slice subnet"
- ),
- "CNSliceSubnet1": fields.Nested(
- subnet_model, description="Details of the CN slice subnet"
- ),
- "RANSliceSubnet1": fields.Nested(
- subnet_model, description="Details of the RAN slice subnet"
- ),
- "MidhaulSliceSubnet1": fields.Nested(
- subnet_model, description="Details of the midhaul slice subnet"
- ),
- "BackhaulSliceSubnet1": fields.Nested(
- subnet_model, description="Details of the backhaul slice subnet"
- ),
+ "TopSliceSubnet1": fields.Nested(subnet_model, description="Details of the top slice subnet"),
+ "CNSliceSubnet1": fields.Nested(subnet_model, description="Details of the CN slice subnet"),
+ "RANSliceSubnet1": fields.Nested(subnet_model, description="Details of the RAN slice subnet"),
+ "MidhaulSliceSubnet1": fields.Nested(subnet_model, description="Details of the midhaul slice subnet"),
+ "BackhaulSliceSubnet1": fields.Nested(subnet_model, description="Details of the backhaul slice subnet"),
"EpTransport CU-UP1": fields.Nested(
ep_transport_model, description="Details of the transport endpoint CU-UP1"
),
@@ -164,12 +132,8 @@ def create_gpp_nrm_28541_model(slice_ns):
slice_ns.model(
"EPF1U",
{
- "localAddress": fields.String(
- description="Local address", example="100.1.1.2"
- ),
- "remoteAddress": fields.String(
- description="Remote address", example="1.1.3.2"
- ),
+ "localAddress": fields.String(description="Local address", example="100.1.1.2"),
+ "remoteAddress": fields.String(description="Remote address", example="1.1.3.2"),
"epTransportRef": fields.List(
fields.String,
description="References to transport endpoints",
@@ -183,120 +147,230 @@ def create_gpp_nrm_28541_model(slice_ns):
)
return gpp_network_slice_request_model
-def create_ietf_network_slice_nbi_yang_model(slice_ns):
- # IETF draft-ietf-teas-ietf-network-slice-nbi-yang Data models
- slo_policy_model = slice_ns.model('SloPolicy', {
- 'metric-bound': fields.List(fields.Nested(slice_ns.model('MetricBound', {
- 'metric-type': fields.String(),
- 'metric-unit': fields.String(),
- 'bound': fields.Integer()
- })))
- })
- sle_policy_model = slice_ns.model('SlePolicy', {
- 'security': fields.String(),
- 'isolation': fields.String(),
- 'path-constraints': fields.Nested(slice_ns.model('PathConstraints', {
- 'service-functions': fields.String(),
- 'diversity': fields.Nested(slice_ns.model('Diversity', {
- 'diversity-type': fields.String()
- }))
- }))
- })
+def create_ietf_network_slice_nbi_yang_model(slice_ns: Namespace) -> tuple[Model, Model]:
+ """Create and register IETF draft-ietf-teas-ietf-network-slice-nbi-yang models.
- slo_sle_template_model = slice_ns.model('SloSleTemplate', {
- 'id': fields.String(),
- 'description': fields.String(),
- 'slo-policy': fields.Nested(slo_policy_model),
- 'sle-policy': fields.Nested(sle_policy_model)
- })
+ Args:
+ slice_ns: Target Flask-RESTX namespace.
- service_match_criteria_model = slice_ns.model('ServiceMatchCriteria', {
- 'match-criterion': fields.List(fields.Nested(slice_ns.model('MatchCriterion', {
- 'index': fields.Integer(),
- 'match-type': fields.List(fields.Nested(slice_ns.model('MatchType', {
- 'type': fields.String(),
- 'VLAN': fields.List(fields.Integer())
- }))),
- 'target-connection-group-id': fields.String()
- })))
- })
+ Returns:
+ Tuple of (slice_ddbb_model, slice_response_model).
+ """
+ slo_policy_model = slice_ns.model(
+ "SloPolicy",
+ {
+ "metric-bound": fields.List(
+ fields.Nested(
+ slice_ns.model(
+ "MetricBound",
+ {
+ "metric-type": fields.String(),
+ "metric-unit": fields.String(),
+ "bound": fields.Integer(),
+ },
+ )
+ )
+ )
+ },
+ )
- attachment_circuit_model = slice_ns.model('AttachmentCircuit', {
- 'id': fields.String(),
- 'ac-ipv4-address': fields.String(),
- 'ac-ipv4-prefix-length': fields.Integer(),
- 'sdp-peering': fields.Nested(slice_ns.model('SdpPeering', {
- 'peer-sap-id': fields.String()
- })),
- 'status': fields.String()
- })
+ sle_policy_model = slice_ns.model(
+ "SlePolicy",
+ {
+ "security": fields.String(),
+ "isolation": fields.String(),
+ "path-constraints": fields.Nested(
+ slice_ns.model(
+ "PathConstraints",
+ {
+ "service-functions": fields.String(),
+ "diversity": fields.Nested(
+ slice_ns.model(
+ "Diversity",
+ {"diversity-type": fields.String()},
+ )
+ ),
+ },
+ )
+ ),
+ },
+ )
- sdp_model = slice_ns.model('Sdp', {
- 'id': fields.String(),
- 'geo-location': fields.String(),
- 'node-id': fields.String(),
- 'sdp-ip-address': fields.String(),
- 'tp-ref': fields.String(),
- 'service-match-criteria': fields.Nested(service_match_criteria_model),
- 'incoming-qos-policy': fields.String(),
- 'outgoing-qos-policy': fields.String(),
- 'sdp-peering': fields.Nested(slice_ns.model('SdpPeering', {
- 'peer-sap-id': fields.String(),
- 'protocols': fields.String()
- })),
- 'ac-svc-ref': fields.List(fields.String()),
- 'attachment-circuits': fields.List(fields.Nested(attachment_circuit_model)),
- 'status': fields.String(),
- 'sdp-monitoring': fields.String()
- })
+ slo_sle_template_model = slice_ns.model(
+ "SloSleTemplate",
+ {
+ "id": fields.String(),
+ "description": fields.String(),
+ "slo-policy": fields.Nested(slo_policy_model),
+ "sle-policy": fields.Nested(sle_policy_model),
+ },
+ )
+
+ service_match_criteria_model = slice_ns.model(
+ "ServiceMatchCriteria",
+ {
+ "match-criterion": fields.List(
+ fields.Nested(
+ slice_ns.model(
+ "MatchCriterion",
+ {
+ "index": fields.Integer(),
+ "match-type": fields.List(
+ fields.Nested(
+ slice_ns.model(
+ "MatchType",
+ {
+ "type": fields.String(),
+ "VLAN": fields.List(fields.Integer()),
+ },
+ )
+ )
+ ),
+ "target-connection-group-id": fields.String(),
+ },
+ )
+ )
+ )
+ },
+ )
+
+ attachment_circuit_model = slice_ns.model(
+ "AttachmentCircuit",
+ {
+ "id": fields.String(),
+ "ac-ipv4-address": fields.String(),
+ "ac-ipv4-prefix-length": fields.Integer(),
+ "sdp-peering": fields.Nested(
+ slice_ns.model(
+ "SdpPeering",
+ {"peer-sap-id": fields.String()},
+ )
+ ),
+ "status": fields.String(),
+ },
+ )
- connection_group_model = slice_ns.model('ConnectionGroup', {
- 'id': fields.String(),
- 'connectivity-type': fields.String(),
- 'connectivity-construct': fields.List(fields.Nested(slice_ns.model('ConnectivityConstruct', {
- 'id': fields.Integer(),
- 'a2a-sdp': fields.List(fields.Nested(slice_ns.model('A2ASdp', {
- 'sdp-id': fields.String()
- })))
- }))),
- 'status': fields.String()
- })
+ sdp_model = slice_ns.model(
+ "Sdp",
+ {
+ "id": fields.String(),
+ "geo-location": fields.String(),
+ "node-id": fields.String(),
+ "sdp-ip-address": fields.String(),
+ "tp-ref": fields.String(),
+ "service-match-criteria": fields.Nested(service_match_criteria_model),
+ "incoming-qos-policy": fields.String(),
+ "outgoing-qos-policy": fields.String(),
+ "sdp-peering": fields.Nested(
+ slice_ns.model(
+ "SdpPeeringDetails",
+ {
+ "peer-sap-id": fields.String(),
+ "protocols": fields.String(),
+ },
+ )
+ ),
+ "ac-svc-ref": fields.List(fields.String()),
+ "attachment-circuits": fields.List(fields.Nested(attachment_circuit_model)),
+ "status": fields.String(),
+ "sdp-monitoring": fields.String(),
+ },
+ )
- slice_service_model = slice_ns.model('SliceService', {
- 'id': fields.String(),
- 'description': fields.String(),
- 'service-tags': fields.Nested(slice_ns.model('ServiceTags', {
- 'tag-type': fields.Nested(slice_ns.model('TagType', {
- 'tag-type': fields.String(),
- 'value': fields.String()
- }))
- })),
- 'slo-sle-template': fields.String(),
- 'status': fields.String(),
- 'sdps': fields.Nested(slice_ns.model('Sdps', {
- 'sdp': fields.List(fields.Nested(sdp_model))
- })),
- 'connection-groups': fields.Nested(slice_ns.model('ConnectionGroups', {
- 'connection-group': fields.List(fields.Nested(connection_group_model))
- }))
- })
+ connection_group_model = slice_ns.model(
+ "ConnectionGroup",
+ {
+ "id": fields.String(),
+ "connectivity-type": fields.String(),
+ "connectivity-construct": fields.List(
+ fields.Nested(
+ slice_ns.model(
+ "ConnectivityConstruct",
+ {
+ "id": fields.Integer(),
+ "a2a-sdp": fields.List(
+ fields.Nested(
+ slice_ns.model(
+ "A2ASdp",
+ {"sdp-id": fields.String()},
+ )
+ )
+ ),
+ },
+ )
+ )
+ ),
+ "status": fields.String(),
+ },
+ )
- ietf_network_slice_request_model = slice_ns.model('NetworkSliceService', {
- 'ietf-network-slice-service:network-slice-services': fields.Nested(slice_ns.model('NetworkSliceServices', {
- 'slo-sle-templates': fields.Nested(slice_ns.model('SloSleTemplates', {
- 'slo-sle-template': fields.List(fields.Nested(slo_sle_template_model))
- })),
- 'slice-service': fields.List(fields.Nested(slice_service_model))
- }))
- })
+ slice_service_model = slice_ns.model(
+ "SliceService",
+ {
+ "id": fields.String(),
+ "description": fields.String(),
+ "service-tags": fields.Nested(
+ slice_ns.model(
+ "ServiceTags",
+ {
+ "tag-type": fields.Nested(
+ slice_ns.model(
+ "TagType",
+ {
+ "tag-type": fields.String(),
+ "value": fields.String(),
+ },
+ )
+ )
+ },
+ )
+ ),
+ "slo-sle-template": fields.String(),
+ "status": fields.String(),
+ "sdps": fields.Nested(
+ slice_ns.model(
+ "Sdps",
+ {"sdp": fields.List(fields.Nested(sdp_model))},
+ )
+ ),
+ "connection-groups": fields.Nested(
+ slice_ns.model(
+ "ConnectionGroups",
+ {"connection-group": fields.List(fields.Nested(connection_group_model))},
+ )
+ ),
+ },
+ )
- slice_ddbb_model = slice_ns.model('ddbb_model', {
- 'slice_id': fields.String(),
- 'intent': fields.List(fields.Nested(ietf_network_slice_request_model)),
- 'controller': fields.String()
- })
+ ietf_network_slice_request_model = slice_ns.model(
+ "NetworkSliceService",
+ {
+ "ietf-network-slice-service:network-slice-services": fields.Nested(
+ slice_ns.model(
+ "NetworkSliceServices",
+ {
+ "slo-sle-templates": fields.Nested(
+ slice_ns.model(
+ "SloSleTemplates",
+ {"slo-sle-template": fields.List(fields.Nested(slo_sle_template_model))},
+ )
+ ),
+ "slice-service": fields.List(fields.Nested(slice_service_model)),
+ },
+ )
+ )
+ },
+ )
+ slice_ddbb_model = slice_ns.model(
+ "ddbb_model",
+ {
+ "slice_id": fields.String(),
+ "intent": fields.List(fields.Nested(ietf_network_slice_request_model)),
+ "controller": fields.String(),
+ },
+ )
slice_response_model = slice_ns.model(
"SliceResponse",
@@ -311,32 +385,42 @@ def create_ietf_network_slice_nbi_yang_model(slice_ns):
slice_ns.model(
"SliceDetails",
{
- "id": fields.String(description="Slice ID", example="slice-service-11327140-7361-41b3-aa45-e84a7fb40be9"),
+ "id": fields.String(
+ description="Slice ID",
+ example="slice-service-11327140-7361-41b3-aa45-e84a7fb40be9",
+ ),
"source": fields.String(description="Source IP", example="10.60.11.3"),
- "destination": fields.String(description="Destination IP", example="10.60.60.105"),
+ "destination": fields.String(
+ description="Destination IP", example="10.60.60.105"
+ ),
"vlan": fields.String(description="VLAN ID", example="100"),
"requirements": fields.List(
fields.Nested(
slice_ns.model(
"SliceRequirement",
{
- "constraint_type": fields.String(description="Type of constraint", example="one-way-bandwidth[kbps]"),
- "constraint_value": fields.String(description="Constraint value", example="2000")
- }
+ "constraint_type": fields.String(
+ description="Type of constraint",
+ example="one-way-bandwidth[kbps]",
+ ),
+ "constraint_value": fields.String(
+ description="Constraint value", example="2000"
+ ),
+ },
)
),
- description="List of requirements for the slice"
- )
- }
+ description="List of requirements for the slice",
+ ),
+ },
)
),
- description="List of slices"
+ description="List of slices",
),
"setup_time": fields.Float(description="Slice setup time in milliseconds", example=12.57),
- }
+ },
)
),
- "error": fields.String(description="Error message if request failed", example=None)
- }
+ "error": fields.String(description="Error message if request failed", example=None),
+ },
)
- return slice_ddbb_model, slice_response_model
\ No newline at end of file
+ return slice_ddbb_model, slice_response_model
diff --git a/swagger/models/create_models_restconf.py b/swagger/models/create_models_restconf.py
index 0a4c0ae56ba8d9ccc89e96c3a5de2d7c65d521dd..c983e3c1eddffa46c9919af6dabf45bbeb12987f 100644
--- a/swagger/models/create_models_restconf.py
+++ b/swagger/models/create_models_restconf.py
@@ -14,417 +14,621 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
-from flask_restx import fields
-
-
-def create_ietf_network_slice_nbi_yang_model(slice_ns):
-
-
- # ==============================================
- # Modelos base y reutilizables
- # ==============================================
-
- # Status models
- admin_status_model = slice_ns.model('AdminStatus', {
- 'status': fields.String(description='Administrative service status'),
- 'last-change': fields.String(description='Indicates the actual date and time of the service status change')
- })
-
- oper_status_model = slice_ns.model('OperStatus', {
- 'status': fields.String(description='Operational status'),
- 'last-change': fields.String(description='Indicates the actual date and time of the service status change')
- })
-
- status_model = slice_ns.model('Status', {
- 'admin-status': fields.Nested(admin_status_model, description='Administrative service status'),
- 'oper-status': fields.Nested(oper_status_model, description='Operational service status')
- })
-
- # SLO Policy models
- metric_bound_model = slice_ns.model('MetricBound', {
- 'metric-type': fields.String(required=True, description='Identifies SLO metric type of the Slice Service'),
- 'metric-unit': fields.String(description='The metric unit of the parameter'),
- 'value-description': fields.String(description='The description of the provided value'),
- 'percentile-value': fields.Float(description='The percentile value of the metric type'),
- 'bound': fields.Integer(description='The bound on the Slice Service connection metric')
- })
-
- slo_policy_model = slice_ns.model('SLOPolicy', {
- 'metric-bound': fields.List(fields.Nested(metric_bound_model), description='List of Slice Service metric bounds'),
- 'availability': fields.String(description='Service availability level'),
- 'mtu': fields.Integer(description='Maximum length of Layer 2 data packets of the Slice Service')
- })
-
- # SLE Policy models
- diversity_model = slice_ns.model('Diversity', {
- 'diversity-type': fields.String(description='The type of disjointness on Slice Service')
- })
-
- path_constraints_model = slice_ns.model('PathConstraints', {
- 'service-functions': fields.Raw(description='Container for the policy of service function'),
- 'diversity': fields.Nested(diversity_model, description='Container for the policy of disjointness')
- })
-
- sle_policy_model = slice_ns.model('SLEPolicy', {
- 'security': fields.List(fields.String, description='The security functions'),
- 'isolation': fields.List(fields.String, description='The Slice Service isolation requirement'),
- 'max-occupancy-level': fields.Integer(description='The maximal occupancy level'),
- 'path-constraints': fields.Nested(path_constraints_model, description='Container for the policy of path constraints')
- })
-
- # Combined SLO/SLE policy
- service_slo_sle_policy_model = slice_ns.model('ServiceSLOSLEPolicy', {
- 'description': fields.String(description='Describes the SLO and SLE policy'),
- 'slo-policy': fields.Nested(slo_policy_model, description='Contains the SLO policy'),
- 'sle-policy': fields.Nested(sle_policy_model, description='Contains the SLE policy')
- })
-
- # ==============================================
- # SLO/SLE Template models
- # ==============================================
-
- slo_sle_template_model = slice_ns.model('SLOSLETemplate', {
- 'id': fields.String(required=True, description='Identification of the SLO and SLE template'),
- 'description': fields.String(description='Describes the SLO and SLE policy template'),
- 'template-ref': fields.String(description='The reference to a standard template'),
- 'slo-policy': fields.Nested(slo_policy_model, description='Contains the SLO policy'),
- 'sle-policy': fields.Nested(sle_policy_model, description='Contains the SLE policy')
- })
-
- slo_sle_templates_model = slice_ns.model('SLOSLETemplates', {
- 'slo-sle-template': fields.List(fields.Nested(slo_sle_template_model), description='List for SLO and SLE template identifiers')
- })
-
- # ==============================================
- # QoS Policy models
- # ==============================================
-
- cos_model = slice_ns.model('CoS', {
- 'cos-id': fields.Integer(required=True, description='Identifier of the CoS'),
- 'cir': fields.Integer(description='Committed Information Rate'),
- 'cbs': fields.Integer(description='Committed Burst Size'),
- 'eir': fields.Integer(description='Excess Information Rate'),
- 'ebs': fields.Integer(description='Excess Burst Size'),
- 'pir': fields.Integer(description='Peak Information Rate'),
- 'pbs': fields.Integer(description='Peak Burst Size')
- })
-
- classes_model = slice_ns.model('Classes', {
- 'cos': fields.List(fields.Nested(cos_model), description='List of Class of Services')
- })
-
- rate_limits_model = slice_ns.model('RateLimits', {
- 'cir': fields.Integer(description='Committed Information Rate'),
- 'cbs': fields.Integer(description='Committed Burst Size'),
- 'eir': fields.Integer(description='Excess Information Rate'),
- 'ebs': fields.Integer(description='Excess Burst Size'),
- 'pir': fields.Integer(description='Peak Information Rate'),
- 'pbs': fields.Integer(description='Peak Burst Size'),
- 'classes': fields.Nested(classes_model, description='Container for service class bandwidth control')
- })
-
- qos_policy_model = slice_ns.model('QoSPolicy', {
- 'qos-policy-name': fields.String(description='The name of the QoS policy'),
- 'rate-limits': fields.Nested(rate_limits_model, description='Container for the asymmetric traffic control')
- })
-
- # ==============================================
- # SDP Peering models
- # ==============================================
-
- sdp_peering_model = slice_ns.model('SDPPeering', {
- 'peer-sap-id': fields.List(fields.String, description='Indicates the reference to the remote endpoints'),
- 'protocols': fields.Raw(description='Serves as an augmentation target')
- })
-
- # ==============================================
- # Attachment Circuit models
- # ==============================================
-
- ac_tag_model = slice_ns.model('ACTag', {
- 'tag-type': fields.String(required=True, description='The Attachment Circuit tag type'),
- 'tag-type-value': fields.List(fields.String, description='The Attachment Circuit tag values')
- })
-
- ac_tags_model = slice_ns.model('ACTags', {
- 'ac-tag': fields.List(fields.Nested(ac_tag_model), description='The Attachment Circuit tag list')
- })
-
- attachment_circuit_model = slice_ns.model('AttachmentCircuit', {
- 'id': fields.String(required=True, description='The identifier of Attachment Circuit'),
- 'description': fields.String(description='The Attachment Circuit description'),
- 'ac-svc-ref': fields.String(description='A reference to the AC service'),
- 'ac-node-id': fields.String(description='The Attachment Circuit node ID'),
- 'ac-tp-id': fields.String(description='The termination port ID'),
- 'ac-ipv4-address': fields.String(description='The IPv4 address of the AC'),
- 'ac-ipv4-prefix-length': fields.Integer(description='The length of the IPv4 subnet prefix'),
- 'ac-ipv6-address': fields.String(description='The IPv6 address of the AC'),
- 'ac-ipv6-prefix-length': fields.Integer(description='The length of IPv6 subnet prefix'),
- 'mtu': fields.Integer(description='Maximum size of the Slice Service Layer 2 data packet'),
- 'ac-tags': fields.Nested(ac_tags_model, description='Container for the Attachment Circuit tags'),
- 'incoming-qos-policy': fields.Nested(qos_policy_model, description='The QoS policy imposed on ingress direction'),
- 'outgoing-qos-policy': fields.Nested(qos_policy_model, description='The QoS policy imposed on egress direction'),
- 'sdp-peering': fields.Nested(sdp_peering_model, description='Describes SDP peering attributes'),
- 'status': fields.Nested(status_model, description='Service status')
- })
-
- attachment_circuits_model = slice_ns.model('AttachmentCircuits', {
- 'attachment-circuit': fields.List(fields.Nested(attachment_circuit_model), description='List of Attachment Circuits')
- })
-
- # ==============================================
- # Service Match Criteria models
- # ==============================================
-
- match_type_model = slice_ns.model('MatchType', {
- 'type': fields.String(required=True, description='Indicates the match type of the entry'),
- 'interface-name': fields.List(fields.String, description='Physical interface name for the match criteria'),
- 'vlan': fields.List(fields.Integer, description='VLAN ID value for the match criteria'),
- 'label': fields.List(fields.String, description='MPLS label value for the match criteria'),
- 'ip-prefix': fields.List(fields.String, description='IP prefix value for the match criteria'),
- 'dscp': fields.List(fields.Integer, description='DSCP value for the match criteria'),
- 'acl-name': fields.List(fields.String, description='ACL name value for the match criteria')
- })
-
- match_criterion_model = slice_ns.model('MatchCriterion', {
- 'index': fields.Integer(required=True, description='The identifier of a match criteria'),
- 'match-type': fields.List(fields.Nested(match_type_model), description='List of the Slice Service traffic match types'),
- 'target-connection-group-id': fields.String(description='Reference to the Slice Service Connection Group'),
- 'connection-group-sdp-role': fields.String(description='Specifies the role of SDP in the Connection Group'),
- 'target-connectivity-construct-id': fields.String(description='Reference to a Network Slice Connectivity Construct')
- })
-
- service_match_criteria_model = slice_ns.model('ServiceMatchCriteria', {
- 'match-criterion': fields.List(fields.Nested(match_criterion_model), description='List of the Slice Service traffic match criteria')
- })
-
- # ==============================================
- # SDP Monitoring models
- # ==============================================
-
- sdp_monitoring_model = slice_ns.model('SDPMonitoring', {
- 'incoming-bw-value': fields.Integer(description='Absolute value of the incoming bandwidth'),
- 'incoming-bw-percent': fields.Integer(description='Percentage of the incoming bandwidth'),
- 'outgoing-bw-value': fields.Integer(description='Absolute value of the outgoing bandwidth'),
- 'outgoing-bw-percent': fields.Integer(description='Percentage of the outgoing bandwidth')
- })
-
- # ==============================================
- # Geo Location models
- # ==============================================
-
- velocity_model = slice_ns.model('Velocity', {
- 'v-north': fields.Float(description='Rate of change towards true north'),
- 'v-east': fields.Float(description='Rate of change perpendicular to the right of true north'),
- 'v-up': fields.Float(description='Rate of change away from the center of mass')
- })
-
- geodetic_system_model = slice_ns.model('GeodeticSystem', {
- 'geodetic-datum': fields.String(description='A geodetic-datum defining the meaning of latitude, longitude, and height'),
- 'coord-accuracy': fields.Float(description='The accuracy of the latitude/longitude pair'),
- 'height-accuracy': fields.Float(description='The accuracy of the height value')
- })
-
- reference_frame_model = slice_ns.model('ReferenceFrame', {
- 'alternate-system': fields.String(description='The system in which the astronomical body is defined'),
- 'astronomical-body': fields.String(description='An astronomical body as named by the IAU'),
- 'geodetic-system': fields.Nested(geodetic_system_model, description='The geodetic system of the location data')
- })
-
- geo_location_model = slice_ns.model('GeoLocation', {
- 'reference-frame': fields.Nested(reference_frame_model, description='The Frame of Reference for the location values'),
- 'latitude': fields.Float(description='The latitude value on the astronomical body'),
- 'longitude': fields.Float(description='The longitude value on the astronomical body'),
- 'height': fields.Float(description='Height from a reference 0 value'),
- 'x': fields.Float(description='The X value as defined by the reference-frame'),
- 'y': fields.Float(description='The Y value as defined by the reference-frame'),
- 'z': fields.Float(description='The Z value as defined by the reference-frame'),
- 'velocity': fields.Nested(velocity_model, description='If the object is in motion, the velocity vector'),
- 'timestamp': fields.String(description='Reference time when location was recorded'),
- 'valid-until': fields.String(description='The timestamp for which this geo-location is valid until')
- })
-
- # ==============================================
- # SDP models
- # ==============================================
-
- sdp_model = slice_ns.model('SDP', {
- 'id': fields.String(required=True, description='The unique identifier of the SDP'),
- 'description': fields.String(description='Provides a description of the SDP'),
- 'geo-location': fields.Nested(geo_location_model, description='A location on an astronomical body'),
- 'node-id': fields.String(description='A unique identifier of an edge node of the SDP'),
- 'sdp-ip-address': fields.List(fields.String, description='IPv4 or IPv6 address of the SDP'),
- 'tp-ref': fields.String(description='A reference to Termination Point in the custom topology'),
- 'service-match-criteria': fields.Nested(service_match_criteria_model, description='Describes the Slice Service match criteria'),
- 'incoming-qos-policy': fields.Nested(qos_policy_model, description='The QoS policy imposed on ingress direction'),
- 'outgoing-qos-policy': fields.Nested(qos_policy_model, description='The QoS policy imposed on egress direction'),
- 'sdp-peering': fields.Nested(sdp_peering_model, description='Describes SDP peering attributes'),
- 'ac-svc-ref': fields.List(fields.String, description='A reference to the ACs'),
- 'ce-mode': fields.Boolean(description='When true, this indicates the SDP is located on the CE'),
- 'attachment-circuits': fields.Nested(attachment_circuits_model, description='List of Attachment Circuits'),
- 'status': fields.Nested(status_model, description='Service status'),
- 'sdp-monitoring': fields.Nested(sdp_monitoring_model, description='Container for SDP monitoring metrics')
- })
-
- sdps_model = slice_ns.model('SDPs', {
- 'sdp': fields.List(fields.Nested(sdp_model), description='List of SDPs in this Slice Service')
- })
-
- # ==============================================
- # Connectivity Construct models
- # ==============================================
-
- connectivity_construct_monitoring_model = slice_ns.model('ConnectivityConstructMonitoring', {
- 'one-way-min-delay': fields.Integer(description='One-way minimum delay or latency'),
- 'one-way-max-delay': fields.Integer(description='One-way maximum delay or latency'),
- 'one-way-delay-variation': fields.Integer(description='One-way delay variation'),
- 'one-way-packet-loss': fields.Float(description='The ratio of packets dropped to packets transmitted'),
- 'two-way-min-delay': fields.Integer(description='Two-way minimum delay or latency'),
- 'two-way-max-delay': fields.Integer(description='Two-way maximum delay or latency'),
- 'two-way-delay-variation': fields.Integer(description='Two-way delay variation'),
- 'two-way-packet-loss': fields.Float(description='The ratio of packets dropped to packets transmitted')
- })
-
- a2a_sdp_model = slice_ns.model('A2ASDP', {
- 'sdp-id': fields.String(required=True, description='Reference to an SDP'),
- 'slo-sle-template': fields.String(description='Standard SLO and SLE template to be used'),
- 'service-slo-sle-policy': fields.Nested(service_slo_sle_policy_model, description='Contains the SLO and SLE policy')
- })
-
- connectivity_construct_model = slice_ns.model('ConnectivityConstruct', {
- 'id': fields.String(required=True, description='The Connectivity Construct identifier'),
- 'p2p-sender-sdp': fields.String(description='Reference to a sender SDP'),
- 'p2p-receiver-sdp': fields.String(description='Reference to a receiver SDP'),
- 'p2mp-sender-sdp': fields.String(description='Reference to a sender SDP'),
- 'p2mp-receiver-sdp': fields.List(fields.String, description='Reference to a receiver SDP'),
- 'a2a-sdp': fields.List(fields.Nested(a2a_sdp_model), description='List of included A2A SDPs'),
- 'slo-sle-template': fields.String(description='Standard SLO and SLE template to be used'),
- 'service-slo-sle-policy': fields.Nested(service_slo_sle_policy_model, description='Contains the SLO and SLE policy'),
- 'service-slo-sle-policy-override': fields.String(description='SLO/SLE policy override option'),
- 'status': fields.Nested(status_model, description='Service status'),
- 'connectivity-construct-monitoring': fields.Nested(connectivity_construct_monitoring_model, description='SLO status per Connectivity Construct')
- })
-
- # ==============================================
- # Connection Group models
- # ==============================================
-
- connection_group_monitoring_model = slice_ns.model('ConnectionGroupMonitoring', {
- 'one-way-min-delay': fields.Integer(description='One-way minimum delay or latency'),
- 'one-way-max-delay': fields.Integer(description='One-way maximum delay or latency'),
- 'one-way-delay-variation': fields.Integer(description='One-way delay variation'),
- 'one-way-packet-loss': fields.Float(description='The ratio of packets dropped to packets transmitted'),
- 'two-way-min-delay': fields.Integer(description='Two-way minimum delay or latency'),
- 'two-way-max-delay': fields.Integer(description='Two-way maximum delay or latency'),
- 'two-way-delay-variation': fields.Integer(description='Two-way delay variation'),
- 'two-way-packet-loss': fields.Float(description='The ratio of packets dropped to packets transmitted')
- })
-
- connection_group_model = slice_ns.model('ConnectionGroup', {
- 'id': fields.String(required=True, description='The Connection Group identifier'),
- 'connectivity-type': fields.String(description='Connection Group connectivity type'),
- 'slo-sle-template': fields.String(description='Standard SLO and SLE template to be used'),
- 'service-slo-sle-policy': fields.Nested(service_slo_sle_policy_model, description='Contains the SLO and SLE policy'),
- 'service-slo-sle-policy-override': fields.String(description='SLO/SLE policy override option'),
- 'connectivity-construct': fields.List(fields.Nested(connectivity_construct_model), description='List of Connectivity Constructs'),
- 'connection-group-monitoring': fields.Nested(connection_group_monitoring_model, description='SLO status per Connection Group')
- })
-
- connection_groups_model = slice_ns.model('ConnectionGroups', {
- 'connection-group': fields.List(fields.Nested(connection_group_model), description='List of Connection Groups')
- })
-
- # ==============================================
- # Service Tags models
- # ==============================================
-
- tag_type_model = slice_ns.model('TagType', {
- 'tag-type': fields.String(required=True, description='Slice Service tag type'),
- 'tag-type-value': fields.List(fields.String, description='The tag values')
- })
-
- service_tags_model = slice_ns.model('ServiceTags', {
- 'tag-type': fields.List(fields.Nested(tag_type_model), description='The service tag list')
- })
-
- # ==============================================
- # Custom Topology models
- # ==============================================
-
- custom_topology_model = slice_ns.model('CustomTopology', {
- 'network-ref': fields.String(description='Used to reference a network')
- })
-
- # ==============================================
- # Slice Service models
- # ==============================================
-
- slice_service_model = slice_ns.model('SliceService', {
- 'id': fields.String(required=True, description='A unique Slice Service identifier within an NSC'),
- 'description': fields.String(description='Textual description of the Slice Service'),
- 'service-tags': fields.Nested(service_tags_model, description='Container for a list of service tags'),
- 'slo-sle-template': fields.String(description='Standard SLO and SLE template to be used'),
- 'service-slo-sle-policy': fields.Nested(service_slo_sle_policy_model, description='Contains the SLO and SLE policy'),
- 'test-only': fields.String(description='When present, this is a feasibility check'),
- 'status': fields.Nested(status_model, description='Service status'),
- 'sdps': fields.Nested(sdps_model, description='Slice Service SDPs'),
- 'connection-groups': fields.Nested(connection_groups_model, description='Contains Connection Groups'),
- 'custom-topology': fields.Nested(custom_topology_model, description='Container for custom topology')
- })
-
- # ==============================================
- # Root model: Network Slice Services
- # ==============================================
-
- network_slice_services_model = slice_ns.model('NetworkSliceServices', {
- 'ietf-network-slice-service:network-slice-services': fields.Nested(slice_ns.model('NetworkSliceServicesContainer', {
- 'slo-sle-templates': fields.Nested(slo_sle_templates_model, description='Contains a set of Slice Service templates'),
- 'slice-service': fields.List(fields.Nested(slice_service_model), description='A Slice Service is identified by a service id')
- }), description='Contains a list of Network Slice Services')
- })
-
- # ------------------------------------------------------------------
- # API responses
- # ------------------------------------------------------------------
+"""Swagger model builders for RESTCONF IETF Network Slice Service YANG model."""
+
+from __future__ import annotations
+
+from flask_restx import Model, Namespace, fields
+
+
+def create_ietf_network_slice_nbi_yang_model(
+ slice_ns: Namespace,
+) -> tuple[Model, Model, Model, Model, Model, Model, Model, Model]:
+ """Create and register RESTCONF IETF Network Slice NBI YANG models.
+
+ Args:
+ slice_ns: Target Flask-RESTX namespace.
+
+ Returns:
+ Tuple of (network_slice_services_model, slo_sle_template_model,
+ slice_service_model, sdp_model, slice_response_model,
+ slice_service_response, slo_sle_template_response, sdp_response).
+ """
+ # =========================================================================
+ # Status Models
+ # =========================================================================
+
+ admin_status_model = slice_ns.model(
+ "AdminStatus",
+ {
+ "status": fields.String(description="Administrative service status"),
+ "last-change": fields.String(description="Indicates the actual date and time of the service status change"),
+ },
+ )
+
+ oper_status_model = slice_ns.model(
+ "OperStatus",
+ {
+ "status": fields.String(description="Operational status"),
+ "last-change": fields.String(description="Indicates the actual date and time of the service status change"),
+ },
+ )
+
+ status_model = slice_ns.model(
+ "Status",
+ {
+ "admin-status": fields.Nested(admin_status_model, description="Administrative service status"),
+ "oper-status": fields.Nested(oper_status_model, description="Operational service status"),
+ },
+ )
+
+ # =========================================================================
+ # SLO/SLE Policy Models
+ # =========================================================================
+
+ metric_bound_model = slice_ns.model(
+ "MetricBound",
+ {
+ "metric-type": fields.String(required=True, description="Identifies SLO metric type of the Slice Service"),
+ "metric-unit": fields.String(description="The metric unit of the parameter"),
+ "value-description": fields.String(description="The description of the provided value"),
+ "percentile-value": fields.Float(description="The percentile value of the metric type"),
+ "bound": fields.Integer(description="The bound on the Slice Service connection metric"),
+ },
+ )
+
+ slo_policy_model = slice_ns.model(
+ "SLOPolicy",
+ {
+ "metric-bound": fields.List(
+ fields.Nested(metric_bound_model), description="List of Slice Service metric bounds"
+ ),
+ "availability": fields.String(description="Service availability level"),
+ "mtu": fields.Integer(description="Maximum length of Layer 2 data packets of the Slice Service"),
+ },
+ )
+
+ diversity_model = slice_ns.model(
+ "Diversity",
+ {
+ "diversity-type": fields.String(description="The type of disjointness on Slice Service"),
+ },
+ )
+
+ path_constraints_model = slice_ns.model(
+ "PathConstraints",
+ {
+ "service-functions": fields.Raw(description="Container for the policy of service function"),
+ "diversity": fields.Nested(diversity_model, description="Container for the policy of disjointness"),
+ },
+ )
+
+ sle_policy_model = slice_ns.model(
+ "SLEPolicy",
+ {
+ "security": fields.List(fields.String, description="The security functions"),
+ "isolation": fields.List(fields.String, description="The Slice Service isolation requirement"),
+ "max-occupancy-level": fields.Integer(description="The maximal occupancy level"),
+ "path-constraints": fields.Nested(
+ path_constraints_model, description="Container for the policy of path constraints"
+ ),
+ },
+ )
+
+ service_slo_sle_policy_model = slice_ns.model(
+ "ServiceSLOSLEPolicy",
+ {
+ "description": fields.String(description="Describes the SLO and SLE policy"),
+ "slo-policy": fields.Nested(slo_policy_model, description="Contains the SLO policy"),
+ "sle-policy": fields.Nested(sle_policy_model, description="Contains the SLE policy"),
+ },
+ )
+
+ # =========================================================================
+ # SLO/SLE Template Models
+ # =========================================================================
+
+ slo_sle_template_model = slice_ns.model(
+ "SLOSLETemplate",
+ {
+ "id": fields.String(required=True, description="Identification of the SLO and SLE template"),
+ "description": fields.String(description="Describes the SLO and SLE policy template"),
+ "template-ref": fields.String(description="The reference to a standard template"),
+ "slo-policy": fields.Nested(slo_policy_model, description="Contains the SLO policy"),
+ "sle-policy": fields.Nested(sle_policy_model, description="Contains the SLE policy"),
+ },
+ )
+
+ slo_sle_templates_model = slice_ns.model(
+ "SLOSLETemplates",
+ {
+ "slo-sle-template": fields.List(
+ fields.Nested(slo_sle_template_model), description="List for SLO and SLE template identifiers"
+ ),
+ },
+ )
+
+ # =========================================================================
+ # QoS Policy Models
+ # =========================================================================
+
+ cos_model = slice_ns.model(
+ "CoS",
+ {
+ "cos-id": fields.Integer(required=True, description="Identifier of the CoS"),
+ "cir": fields.Integer(description="Committed Information Rate"),
+ "cbs": fields.Integer(description="Committed Burst Size"),
+ "eir": fields.Integer(description="Excess Information Rate"),
+ "ebs": fields.Integer(description="Excess Burst Size"),
+ "pir": fields.Integer(description="Peak Information Rate"),
+ "pbs": fields.Integer(description="Peak Burst Size"),
+ },
+ )
+
+ classes_model = slice_ns.model(
+ "Classes",
+ {
+ "cos": fields.List(fields.Nested(cos_model), description="List of Class of Services"),
+ },
+ )
+
+ rate_limits_model = slice_ns.model(
+ "RateLimits",
+ {
+ "cir": fields.Integer(description="Committed Information Rate"),
+ "cbs": fields.Integer(description="Committed Burst Size"),
+ "eir": fields.Integer(description="Excess Information Rate"),
+ "ebs": fields.Integer(description="Excess Burst Size"),
+ "pir": fields.Integer(description="Peak Information Rate"),
+ "pbs": fields.Integer(description="Peak Burst Size"),
+ "classes": fields.Nested(classes_model, description="Container for service class bandwidth control"),
+ },
+ )
+
+ qos_policy_model = slice_ns.model(
+ "QoSPolicy",
+ {
+ "qos-policy-name": fields.String(description="The name of the QoS policy"),
+ "rate-limits": fields.Nested(rate_limits_model, description="Container for the asymmetric traffic control"),
+ },
+ )
+
+ # =========================================================================
+ # SDP Peering Models
+ # =========================================================================
+
+ sdp_peering_model = slice_ns.model(
+ "SDPPeering",
+ {
+ "peer-sap-id": fields.List(fields.String, description="Indicates the reference to the remote endpoints"),
+ "protocols": fields.Raw(description="Serves as an augmentation target"),
+ },
+ )
+
+ # =========================================================================
+ # Attachment Circuit Models
+ # =========================================================================
+
+ ac_tag_model = slice_ns.model(
+ "ACTag",
+ {
+ "tag-type": fields.String(required=True, description="The Attachment Circuit tag type"),
+ "tag-type-value": fields.List(fields.String, description="The Attachment Circuit tag values"),
+ },
+ )
+
+ ac_tags_model = slice_ns.model(
+ "ACTags",
+ {
+ "ac-tag": fields.List(fields.Nested(ac_tag_model), description="The Attachment Circuit tag list"),
+ },
+ )
+
+ attachment_circuit_model = slice_ns.model(
+ "AttachmentCircuit",
+ {
+ "id": fields.String(required=True, description="The identifier of Attachment Circuit"),
+ "description": fields.String(description="The Attachment Circuit description"),
+ "ac-svc-ref": fields.String(description="A reference to the AC service"),
+ "ac-node-id": fields.String(description="The Attachment Circuit node ID"),
+ "ac-tp-id": fields.String(description="The termination port ID"),
+ "ac-ipv4-address": fields.String(description="The IPv4 address of the AC"),
+ "ac-ipv4-prefix-length": fields.Integer(description="The length of the IPv4 subnet prefix"),
+ "ac-ipv6-address": fields.String(description="The IPv6 address of the AC"),
+ "ac-ipv6-prefix-length": fields.Integer(description="The length of IPv6 subnet prefix"),
+ "mtu": fields.Integer(description="Maximum size of the Slice Service Layer 2 data packet"),
+ "ac-tags": fields.Nested(ac_tags_model, description="Container for the Attachment Circuit tags"),
+ "incoming-qos-policy": fields.Nested(
+ qos_policy_model, description="The QoS policy imposed on ingress direction"
+ ),
+ "outgoing-qos-policy": fields.Nested(
+ qos_policy_model, description="The QoS policy imposed on egress direction"
+ ),
+ "sdp-peering": fields.Nested(sdp_peering_model, description="Describes SDP peering attributes"),
+ "status": fields.Nested(status_model, description="Service status"),
+ },
+ )
+
+ attachment_circuits_model = slice_ns.model(
+ "AttachmentCircuits",
+ {
+ "attachment-circuit": fields.List(
+ fields.Nested(attachment_circuit_model), description="List of Attachment Circuits"
+ ),
+ },
+ )
+
+ # =========================================================================
+ # Service Match Criteria Models
+ # =========================================================================
+
+ match_type_model = slice_ns.model(
+ "MatchType",
+ {
+ "type": fields.String(required=True, description="Indicates the match type of the entry"),
+ "interface-name": fields.List(fields.String, description="Physical interface name for the match criteria"),
+ "vlan": fields.List(fields.Integer, description="VLAN ID value for the match criteria"),
+ "label": fields.List(fields.String, description="MPLS label value for the match criteria"),
+ "ip-prefix": fields.List(fields.String, description="IP prefix value for the match criteria"),
+ "dscp": fields.List(fields.Integer, description="DSCP value for the match criteria"),
+ "acl-name": fields.List(fields.String, description="ACL name value for the match criteria"),
+ },
+ )
+
+ match_criterion_model = slice_ns.model(
+ "MatchCriterion",
+ {
+ "index": fields.Integer(required=True, description="The identifier of a match criteria"),
+ "match-type": fields.List(
+ fields.Nested(match_type_model), description="List of the Slice Service traffic match types"
+ ),
+ "target-connection-group-id": fields.String(description="Reference to the Slice Service Connection Group"),
+ "connection-group-sdp-role": fields.String(description="Specifies the role of SDP in the Connection Group"),
+ "target-connectivity-construct-id": fields.String(
+ description="Reference to a Network Slice Connectivity Construct"
+ ),
+ },
+ )
+
+ service_match_criteria_model = slice_ns.model(
+ "ServiceMatchCriteria",
+ {
+ "match-criterion": fields.List(
+ fields.Nested(match_criterion_model), description="List of the Slice Service traffic match criteria"
+ ),
+ },
+ )
+
+ # =========================================================================
+ # SDP Monitoring Models
+ # =========================================================================
+
+ sdp_monitoring_model = slice_ns.model(
+ "SDPMonitoring",
+ {
+ "incoming-bw-value": fields.Integer(description="Absolute value of the incoming bandwidth"),
+ "incoming-bw-percent": fields.Integer(description="Percentage of the incoming bandwidth"),
+ "outgoing-bw-value": fields.Integer(description="Absolute value of the outgoing bandwidth"),
+ "outgoing-bw-percent": fields.Integer(description="Percentage of the outgoing bandwidth"),
+ },
+ )
+
+ # =========================================================================
+ # Geo Location Models
+ # =========================================================================
+
+ velocity_model = slice_ns.model(
+ "Velocity",
+ {
+ "v-north": fields.Float(description="Rate of change towards true north"),
+ "v-east": fields.Float(description="Rate of change perpendicular to the right of true north"),
+ "v-up": fields.Float(description="Rate of change away from the center of mass"),
+ },
+ )
+
+ geodetic_system_model = slice_ns.model(
+ "GeodeticSystem",
+ {
+ "geodetic-datum": fields.String(
+ description="A geodetic-datum defining the meaning of latitude, longitude, and height"
+ ),
+ "coord-accuracy": fields.Float(description="The accuracy of the latitude/longitude pair"),
+ "height-accuracy": fields.Float(description="The accuracy of the height value"),
+ },
+ )
+
+ reference_frame_model = slice_ns.model(
+ "ReferenceFrame",
+ {
+ "alternate-system": fields.String(description="The system in which the astronomical body is defined"),
+ "astronomical-body": fields.String(description="An astronomical body as named by the IAU"),
+ "geodetic-system": fields.Nested(
+ geodetic_system_model, description="The geodetic system of the location data"
+ ),
+ },
+ )
+
+ geo_location_model = slice_ns.model(
+ "GeoLocation",
+ {
+ "reference-frame": fields.Nested(
+ reference_frame_model, description="The Frame of Reference for the location values"
+ ),
+ "latitude": fields.Float(description="The latitude value on the astronomical body"),
+ "longitude": fields.Float(description="The longitude value on the astronomical body"),
+ "height": fields.Float(description="Height from a reference 0 value"),
+ "x": fields.Float(description="The X value as defined by the reference-frame"),
+ "y": fields.Float(description="The Y value as defined by the reference-frame"),
+ "z": fields.Float(description="The Z value as defined by the reference-frame"),
+ "velocity": fields.Nested(velocity_model, description="If the object is in motion, the velocity vector"),
+ "timestamp": fields.String(description="Reference time when location was recorded"),
+ "valid-until": fields.String(description="The timestamp for which this geo-location is valid until"),
+ },
+ )
+
+ # =========================================================================
+ # SDP Models
+ # =========================================================================
+
+ sdp_model = slice_ns.model(
+ "SDP",
+ {
+ "id": fields.String(required=True, description="The unique identifier of the SDP"),
+ "description": fields.String(description="Provides a description of the SDP"),
+ "geo-location": fields.Nested(geo_location_model, description="A location on an astronomical body"),
+ "node-id": fields.String(description="A unique identifier of an edge node of the SDP"),
+ "sdp-ip-address": fields.List(fields.String, description="IPv4 or IPv6 address of the SDP"),
+ "tp-ref": fields.String(description="A reference to Termination Point in the custom topology"),
+ "service-match-criteria": fields.Nested(
+ service_match_criteria_model, description="Describes the Slice Service match criteria"
+ ),
+ "incoming-qos-policy": fields.Nested(
+ qos_policy_model, description="The QoS policy imposed on ingress direction"
+ ),
+ "outgoing-qos-policy": fields.Nested(
+ qos_policy_model, description="The QoS policy imposed on egress direction"
+ ),
+ "sdp-peering": fields.Nested(sdp_peering_model, description="Describes SDP peering attributes"),
+ "ac-svc-ref": fields.List(fields.String, description="A reference to the ACs"),
+ "ce-mode": fields.Boolean(description="When true, this indicates the SDP is located on the CE"),
+ "attachment-circuits": fields.Nested(attachment_circuits_model, description="List of Attachment Circuits"),
+ "status": fields.Nested(status_model, description="Service status"),
+ "sdp-monitoring": fields.Nested(sdp_monitoring_model, description="Container for SDP monitoring metrics"),
+ },
+ )
+
+ sdps_model = slice_ns.model(
+ "SDPs",
+ {
+ "sdp": fields.List(fields.Nested(sdp_model), description="List of SDPs in this Slice Service"),
+ },
+ )
+
+ # =========================================================================
+ # Connectivity Construct Models
+ # =========================================================================
+
+ connectivity_construct_monitoring_model = slice_ns.model(
+ "ConnectivityConstructMonitoring",
+ {
+ "one-way-min-delay": fields.Integer(description="One-way minimum delay or latency"),
+ "one-way-max-delay": fields.Integer(description="One-way maximum delay or latency"),
+ "one-way-delay-variation": fields.Integer(description="One-way delay variation"),
+ "one-way-packet-loss": fields.Float(description="The ratio of packets dropped to packets transmitted"),
+ "two-way-min-delay": fields.Integer(description="Two-way minimum delay or latency"),
+ "two-way-max-delay": fields.Integer(description="Two-way maximum delay or latency"),
+ "two-way-delay-variation": fields.Integer(description="Two-way delay variation"),
+ "two-way-packet-loss": fields.Float(description="The ratio of packets dropped to packets transmitted"),
+ },
+ )
+
+ a2a_sdp_model = slice_ns.model(
+ "A2ASDP",
+ {
+ "sdp-id": fields.String(required=True, description="Reference to an SDP"),
+ "slo-sle-template": fields.String(description="Standard SLO and SLE template to be used"),
+ "service-slo-sle-policy": fields.Nested(
+ service_slo_sle_policy_model, description="Contains the SLO and SLE policy"
+ ),
+ },
+ )
+
+ connectivity_construct_model = slice_ns.model(
+ "ConnectivityConstruct",
+ {
+ "id": fields.String(required=True, description="The Connectivity Construct identifier"),
+ "p2p-sender-sdp": fields.String(description="Reference to a sender SDP"),
+ "p2p-receiver-sdp": fields.String(description="Reference to a receiver SDP"),
+ "p2mp-sender-sdp": fields.String(description="Reference to a sender SDP"),
+ "p2mp-receiver-sdp": fields.List(fields.String, description="Reference to a receiver SDP"),
+ "a2a-sdp": fields.List(fields.Nested(a2a_sdp_model), description="List of included A2A SDPs"),
+ "slo-sle-template": fields.String(description="Standard SLO and SLE template to be used"),
+ "service-slo-sle-policy": fields.Nested(
+ service_slo_sle_policy_model, description="Contains the SLO and SLE policy"
+ ),
+ "service-slo-sle-policy-override": fields.String(description="SLO/SLE policy override option"),
+ "status": fields.Nested(status_model, description="Service status"),
+ "connectivity-construct-monitoring": fields.Nested(
+ connectivity_construct_monitoring_model, description="SLO status per Connectivity Construct"
+ ),
+ },
+ )
+
+ # =========================================================================
+ # Connection Group Models
+ # =========================================================================
+
+ connection_group_monitoring_model = slice_ns.model(
+ "ConnectionGroupMonitoring",
+ {
+ "one-way-min-delay": fields.Integer(description="One-way minimum delay or latency"),
+ "one-way-max-delay": fields.Integer(description="One-way maximum delay or latency"),
+ "one-way-delay-variation": fields.Integer(description="One-way delay variation"),
+ "one-way-packet-loss": fields.Float(description="The ratio of packets dropped to packets transmitted"),
+ "two-way-min-delay": fields.Integer(description="Two-way minimum delay or latency"),
+ "two-way-max-delay": fields.Integer(description="Two-way maximum delay or latency"),
+ "two-way-delay-variation": fields.Integer(description="Two-way delay variation"),
+ "two-way-packet-loss": fields.Float(description="The ratio of packets dropped to packets transmitted"),
+ },
+ )
+
+ connection_group_model = slice_ns.model(
+ "ConnectionGroup",
+ {
+ "id": fields.String(required=True, description="The Connection Group identifier"),
+ "connectivity-type": fields.String(description="Connection Group connectivity type"),
+ "slo-sle-template": fields.String(description="Standard SLO and SLE template to be used"),
+ "service-slo-sle-policy": fields.Nested(
+ service_slo_sle_policy_model, description="Contains the SLO and SLE policy"
+ ),
+ "service-slo-sle-policy-override": fields.String(description="SLO/SLE policy override option"),
+ "connectivity-construct": fields.List(
+ fields.Nested(connectivity_construct_model), description="List of Connectivity Constructs"
+ ),
+ "connection-group-monitoring": fields.Nested(
+ connection_group_monitoring_model, description="SLO status per Connection Group"
+ ),
+ },
+ )
+
+ connection_groups_model = slice_ns.model(
+ "ConnectionGroups",
+ {
+ "connection-group": fields.List(
+ fields.Nested(connection_group_model), description="List of Connection Groups"
+ ),
+ },
+ )
+
+ # =========================================================================
+ # Service Tags Models
+ # =========================================================================
+
+ tag_type_model = slice_ns.model(
+ "TagType",
+ {
+ "tag-type": fields.String(required=True, description="Slice Service tag type"),
+ "tag-type-value": fields.List(fields.String, description="The tag values"),
+ },
+ )
+
+ service_tags_model = slice_ns.model(
+ "ServiceTags",
+ {
+ "tag-type": fields.List(fields.Nested(tag_type_model), description="The service tag list"),
+ },
+ )
+
+ # =========================================================================
+ # Custom Topology Models
+ # =========================================================================
+
+ custom_topology_model = slice_ns.model(
+ "CustomTopology",
+ {
+ "network-ref": fields.String(description="Used to reference a network"),
+ },
+ )
+
+ # =========================================================================
+ # Slice Service Models
+ # =========================================================================
+
+ slice_service_model = slice_ns.model(
+ "SliceService",
+ {
+ "id": fields.String(required=True, description="A unique Slice Service identifier within an NSC"),
+ "description": fields.String(description="Textual description of the Slice Service"),
+ "service-tags": fields.Nested(service_tags_model, description="Container for a list of service tags"),
+ "slo-sle-template": fields.String(description="Standard SLO and SLE template to be used"),
+ "service-slo-sle-policy": fields.Nested(
+ service_slo_sle_policy_model, description="Contains the SLO and SLE policy"
+ ),
+ "test-only": fields.String(description="When present, this is a feasibility check"),
+ "status": fields.Nested(status_model, description="Service status"),
+ "sdps": fields.Nested(sdps_model, description="Slice Service SDPs"),
+ "connection-groups": fields.Nested(connection_groups_model, description="Contains Connection Groups"),
+ "custom-topology": fields.Nested(custom_topology_model, description="Container for custom topology"),
+ },
+ )
+
+ # =========================================================================
+ # Root Model: Network Slice Services
+ # =========================================================================
+
+ network_slice_services_model = slice_ns.model(
+ "NetworkSliceServices",
+ {
+ "ietf-network-slice-service:network-slice-services": fields.Nested(
+ slice_ns.model(
+ "NetworkSliceServicesContainer",
+ {
+ "slo-sle-templates": fields.Nested(
+ slo_sle_templates_model, description="Contains a set of Slice Service templates"
+ ),
+ "slice-service": fields.List(
+ fields.Nested(slice_service_model),
+ description="A Slice Service is identified by a service id",
+ ),
+ },
+ ),
+ description="Contains a list of Network Slice Services",
+ ),
+ },
+ )
+
+ # =========================================================================
+ # Response Models
+ # =========================================================================
slice_service_response = slice_ns.model(
- 'SliceServiceResponse',
+ "SliceServiceResponse",
{
- 'ietf-network-slice-service:network-slice-services':
- fields.Nested(slice_service_model)
- }
+ "ietf-network-slice-service:network-slice-services": fields.Nested(slice_service_model),
+ },
)
slo_sle_template_response = slice_ns.model(
- 'SloSleTemplateResponse',
+ "SloSleTemplateResponse",
{
- 'ietf-network-slice-service:network-slice-services':
- fields.Nested(
- slice_ns.model('NetworkSliceServicesTemplatesOnly', {
- 'slo-sle-templates': fields.Nested(slo_sle_template_model)
- })
+ "ietf-network-slice-service:network-slice-services": fields.Nested(
+ slice_ns.model(
+ "NetworkSliceServicesTemplatesOnly",
+ {
+ "slo-sle-templates": fields.Nested(slo_sle_template_model),
+ },
)
- }
+ ),
+ },
)
- sdp_response = slice_ns.model('SdpResponse',
+ sdp_response = slice_ns.model(
+ "SdpResponse",
{
- 'ietf-network-slice-service:network-slice-services':
- fields.Nested(
- slice_ns.model('NetworkSliceServicesSdpsOnly', {
- 'slice-service': fields.List(fields.Nested(
- slice_ns.model('SliceServiceSdpsOnly', {
- 'id': fields.String(),
- 'sdps': fields.Nested(sdps_model)
- })
- ))
- })
+ "ietf-network-slice-service:network-slice-services": fields.Nested(
+ slice_ns.model(
+ "NetworkSliceServicesSdpsOnly",
+ {
+ "slice-service": fields.List(
+ fields.Nested(
+ slice_ns.model(
+ "SliceServiceSdpsOnly",
+ {
+ "id": fields.String(),
+ "sdps": fields.Nested(sdps_model),
+ },
+ )
+ )
+ ),
+ },
)
- })
+ ),
+ },
+ )
- # ------------------------------------------------------------------
- # Custom slice response (non-IETF)
- # ------------------------------------------------------------------
+ # =========================================================================
+ # Custom Slice Response (non-IETF)
+ # =========================================================================
slice_response_model = slice_ns.model(
"SliceResponse",
@@ -449,21 +653,30 @@ def create_ietf_network_slice_nbi_yang_model(slice_ns):
"SliceRequirement",
{
"constraint_type": fields.String(),
- "constraint_value": fields.String()
- }
+ "constraint_value": fields.String(),
+ },
)
)
- )
- }
+ ),
+ },
)
)
),
- "setup_time": fields.Float()
- }
+ "setup_time": fields.Float(),
+ },
)
),
- "error": fields.String()
- }
+ "error": fields.String(),
+ },
)
- return network_slice_services_model, slo_sle_template_model, slice_service_model, sdp_model, slice_response_model, slice_service_response, slo_sle_template_response, sdp_response
\ No newline at end of file
+ return (
+ network_slice_services_model,
+ slo_sle_template_model,
+ slice_service_model,
+ sdp_model,
+ slice_response_model,
+ slice_service_response,
+ slo_sle_template_response,
+ sdp_response,
+ )
diff --git a/swagger/restconf_namespace.py b/swagger/restconf_namespace.py
index 423972bbd2a336761ac9f71ef4e74190dfc98b59..67be0365fdf6cfd9fd42cf056a749f2b4d50d42b 100644
--- a/swagger/restconf_namespace.py
+++ b/swagger/restconf_namespace.py
@@ -14,392 +14,524 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
+"""RESTCONF namespace definitions and resource controllers for IETF Network Slice Service."""
+
+from __future__ import annotations
+
import logging
-from flask import request, Response, request, current_app, stream_with_context
+from typing import Any
+
+from flask import Response, current_app, request, stream_with_context
from flask_restx import Namespace, Resource, fields
+
+from src.api.main import Api as RestconfHandler
from src.main import NSController
-from src.api.main import Api
-from swagger.models.create_models_restconf import create_ietf_network_slice_nbi_yang_model
+from swagger.models.create_models_restconf import (
+ create_ietf_network_slice_nbi_yang_model,
+)
+
+logger = logging.getLogger(__name__)
# Namespace
restconf_ns = Namespace(
"restconf",
- description="RESTCONF operations for IETF Network Slice Service YANG model"
+ description="RESTCONF operations for IETF Network Slice Service YANG model",
)
-network_slice_services_model, slo_sle_template_model, slice_service_model, sdp_model, slice_response_model, slice_service_response, slo_sle_template_response, sdp_response = create_ietf_network_slice_nbi_yang_model(restconf_ns)
+
+(
+ network_slice_services_model,
+ slo_sle_template_model,
+ slice_service_model,
+ sdp_model,
+ slice_response_model,
+ slice_service_response,
+ slo_sle_template_response,
+ sdp_response,
+) = create_ietf_network_slice_nbi_yang_model(restconf_ns)
+
+
+# =============================================================================
+# Network Slice Services Root
+# =============================================================================
+
@restconf_ns.route("/data/ietf-network-slice-service:network-slice-services")
class NetworkSliceServices(Resource):
+ """Resource for root network slice services container."""
@restconf_ns.doc(summary="Contains a list of Network Slice Services")
@restconf_ns.response(200, "Network slice services returned", network_slice_services_model)
@restconf_ns.response(404, "Nothing found")
@restconf_ns.response(500, "Internal server error")
- def get(self):
+ def get(self) -> tuple[dict[str, Any], int]:
+ """Retrieve all network slice services."""
controller = NSController(controller_type="RESTCONF")
- return Api(controller).get_network_slice_services()
+ return RestconfHandler(controller).get_network_slice_services()
@restconf_ns.doc(summary="Create Network Slice Services")
@restconf_ns.expect(network_slice_services_model)
@restconf_ns.response(201, "Container network-slice-services created", slice_response_model)
@restconf_ns.response(500, "Internal server error")
- def post(self):
+ def post(self) -> tuple[dict[str, Any], int]:
+ """Create network slice services."""
json_data = request.get_json()
controller = NSController(controller_type="RESTCONF")
- return Api(controller).add_network_slice_service(json_data)
+ return RestconfHandler(controller).add_network_slice_service(json_data)
@restconf_ns.doc(summary="Update Network Slice Services")
@restconf_ns.expect(network_slice_services_model)
@restconf_ns.response(200, "Container network-slice-services updated", slice_response_model)
@restconf_ns.response(404, "Nothing found to update")
@restconf_ns.response(500, "Internal server error")
- def put(self):
+ def put(self) -> tuple[dict[str, Any], int]:
+ """Update network slice services."""
json_data = request.get_json()
controller = NSController(controller_type="RESTCONF")
- return Api(controller).update_network_slice_service(json_data)
+ return RestconfHandler(controller).update_network_slice_service(json_data)
@restconf_ns.doc(summary="Delete Network Slice Services")
@restconf_ns.response(204, "All slices deleted")
@restconf_ns.response(500, "Internal server error")
- def delete(self):
+ def delete(self) -> tuple[dict[str, Any], int]:
+ """Delete all network slice services."""
controller = NSController(controller_type="RESTCONF")
- return Api(controller).delete_network_slice_services()
+ return RestconfHandler(controller).delete_network_slice_services()
+
+
+# =============================================================================
+# Slice Services
+# =============================================================================
+
@restconf_ns.route("/data/ietf-network-slice-service:network-slice-services/slice-service")
class SliceServiceList(Resource):
+ """Resource for collection-level slice-service operations."""
@restconf_ns.doc(summary="Contains a set of Slice Services")
@restconf_ns.response(200, "Slices returned", slice_service_response)
@restconf_ns.response(404, "No slices found")
- def get(self):
+ def get(self) -> tuple[dict[str, Any], int]:
+ """Retrieve all slice services."""
controller = NSController(controller_type="RESTCONF")
- return Api(controller).get_slice_services()
+ return RestconfHandler(controller).get_slice_services()
@restconf_ns.doc(summary="Create Slice Services")
@restconf_ns.expect(slice_service_model)
- #@restconf_ns.expect(slice_service_model)
@restconf_ns.response(201, "Slice created", slice_response_model)
@restconf_ns.response(409, "Slice already exists")
- def post(self):
+ def post(self) -> tuple[dict[str, Any], int]:
+ """Create a slice service."""
json_data = request.get_json()
controller = NSController(controller_type="RESTCONF")
- return Api(controller).add_slice_service(json_data)
+ return RestconfHandler(controller).add_slice_service(json_data)
@restconf_ns.doc(summary="Delete Slice Services")
@restconf_ns.response(204, "All slices deleted")
- def delete(self):
+ def delete(self) -> tuple[dict[str, Any], int]:
+ """Delete all slice services."""
controller = NSController(controller_type="RESTCONF")
- return Api(controller).delete_slice_services()
+ return RestconfHandler(controller).delete_slice_services()
+
@restconf_ns.route("/data/ietf-network-slice-service:network-slice-services/slice-service=")
@restconf_ns.doc(params={"slice_service_id": "Slice identifier"})
class SliceService(Resource):
+ """Resource for individual slice-service operations."""
@restconf_ns.doc(summary="Get a Slice Service by ID")
@restconf_ns.response(200, "Slice returned", slice_service_response)
@restconf_ns.response(404, "Slice not found")
- def get(self, slice_service_id):
+ def get(self, slice_service_id: str) -> tuple[dict[str, Any], int]:
+ """Retrieve a specific slice service."""
controller = NSController(controller_type="RESTCONF")
- return Api(controller).get_slice_services(slice_service_id)
-
+ return RestconfHandler(controller).get_slice_services(slice_service_id)
+
@restconf_ns.doc(summary="Update Slice Service by ID")
@restconf_ns.expect(slice_service_model)
@restconf_ns.response(200, "Slice updated", slice_response_model)
@restconf_ns.response(404, "No slice found to update")
- def put(self, slice_service_id):
+ def put(self, slice_service_id: str) -> tuple[dict[str, Any], int]:
+ """Update a specific slice service."""
json_data = request.get_json()
controller = NSController(controller_type="RESTCONF")
- return Api(controller).update_slice_service(slice_service_id, json_data)
+ return RestconfHandler(controller).update_slice_service(slice_service_id, json_data)
@restconf_ns.doc(summary="Delete a Slice Service by ID")
@restconf_ns.response(204, "Slice deleted")
@restconf_ns.response(404, "Slice not found")
- def delete(self, slice_service_id):
+ def delete(self, slice_service_id: str) -> tuple[dict[str, Any], int]:
+ """Delete a specific slice service."""
controller = NSController(controller_type="RESTCONF")
- return Api(controller).delete_slice_services(slice_service_id)
+ return RestconfHandler(controller).delete_slice_services(slice_service_id)
-@restconf_ns.route("/data/ietf-network:networks/network=")
-@restconf_ns.doc(params={"slice_service_id": "Slice identifier"})
-class SliceNetworkTopology(Resource):
- @restconf_ns.doc(summary="Get Network Slice Topology by Slice ID (draft-ietf-teas-network-slice-topology-yang-04)")
- @restconf_ns.response(200, "Slice topology returned")
- @restconf_ns.response(404, "Slice not found")
- def get(self, slice_service_id):
- controller = NSController(controller_type="RESTCONF")
- return Api(controller).get_slice_topology(slice_service_id)
+# =============================================================================
+# SLO/SLE Templates
+# =============================================================================
+
@restconf_ns.route("/data/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template")
class SloSleTemplateList(Resource):
+ """Resource for collection-level SLO/SLE template operations."""
@restconf_ns.doc(summary="Contains a set of SLO/SLE templates")
@restconf_ns.response(200, "Templates returned", slo_sle_template_response)
- def get(self):
+ def get(self) -> tuple[dict[str, Any], int]:
+ """Retrieve all SLO/SLE templates."""
controller = NSController(controller_type="RESTCONF")
- return Api(controller).get_slo_sle_templates()
+ return RestconfHandler(controller).get_slo_sle_templates()
@restconf_ns.doc(summary="Create SLO/SLE templates")
@restconf_ns.expect(slo_sle_template_model)
@restconf_ns.response(201, "Template created", slice_response_model)
@restconf_ns.response(409, "Template already exists")
- def post(self):
+ def post(self) -> tuple[dict[str, Any], int]:
+ """Create an SLO/SLE template."""
json_data = request.get_json()
controller = NSController(controller_type="RESTCONF")
- return Api(controller).add_slo_sle_template(json_data)
+ return RestconfHandler(controller).add_slo_sle_template(json_data)
@restconf_ns.doc(summary="Delete SLO/SLE templates")
@restconf_ns.response(204, "All templates deleted")
- def delete(self):
+ def delete(self) -> tuple[dict[str, Any], int]:
+ """Delete all SLO/SLE templates."""
controller = NSController(controller_type="RESTCONF")
- return Api(controller).delete_slo_sle_templates()
+ return RestconfHandler(controller).delete_slo_sle_templates()
-@restconf_ns.route("/data/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template=")
+
+@restconf_ns.route(
+ "/data/ietf-network-slice-service:network-slice-services/slo-sle-templates/slo-sle-template="
+)
class SloSleTemplate(Resource):
+ """Resource for individual SLO/SLE template operations."""
@restconf_ns.doc(summary="Get SLO/SLE template by ID")
@restconf_ns.response(200, "Template returned", slo_sle_template_response)
@restconf_ns.response(404, "Template not found")
- def get(self, slo_sle_template_id):
+ def get(self, slo_sle_template_id: str) -> tuple[dict[str, Any], int]:
+ """Retrieve an SLO/SLE template."""
controller = NSController(controller_type="RESTCONF")
- return Api(controller).get_slo_sle_templates(slo_sle_template_id)
-
+ return RestconfHandler(controller).get_slo_sle_templates(slo_sle_template_id)
+
@restconf_ns.doc(summary="Update SLO/SLE template by ID")
@restconf_ns.expect(slo_sle_template_model)
@restconf_ns.response(200, "Template updated", slice_response_model)
@restconf_ns.response(404, "No template found to update")
- def put(self, slo_sle_template_id):
+ def put(self, slo_sle_template_id: str) -> tuple[dict[str, Any], int]:
+ """Update an SLO/SLE template."""
json_data = request.get_json()
controller = NSController(controller_type="RESTCONF")
- return Api(controller).update_slo_sle_template(slo_sle_template_id, json_data)
+ return RestconfHandler(controller).update_slo_sle_template(slo_sle_template_id, json_data)
@restconf_ns.doc(summary="Delete SLO/SLE template by ID")
@restconf_ns.response(204, "Template deleted")
- def delete(self, slo_sle_template_id):
+ def delete(self, slo_sle_template_id: str) -> tuple[dict[str, Any], int]:
+ """Delete an SLO/SLE template."""
controller = NSController(controller_type="RESTCONF")
- return Api(controller).delete_slo_sle_templates(slo_sle_template_id)
+ return RestconfHandler(controller).delete_slo_sle_templates(slo_sle_template_id)
+
+
+# =============================================================================
+# SDPs
+# =============================================================================
+
@restconf_ns.route("/data/ietf-network-slice-service:network-slice-services/slice-service=/sdps")
class SdpList(Resource):
+ """Resource for collection-level SDP operations on a slice service."""
@restconf_ns.doc(summary="Contains a set of Slice Service SDPs")
@restconf_ns.response(200, "SDPs returned", sdp_response)
- def get(self, slice_service_id):
+ def get(self, slice_service_id: str) -> tuple[dict[str, Any], int]:
+ """Retrieve all SDPs for a slice service."""
controller = NSController(controller_type="RESTCONF")
- return Api(controller).get_sdps(slice_service_id)
+ return RestconfHandler(controller).get_sdps(slice_service_id)
@restconf_ns.doc(summary="Create Slice Service SDPs")
@restconf_ns.expect(sdp_model)
@restconf_ns.response(201, "SDP created", slice_response_model)
@restconf_ns.response(409, "SDP already exists")
- def post(self, slice_service_id):
+ def post(self, slice_service_id: str) -> tuple[dict[str, Any], int]:
+ """Create an SDP on a slice service."""
json_data = request.get_json()
controller = NSController(controller_type="RESTCONF")
- return Api(controller).add_sdp(slice_service_id, json_data)
+ return RestconfHandler(controller).add_sdp(slice_service_id, json_data)
@restconf_ns.doc(summary="Delete Slice Service SDPs")
@restconf_ns.response(204, "All SDPs deleted")
- def delete(self, slice_service_id):
+ def delete(self, slice_service_id: str) -> tuple[dict[str, Any], int]:
+ """Delete all SDPs on a slice service."""
controller = NSController(controller_type="RESTCONF")
- return Api(controller).delete_sdps(slice_service_id)
-@restconf_ns.route("/data/ietf-network-slice-service:network-slice-services/slice-service=/sdps/sdp=")
+ return RestconfHandler(controller).delete_sdps(slice_service_id)
+
+
+@restconf_ns.route(
+ "/data/ietf-network-slice-service:network-slice-services/slice-service=/sdps/sdp="
+)
class Sdp(Resource):
+ """Resource for individual SDP operations on a slice service."""
@restconf_ns.doc(summary="Get Slice Service SDP by ID")
@restconf_ns.response(200, "SDP returned", sdp_response)
@restconf_ns.response(404, "SDP not found")
- def get(self, slice_service_id, sdp_id):
+ def get(self, slice_service_id: str, sdp_id: str) -> tuple[dict[str, Any], int]:
+ """Retrieve an SDP."""
controller = NSController(controller_type="RESTCONF")
- return Api(controller).get_sdps(slice_service_id, sdp_id)
+ return RestconfHandler(controller).get_sdps(slice_service_id, sdp_id)
+
@restconf_ns.doc(summary="Update Slice Service SDP by ID")
@restconf_ns.expect(sdp_model)
@restconf_ns.response(200, "SDP updated", slice_response_model)
@restconf_ns.response(404, "No SDP found to update")
- def put(self, slice_service_id, sdp_id):
+ def put(self, slice_service_id: str, sdp_id: str) -> tuple[dict[str, Any], int]:
+ """Update an SDP."""
json_data = request.get_json()
controller = NSController(controller_type="RESTCONF")
- return Api(controller).update_sdp(slice_service_id, sdp_id, json_data)
+ return RestconfHandler(controller).update_sdp(slice_service_id, sdp_id, json_data)
+
@restconf_ns.doc(summary="Delete Slice Service SDP by ID")
@restconf_ns.response(204, "SDP deleted")
- def delete(self, slice_service_id, sdp_id):
+ def delete(self, slice_service_id: str, sdp_id: str) -> tuple[dict[str, Any], int]:
+ """Delete an SDP."""
+ controller = NSController(controller_type="RESTCONF")
+ return RestconfHandler(controller).delete_sdps(slice_service_id, sdp_id)
+
+
+# =============================================================================
+# Topology
+# =============================================================================
+
+
+@restconf_ns.route("/data/ietf-network:networks/network=")
+@restconf_ns.doc(params={"slice_service_id": "Slice identifier"})
+class SliceNetworkTopology(Resource):
+ """Resource for retrieving slice topology."""
+
+ @restconf_ns.doc(
+ summary="Get Network Slice Topology by Slice ID (draft-ietf-teas-network-slice-topology-yang-04)",
+ )
+ @restconf_ns.response(200, "Slice topology returned")
+ @restconf_ns.response(404, "Slice not found")
+ def get(self, slice_service_id: str) -> tuple[dict[str, Any], int]:
+ """Retrieve slice topology."""
controller = NSController(controller_type="RESTCONF")
- return Api(controller).delete_sdps(slice_service_id, sdp_id)
+ return RestconfHandler(controller).get_slice_topology(slice_service_id)
+
+
+# =============================================================================
+# Telemetry Operations
+# =============================================================================
+
-# ================================================================================================================================================
@restconf_ns.route("/operations/telemetry/client")
class ClientsList(Resource):
+ """Resource for telemetry client collection operations."""
+
@restconf_ns.doc(description="Gets the list of all registered clients")
@restconf_ns.response(200, "Clients retrieved")
@restconf_ns.response(404, "No clients found")
@restconf_ns.response(500, "Internal server error")
- def get(self):
- return Api(NSController(controller_type="RESTCONF")).get_clients()
-
+ def get(self) -> tuple[dict[str, Any] | list[dict[str, Any]], int]:
+ """Retrieve all clients."""
+ return RestconfHandler(NSController(controller_type="RESTCONF")).get_clients()
+
@restconf_ns.doc(description="Deletes all clients")
@restconf_ns.response(204, "Clients deleted")
@restconf_ns.response(500, "Internal server error")
- def delete(self):
- return Api(NSController(controller_type="RESTCONF")).delete_clients()
-
+ def delete(self) -> tuple[dict[str, Any], int]:
+ """Delete all clients."""
+ return RestconfHandler(NSController(controller_type="RESTCONF")).delete_clients()
+
+
@restconf_ns.route("/operations/telemetry/client/")
class Clients(Resource):
+ """Resource for individual telemetry client operations."""
+
@restconf_ns.response(200, "Client retrieved")
@restconf_ns.response(404, "No client found")
@restconf_ns.response(500, "Internal server error")
- def get(self, client_id):
- return Api(NSController(controller_type="RESTCONF")).get_clients(client_id)
-
+ def get(self, client_id: str) -> tuple[dict[str, Any] | list[dict[str, Any]], int]:
+ """Retrieve a specific client."""
+ return RestconfHandler(NSController(controller_type="RESTCONF")).get_clients(client_id)
+
@restconf_ns.doc(description="Creates a new client")
- @restconf_ns.response(201, "Client created", model=restconf_ns.model("ClientModel", {
- "client_id": fields.String(required=True, description="Client ID"),
- }))
+ @restconf_ns.response(
+ 201,
+ "Client created",
+ model=restconf_ns.model(
+ "ClientModel",
+ {
+ "client_id": fields.String(required=True, description="Client ID"),
+ },
+ ),
+ )
@restconf_ns.response(500, "Internal server error")
- def post(self, client_id):
- return Api(NSController(controller_type="RESTCONF")).add_client(client_id)
+ def post(self, client_id: str) -> tuple[dict[str, Any], int]:
+ """Register a new client."""
+ return RestconfHandler(NSController(controller_type="RESTCONF")).add_client(client_id)
@restconf_ns.doc(description="Deletes a given client")
@restconf_ns.response(204, "Client deleted")
@restconf_ns.response(404, "No client found")
@restconf_ns.response(500, "Internal server error")
- def delete(self, client_id):
- return Api(NSController(controller_type="RESTCONF")).delete_clients(client_id)
+ def delete(self, client_id: str) -> tuple[dict[str, Any], int]:
+ """Delete a client."""
+ return RestconfHandler(NSController(controller_type="RESTCONF")).delete_clients(client_id)
+
@restconf_ns.route("/operations/telemetry/subscription/")
class Subscriptions(Resource):
+ """Resource for client subscription collection operations."""
+
@restconf_ns.doc(
description="Retrieves all telemetry subscriptions of a given client.",
- params={"client_id": "Client ID."}
+ params={"client_id": "Client ID."},
)
@restconf_ns.response(200, "Subscriptions successfully retrieved")
@restconf_ns.response(500, "Internal server error")
- def get(self, client_id):
- api = Api(NSController(controller_type="RESTCONF"))
- return current_app.ensure_sync(api.get_subscriptions)(client_id)
-
+ def get(self, client_id: str) -> Any:
+ """Retrieve all subscriptions for a client."""
+ handler = RestconfHandler(NSController(controller_type="RESTCONF"))
+ return current_app.ensure_sync(handler.get_subscriptions)(client_id)
+
@restconf_ns.doc(
description="Creates a new telemetry subscription for a given client.",
- params={"client_id": "Client ID."}
+ params={"client_id": "Client ID."},
)
@restconf_ns.response(201, "Subscription successfully created")
@restconf_ns.response(400, "Invalid request")
@restconf_ns.response(500, "Internal server error")
- @restconf_ns.expect(restconf_ns.model("TelemetrySubscriptionModel", {
- "slice_id": fields.String(required=True, description="Network Slice ID"),
- "frequency": fields.Integer(required=True, description="Telemetry push frequency in seconds")
- }), validate=True)
- def post(self, client_id):
- data = request.json
+ @restconf_ns.expect(
+ restconf_ns.model(
+ "TelemetrySubscriptionModel",
+ {
+ "slice_id": fields.String(required=True, description="Network Slice ID"),
+ "frequency": fields.Integer(required=True, description="Telemetry push frequency in seconds"),
+ },
+ ),
+ validate=True,
+ )
+ def post(self, client_id: str) -> Any:
+ """Create a telemetry subscription for a client."""
+ data = request.json or {}
slice_id = data.get("slice_id")
frequency = data.get("frequency")
- api = Api(NSController(controller_type="RESTCONF"))
- return current_app.ensure_sync(api.add_subscription)(
- client_id,
- slice_id,
- frequency
- )
-
+ handler = RestconfHandler(NSController(controller_type="RESTCONF"))
+ return current_app.ensure_sync(handler.add_subscription)(client_id, slice_id, frequency)
+
@restconf_ns.doc(
description="Deletes all telemetry subscriptions of a given client.",
- params={"client_id": "Client ID."}
+ params={"client_id": "Client ID."},
)
@restconf_ns.response(204, "Subscriptions successfully deleted")
@restconf_ns.response(500, "Internal server error")
- def delete(self, client_id):
- return Api(NSController(controller_type="RESTCONF")).delete_subscriptions(client_id)
+ def delete(self, client_id: str) -> tuple[dict[str, Any], int]:
+ """Delete all subscriptions for a client."""
+ return RestconfHandler(NSController(controller_type="RESTCONF")).delete_subscriptions(client_id)
@restconf_ns.route("/operations/telemetry/subscription//slice/")
class SliceSubscriptions(Resource):
+ """Resource for specific client-slice subscription operations."""
+
@restconf_ns.doc(
description="Retrieves a specific telemetry subscription of a given client.",
- params={
- "client_id": "Client ID.",
- "slice_id": "Network Slice ID."
- }
+ params={"client_id": "Client ID.", "slice_id": "Network Slice ID."},
)
@restconf_ns.response(200, "Subscription successfully retrieved")
@restconf_ns.response(500, "Internal server error")
- def get(self, client_id, slice_id):
- api = Api(NSController(controller_type="RESTCONF"))
- return current_app.ensure_sync(api.get_subscriptions)(client_id, slice_id)
-
+ def get(self, client_id: str, slice_id: str) -> Any:
+ """Retrieve a specific subscription."""
+ handler = RestconfHandler(NSController(controller_type="RESTCONF"))
+ return current_app.ensure_sync(handler.get_subscriptions)(client_id, slice_id)
+
@restconf_ns.doc(
description="Updates a telemetry subscription for a given client.",
- params={
- "client_id": "Client ID.",
- "slice_id": "Network Slice ID."
- }
+ params={"client_id": "Client ID.", "slice_id": "Network Slice ID."},
)
- @restconf_ns.response(201, "Subscription successfully updated")
+ @restconf_ns.response(200, "Subscription successfully updated")
@restconf_ns.response(400, "Invalid request")
+
@restconf_ns.response(500, "Internal server error")
- @restconf_ns.expect(restconf_ns.model("TelemetryUpdateSubscriptionModel", {
- "frequency": fields.Integer(required=True, description="Telemetry push frequency in seconds")
- }), validate=True)
- def put(self, client_id, slice_id):
- data = request.json
+ @restconf_ns.expect(
+ restconf_ns.model(
+ "TelemetryUpdateSubscriptionModel",
+ {
+ "frequency": fields.Integer(required=True, description="Telemetry push frequency in seconds"),
+ },
+ ),
+ validate=True,
+ )
+ def put(self, client_id: str, slice_id: str) -> Any:
+ """Update a telemetry subscription."""
+ data = request.json or {}
frequency = data.get("frequency")
- api = Api(NSController(controller_type="RESTCONF"))
- return current_app.ensure_sync(api.update_subscription)(
- client_id,
- slice_id,
- frequency
- )
+ handler = RestconfHandler(NSController(controller_type="RESTCONF"))
+ return current_app.ensure_sync(handler.update_subscription)(client_id, slice_id, frequency)
@restconf_ns.doc(
description="Deletes a specific telemetry subscription of a given client.",
- params={
- "client_id": "Client ID.",
- "slice_id": "Network Slice ID."
- }
+ params={"client_id": "Client ID.", "slice_id": "Network Slice ID."},
)
@restconf_ns.response(204, "Subscription successfully deleted")
@restconf_ns.response(500, "Internal server error")
- def delete(self, client_id, slice_id):
- return Api(NSController(controller_type="RESTCONF")).delete_subscriptions(client_id, slice_id)
+ def delete(self, client_id: str, slice_id: str) -> tuple[dict[str, Any], int]:
+ """Delete a specific subscription."""
+ return RestconfHandler(NSController(controller_type="RESTCONF")).delete_subscriptions(client_id, slice_id)
@restconf_ns.route("/operations/telemetry/subscription//slice//stream")
class SliceSubscriptionsStream(Resource):
+ """Resource for streaming SSE telemetry updates for a subscription."""
+
@restconf_ns.doc(
description="Opens a telemetry stream of a given subscription of a certain client",
- params={
- "client_id": "Client ID.",
- "slice_id": "Network Slice ID."
- }
+ params={"client_id": "Client ID.", "slice_id": "Network Slice ID."},
)
@restconf_ns.response(200, "Telemetry stream opened")
@restconf_ns.response(500, "Internal server error")
- def get(self, client_id, slice_id):
- api = Api(NSController(controller_type="RESTCONF"))
+ def get(self, client_id: str, slice_id: str) -> Response:
+ """Stream SSE telemetry updates."""
+ handler = RestconfHandler(NSController(controller_type="RESTCONF"))
return Response(
stream_with_context(
- api.sync_stream(
- api.stream_slice_subscription,
+ handler.sync_stream(
+ handler.stream_slice_subscription,
client_id,
- slice_id
+ slice_id,
)
),
- mimetype="text/event-stream"
+ mimetype="text/event-stream",
)
+
@restconf_ns.route("/operations/telemetry/slice")
class TelemetryList(Resource):
+ """Resource for retrieving telemetry metrics across all slices."""
+
@restconf_ns.doc(description="Gets latest telemetry of all slices.")
@restconf_ns.response(200, "Telemetry retrieved")
@restconf_ns.response(500, "Internal server error")
- def get(self):
+ def get(self) -> Any:
+ """Retrieve latest telemetry for all slices."""
controller = NSController(controller_type="RESTCONF")
- logging.info("Retrieving latest telemetry for all slices")
- return current_app.ensure_sync(Api(controller).get_telemetry)()
+ logger.info("Retrieving latest telemetry for all slices")
+ return current_app.ensure_sync(RestconfHandler(controller).get_telemetry)()
+
+
@restconf_ns.route("/operations/telemetry/slice/")
class Telemetry(Resource):
+ """Resource for retrieving telemetry metrics for a specific slice."""
+
@restconf_ns.doc(description="Gets latest telemetry of a certain network slice")
@restconf_ns.response(200, "Telemetry retrieved")
@restconf_ns.response(404, "Slice not found")
@restconf_ns.response(500, "Internal server error")
- def get(self, slice_id):
+ def get(self, slice_id: str) -> Any:
+ """Retrieve latest telemetry for a specific slice."""
controller = NSController(controller_type="RESTCONF")
- logging.info(f"Retrieving latest telemetry for slice '{slice_id}'")
- return current_app.ensure_sync(Api(controller).get_telemetry)(slice_id=slice_id)
\ No newline at end of file
+ logger.info("Retrieving latest telemetry for slice '%s'", slice_id)
+ return current_app.ensure_sync(RestconfHandler(controller).get_telemetry)(slice_id=slice_id)
+
diff --git a/swagger/tfs_namespace.py b/swagger/tfs_namespace.py
index 2686b837b7b7de7a5fb91b0efedc2f7742e88815..4e00e6a346c6332e751e5a2f366a0f86fab636a1 100644
--- a/swagger/tfs_namespace.py
+++ b/swagger/tfs_namespace.py
@@ -14,141 +14,132 @@
# This file is an original contribution from Telefonica Innovación Digital S.L.
+"""TeraFlowSDN (TFS) namespace definitions and resource controllers."""
+
+from __future__ import annotations
+
+from typing import Any
+
from flask import request
-from flask_restx import Namespace, Resource, fields, reqparse
+from flask_restx import Namespace, Resource, reqparse
+
+from src.api.main import Api as TfsHandler
from src.main import NSController
-from src.api.main import Api
-import json
-from swagger.models.create_models import create_gpp_nrm_28541_model, create_ietf_network_slice_nbi_yang_model
+from swagger.helpers import extract_json_payload
+from swagger.models.create_models import (
+ create_gpp_nrm_28541_model,
+ create_ietf_network_slice_nbi_yang_model,
+)
tfs_ns = Namespace(
"tfs",
- description="Operations related to transport network slices with TeraflowSDN (TFS) controller"
+ description="Operations related to transport network slices with TeraflowSDN (TFS) controller",
)
# 3GPP NRM TS28.541 Data models
gpp_network_slice_request_model = create_gpp_nrm_28541_model(tfs_ns)
# IETF draft-ietf-teas-ietf-network-slice-nbi-yang Data models
-
slice_ddbb_model, slice_response_model = create_ietf_network_slice_nbi_yang_model(tfs_ns)
upload_parser = reqparse.RequestParser()
-upload_parser.add_argument('file', location='files', type='FileStorage', help="File to upload")
-upload_parser.add_argument('json_data', location='form', help="JSON Data in string format")
+upload_parser.add_argument("file", location="files", type="FileStorage", help="File to upload")
+upload_parser.add_argument("json_data", location="form", help="JSON Data in string format")
+
+
+# =============================================================================
+# Namespace Resource Controllers
+# =============================================================================
-# Namespace Controllers
+
+@tfs_ns.route("/slices")
@tfs_ns.route("/slice")
class TfsSliceList(Resource):
- @tfs_ns.doc(summary="Return all transport network slices", description="Returns all transport network slices from the slice controller.")
+ """Resource for collection-level slice operations on TFS controller."""
+
+ @tfs_ns.doc(
+ summary="Return all transport network slices",
+ description="Returns all transport network slices from the slice controller.",
+ )
@tfs_ns.response(200, "Slices returned", slice_ddbb_model)
@tfs_ns.response(404, "Transport network slices not found")
@tfs_ns.response(500, "Internal server error")
- def get(self):
- """Retrieve all slices"""
+ def get(self) -> tuple[dict[str, Any] | list[Any], int]:
+ """Retrieve all slices."""
controller = NSController(controller_type="TFS")
- data, code = Api(controller).get_flows()
- return data, code
-
- @tfs_ns.doc(summary="Submit a transport network slice request", description="This endpoint allows clients to submit transport network slice requests using a JSON payload.")
- @tfs_ns.response(201,"Slice created successfully", slice_response_model)
- @tfs_ns.response(200, "No service to process.")
+ return TfsHandler(controller).get_flows()
+
+ @tfs_ns.doc(
+ summary="Submit a transport network slice request",
+ description="This endpoint allows clients to submit transport network slice requests using a JSON payload.",
+ )
+ @tfs_ns.response(201, "Slice created successfully", slice_response_model)
+ @tfs_ns.response(422, "Unprocessable entity")
@tfs_ns.response(400, "Invalid request format")
@tfs_ns.response(500, "Internal server error")
@tfs_ns.expect(upload_parser)
- def post(self):
- """Submit a new slice request with a file"""
-
- json_data = None
-
- # Try to get the JSON data from the uploaded file
- uploaded_file = request.files.get('file')
- if uploaded_file:
- if not uploaded_file.filename.endswith('.json'):
- return {
- "success": False,
- "data": None,
- "error": "Only JSON files allowed"
- }, 400
-
- try:
- json_data = json.load(uploaded_file) # Convert file to JSON
- except json.JSONDecodeError:
- return {
- "success": False,
- "data": None,
- "error": "JSON file not valid"
- }, 400
-
- # If no file was uploaded, try to get the JSON data from the form
- if json_data is None:
- raw_json = request.form.get('json_data')
- if raw_json:
- try:
- json_data = json.loads(raw_json) # Convert string to JSON
- except json.JSONDecodeError:
- return {
- "success": False,
- "data": None,
- "error": "JSON file not valid"
- }, 400
-
- # If no JSON data was found, return an error
- if json_data is None:
- return {
- "success": False,
- "data": None,
- "error": "No data sent"
- }, 400
-
- # Process the JSON data with the NSController
+ def post(self) -> tuple[dict[str, Any], int]:
+ """Submit a new slice request with a file or form payload."""
+ json_data, err_resp = extract_json_payload(request)
+ if err_resp is not None:
+ return err_resp
+
controller = NSController(controller_type="TFS")
- data, code = Api(controller).add_flow(json_data)
- return data, code
-
- @tfs_ns.doc(summary="Delete all transport network slices", description="Deletes all transport network slices from the slice controller.")
+ return TfsHandler(controller).add_flow(json_data) # type: ignore[arg-type]
+
+ @tfs_ns.doc(
+ summary="Delete all transport network slices",
+ description="Deletes all transport network slices from the slice controller.",
+ )
@tfs_ns.response(204, "All transport network slices deleted successfully.")
@tfs_ns.response(500, "Internal server error")
- def delete(self):
- """Delete all slices"""
+ def delete(self) -> tuple[dict[str, Any], int]:
+ """Delete all slices."""
controller = NSController(controller_type="TFS")
- data, code = Api(controller).delete_flows()
- return data, code
+ return TfsHandler(controller).delete_flows()
+@tfs_ns.route("/slices/")
@tfs_ns.route("/slice/")
@tfs_ns.doc(params={"slice_id": "The ID of the slice to retrieve or modify"})
class TfsSlice(Resource):
- @tfs_ns.doc(summary="Return a specific transport network slice", description="Returns specific information related to a slice by providing its id")
+
+ """Resource for individual slice operations on TFS controller."""
+
+ @tfs_ns.doc(
+ summary="Return a specific transport network slice",
+ description="Returns specific information related to a slice by providing its id",
+ )
@tfs_ns.response(200, "Slice returned", slice_ddbb_model)
@tfs_ns.response(404, "Transport network slice not found.")
@tfs_ns.response(500, "Internal server error")
- def get(self, slice_id):
- """Retrieve a specific slice"""
+ def get(self, slice_id: str) -> tuple[dict[str, Any] | list[Any], int]:
+ """Retrieve a specific slice."""
controller = NSController(controller_type="TFS")
- data, code = Api(controller).get_flows(slice_id)
- return data, code
+ return TfsHandler(controller).get_flows(slice_id)
- @tfs_ns.doc(summary="Delete a specific transport network slice", description="Deletes a specific transport network slice from the slice controller based on the provided `slice_id`.")
+ @tfs_ns.doc(
+ summary="Delete a specific transport network slice",
+ description="Deletes a specific transport network slice from the slice controller based on the provided `slice_id`.",
+ )
@tfs_ns.response(204, "Transport network slice deleted successfully.")
@tfs_ns.response(404, "Transport network slice not found.")
@tfs_ns.response(500, "Internal server error")
- def delete(self, slice_id):
- """Delete a slice"""
+ def delete(self, slice_id: str) -> tuple[dict[str, Any], int]:
+ """Delete a slice."""
controller = NSController(controller_type="TFS")
- data, code = Api(controller).delete_flows(slice_id)
- return data, code
+ return TfsHandler(controller).delete_flows(slice_id)
@tfs_ns.expect(slice_ddbb_model, validate=True)
- @tfs_ns.doc(summary="Modify a specific transport network slice", description="Returns a specific slice that has been modified")
+ @tfs_ns.doc(
+ summary="Modify a specific transport network slice",
+ description="Returns a specific slice that has been modified",
+ )
@tfs_ns.response(200, "Slice modified", slice_response_model)
@tfs_ns.response(404, "Transport network slice not found.")
@tfs_ns.response(500, "Internal server error")
- def put(self, slice_id):
- """Modify a slice"""
+ def put(self, slice_id: str) -> tuple[dict[str, Any], int]:
+ """Modify a slice."""
json_data = request.get_json()
controller = NSController(controller_type="TFS")
- data, code = Api(controller).modify_flow(slice_id, json_data)
- return data, code
-
-
+ return TfsHandler(controller).modify_flow(slice_id, json_data)