diff --git a/src/e2e_orchestrator/Dockerfile b/src/e2e_orchestrator/Dockerfile index 847e75ed42308477509fcc13d2681b1ff1447945..3751c02a8e6017aa7231b5981076dc2e5bf398d2 100644 --- a/src/e2e_orchestrator/Dockerfile +++ b/src/e2e_orchestrator/Dockerfile @@ -82,6 +82,9 @@ COPY src/context/client/. context/client/ COPY src/context/service/database/uuids/. context/service/database/uuids/ COPY src/service/__init__.py service/__init__.py COPY src/service/client/. service/client/ +COPY src/pathcomp/__init__.py pathcomp/__init__.py +COPY src/pathcomp/frontend/__init__.py pathcomp/frontend/__init__.py +COPY src/pathcomp/frontend/client/. pathcomp/frontend/client/ COPY src/e2e_orchestrator/. e2e_orchestrator/ # Start the service diff --git a/src/e2e_orchestrator/service/E2EOrchestratorServiceServicerImpl.py b/src/e2e_orchestrator/service/E2EOrchestratorServiceServicerImpl.py index ead3aeb839508008db947a647ad50c635fc4ee60..7e87327870307f54c2ab3030d1c53247889a70af 100644 --- a/src/e2e_orchestrator/service/E2EOrchestratorServiceServicerImpl.py +++ b/src/e2e_orchestrator/service/E2EOrchestratorServiceServicerImpl.py @@ -19,6 +19,9 @@ from common.proto.context_pb2 import Empty, Connection, EndPointId from common.proto.e2eorchestrator_pb2_grpc import E2EOrchestratorServiceServicer from context.client.ContextClient import ContextClient from context.service.database.uuids.EndPoint import endpoint_get_uuid +from common.proto.context_pb2 import ServiceTypeEnum +from pathcomp.frontend.client.PathCompClient import PathCompClient +from common.proto.pathcomp_pb2 import PathCompRequest LOGGER = logging.getLogger(__name__) @@ -33,6 +36,18 @@ class E2EOrchestratorServiceServicerImpl(E2EOrchestratorServiceServicer): def Compute( self, request: E2EOrchestratorRequest, context: grpc.ServicerContext ) -> E2EOrchestratorReply: + if request.service.service_type == ServiceTypeEnum.SERVICETYPE_OPTICAL_CONNECTIVITY: + LOGGER.info("E2E Orchestrator: Detected OPTICAL_CONNECTIVITY service. Calling PathComp.") + pathcomp_client = PathCompClient() + pathcomp_req = PathCompRequest() + pathcomp_req.services.append(request.service) + pathcomp_reply = pathcomp_client.Compute(pathcomp_req) + + e2e_reply = E2EOrchestratorReply() + e2e_reply.services.extend(pathcomp_reply.services) + e2e_reply.connections.extend(pathcomp_reply.connections) + return e2e_reply + endpoints_ids = [ endpoint_get_uuid(endpoint_id)[2] for endpoint_id in request.service.service_endpoint_ids diff --git a/src/pathcomp/frontend/service/OpticalPathComp.py b/src/pathcomp/frontend/service/OpticalPathComp.py new file mode 100644 index 0000000000000000000000000000000000000000..20d4dc5eb17870e47a10c0e8d99f28b92216d8bd --- /dev/null +++ b/src/pathcomp/frontend/service/OpticalPathComp.py @@ -0,0 +1,443 @@ +# 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. + +import json +import logging +import uuid + +import requests +from common.proto.context_pb2 import ConfigActionEnum, ConfigRule, Service +from common.proto.pathcomp_pb2 import PathCompReply + +from .TopologyTools import get_device_with_driver + +LOGGER = logging.getLogger(__name__) + +def safe_get(d, keys, default=None): + for key in keys: + if isinstance(d, dict): + d = d.get(key, default) + else: + return default + return d + + +def group_block(group, action, group_id_override=None, node=None): + active = "true" if action == 'create' else "false" + group_id = group_id_override if group_id_override is not None else group.get("group-id", group.get("digital_sub_carriers_group_id", 1)) + + if node == "leaf": + return { + "digital_sub_carriers_group_id": group_id, + "digital_sub_carrier_id": [ + {'sub_carrier_id': 1, 'active': active}, + {'sub_carrier_id': 2, 'active': active}, + {'sub_carrier_id': 3, 'active': active}, + {'sub_carrier_id': 4, 'active': active} + ] + } + else: + return { + "digital_sub_carriers_group_id": group_id, + "digital_sub_carrier_id": [ + { + "sub_carrier_id": sid, + "active": active, + } + for sid in group.get("subcarrier-id", []) + ] + } + +def compute_optical_path(service: Service) -> PathCompReply: + # Extract intent from config rules + intent_str = "" + for cr in service.service_config.config_rules: + if cr.WhichOneof('config_rule') == 'custom' and cr.custom.resource_key == "intent": + intent_str = cr.custom.resource_value + break + + intent = json.loads(intent_str) if intent_str else {} + action = "create" # Default action + + # Extract src (sender) and dst (receivers) from intent + services = intent.get("ietf-network-slice-service:network-slice-services", {}).get("slice-service", []) + source = None + destination = None + + for srv in services: + c_groups = srv.get("connection-groups", {}).get("connection-group", []) + for cg in c_groups: + constructs = cg.get("connectivity-construct", []) + for construct in constructs: + source = construct.get("p2mp-sender-sdp") + destination = construct.get("p2mp-receiver-sdp") + if source and destination: + break + if source and destination: + break + if source and destination: + break + + if not source or not destination: + raise Exception("Missing p2mp-sender-sdp or p2mp-receiver-sdp parameters in the intent") + + if isinstance(source, str): + sources_list = [source] + else: + sources_list = list(source) + + if isinstance(destination, str): + destinations_list = [destination] + else: + destinations_list = list(destination) + + # In optical networks, the leaves are often the "sources" of light for the computation or vice-versa. + # Based on user's instruction: sources = receivers, destinations = sender + payload = { + "sources": destinations_list, + "destinations": sources_list, + "bitrate": 100, + "bidirectional": True, + "band": 200, + "subcarriers_per_source": [4] * len(destinations_list) + } + + url = "http://10.30.7.66:31060/OpticalTFS/restconf/operations/tapi-path-computation:compute-p2mp" + headers = { + "Content-Type": "application/json", + "Accept": "*/*" + } + + # resp = requests.post(url, headers=headers, json=payload, timeout=15) + # resp.raise_for_status() + # resp_json = resp.json() + + # MOCK RESPONSE (If the Optical Controller is down) + resp_json = { + "tapi-connectivity:connectivity-service": { + "connection": [ + { + "optical-connection-attributes": { + "central-frequency": 195000000000000, + "Tx-power": 0, + "modulation": { + "modulation-technique": "DP-16QAM", + "operational-mode": 9, + "port": "port-1" + }, + "digital-subcarrier-spacing": 300000000, + "subcarrier-attributes": { + "digital-subcarrier-group": [ + { + "group-id": 1, + "modulation-technique": "DP-QPSK", + "central-frequency": 195006250, + "operational-mode": 4, + "Tx-power": -99, + "group-size": 4, + "port": "port-1", + "subcarrier-id": [1, 2, 3, 4] + }, + { + "group-id": 2, + "modulation-technique": "DP-QPSK", + "central-frequency": 195018750, + "operational-mode": 4, + "Tx-power": -99, + "group-size": 4, + "port": "port-3", + "subcarrier-id": [5, 6, 7, 8] + }, + { + "group-id": 3, + "modulation-technique": "DP-QPSK", + "central-frequency": 195031250, + "operational-mode": 4, + "Tx-power": -99, + "group-size": 4, + "port": "port-5", + "subcarrier-id": [9, 10, 11, 12] + } + ] + } + }, + "end-point": [ + { + "direction": "BIDIRECTIONAL", + "layer-protocol-name": "PHOTONIC_MEDIA", + "layer-protocol-qualifier": "tapi-photonic-media:PHOTONIC_LAYER_QUALIFIER_MC", + "local-id": "T1.1", + "service-interface-point": { + "service-interface-point-uuid": "T2.1" + }, + "tapi-photonic-media:media-channel-connectivity-service-end-point-spec": { + "mc-config": { + "spectrum": { + "center-frequency": 195000000000000, + "frequency-constraint": { + "adjustment-granularity": "G_6_25GHZ", + "grid-type": "FLEX" + } + } + } + } + }, + { + "direction": "BIDIRECTIONAL", + "layer-protocol-name": "PHOTONIC_MEDIA", + "layer-protocol-qualifier": "tapi-photonic-media:PHOTONIC_LAYER_QUALIFIER_MC", + "local-id": "T2.1", + "service-interface-point": { + "service-interface-point-uuid": "T1.1" + }, + "tapi-photonic-media:media-channel-connectivity-service-end-point-spec": { + "mc-config": { + "spectrum": { + "center-frequency": 195006250, + "frequency-constraint": { + "adjustment-granularity": "G_6_25GHZ", + "grid-type": "FLEX" + } + } + } + } + }, + { + "direction": "BIDIRECTIONAL", + "layer-protocol-name": "PHOTONIC_MEDIA", + "layer-protocol-qualifier": "tapi-photonic-media:PHOTONIC_LAYER_QUALIFIER_MC", + "local-id": "T1.2", + "service-interface-point": { + "service-interface-point-uuid": "T2.1" + }, + "tapi-photonic-media:media-channel-connectivity-service-end-point-spec": { + "mc-config": { + "spectrum": { + "center-frequency": 195018750, + "frequency-constraint": { + "adjustment-granularity": "G_6_25GHZ", + "grid-type": "FLEX" + } + } + } + } + }, + { + "direction": "BIDIRECTIONAL", + "layer-protocol-name": "PHOTONIC_MEDIA", + "layer-protocol-qualifier": "tapi-photonic-media:PHOTONIC_LAYER_QUALIFIER_MC", + "local-id": "T2.1", + "service-interface-point": { + "service-interface-point-uuid": "T1.2" + }, + "tapi-photonic-media:media-channel-connectivity-service-end-point-spec": { + "mc-config": { + "spectrum": { + "center-frequency": 195018750, + "frequency-constraint": { + "adjustment-granularity": "G_6_25GHZ", + "grid-type": "FLEX" + } + } + } + } + }, + { + "direction": "BIDIRECTIONAL", + "layer-protocol-name": "PHOTONIC_MEDIA", + "layer-protocol-qualifier": "tapi-photonic-media:PHOTONIC_LAYER_QUALIFIER_MC", + "local-id": "T1.3", + "service-interface-point": { + "service-interface-point-uuid": "T2.1" + }, + "tapi-photonic-media:media-channel-connectivity-service-end-point-spec": { + "mc-config": { + "spectrum": { + "center-frequency": 195031250, + "frequency-constraint": { + "adjustment-granularity": "G_6_25GHZ", + "grid-type": "FLEX" + } + } + } + } + }, + { + "direction": "BIDIRECTIONAL", + "layer-protocol-name": "PHOTONIC_MEDIA", + "layer-protocol-qualifier": "tapi-photonic-media:PHOTONIC_LAYER_QUALIFIER_MC", + "local-id": "T2.1", + "service-interface-point": { + "service-interface-point-uuid": "T1.3" + }, + "tapi-photonic-media:media-channel-connectivity-service-end-point-spec": { + "mc-config": { + "spectrum": { + "center-frequency": 195031250, + "frequency-constraint": { + "adjustment-granularity": "G_6_25GHZ", + "grid-type": "FLEX" + } + } + } + } + } + ] + } + ] + } + } + + # Generate Rules + src_name = source # T2.1 + dest_list = destinations_list # T1.1, T1.2 + # Extract destinations from response end-points if available + try: + endpoints = resp_json.get("tapi-connectivity:connectivity-service", {}).get("connection", [{}])[0].get("end-point", []) + extracted_dests = [] + for ep in endpoints: + local_id = ep.get("local-id") + if local_id and local_id != src_name and local_id not in extracted_dests: + extracted_dests.append(local_id) + if extracted_dests: + dest_list = extracted_dests + except Exception: + pass + + dest_str = ",".join(dest_list) + config_rules = [] + + network_slice_uuid_str = f"{src_name}_to_{dest_str}" + tunnel_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, network_slice_uuid_str)) + + provisionamiento = { + "network-slice-uuid": network_slice_uuid_str, + "viability": True, + "actions": [] + } + try: + attributes = resp_json.get("tapi-connectivity:connectivity-service", {}).get("connection", [{}])[0].get("optical-connection-attributes", {}) + groups = attributes.get("subcarrier-attributes", {}).get("digital-subcarrier-group", []) + operational_mode = attributes.get("modulation", {}).get("operational-mode", 9) + except Exception: + # Provide fallback if mock doesn't match perfectly + groups = resp_json.get("digital-subcarrier-groups", []) + operational_mode = resp_json.get("op-mode", 9) + + hub_groups = [ + group_block(group, action, group_id_override=index + 1) + for index, group in enumerate(groups) + ] + + hub_freq_raw = attributes.get("central-frequency", 195000000000000) + hub_freq = int(hub_freq_raw / 1e6) if hub_freq_raw > 1e10 else int(hub_freq_raw) + + hub = { + "name": "channel-1", + "frequency": hub_freq, + "target_output_power": attributes.get("Tx-power", 0), + "operational_mode": operational_mode, + "operation": "merge", + "digital_sub_carriers_group": hub_groups + } + + leaves = [] + print("dest_list:", dest_list) + print("groups:", groups) + for dest, group in zip(dest_list, groups): + port = group.get("port", "port-1") + if port.startswith("port-"): + name = f"channel-{port.split('-')[1]}" + else: + name = "channel-1" + + freq_raw = group.get("central-frequency", 195006250) + freq = int(freq_raw / 1e6) if freq_raw > 1e10 else int(freq_raw) + + leaf = { + "name": name, + "frequency": freq, + "target_output_power": group.get("Tx-power", 0), + "operational_mode": int(group.get("operational-mode", operational_mode)), + "operation": "merge", + "digital_sub_carriers_group": [group_block(group, action, group_id_override=1, node="leaf")] + } + leaves.append(leaf) + + final_json = {"components": [hub] + leaves} + + # Add transceiver activation action + provisionamiento["actions"].append({ + "type": "XR_AGENT_ACTIVATE_TRANSCEIVER", + "layer": "OPTICAL", + "content": final_json, + "controller-uuid": "IPoWDM Controller" + }) + + # Extract IP configuration from intent for L3 VPN setup + nodes = {} + sdp_list = intent.get('ietf-network-slice-service:network-slice-services', {}).get('slice-service', [{}])[0].get('sdps', {}).get('sdp', []) + + for sdp in sdp_list: + node = sdp.get('id') + attachments = sdp.get('attachment-circuits', {}).get('attachment-circuit', []) + for ac in attachments: + ip = ac.get('ac-ipv4-address', None) + prefix = ac.get('ac-ipv4-prefix-length', None) + vlan = 500 # Fixed VLAN ID + nodes[node] = { + "ip-address": ip, + "ip-mask": prefix, + "vlan-id": vlan + } + + # Add L3 VPN configuration action for P2MP topology + if src_name in nodes: + content = { + "tunnel-uuid": tunnel_uuid, + "src-node-uuid": src_name, + "src-ip-address": nodes[src_name]["ip-address"], + "src-ip-mask": str(nodes[src_name]["ip-mask"]), + "src-vlan-id": nodes[src_name]["vlan-id"], + } + + for i, dest in enumerate(dest_list): + if dest in nodes: + content[f"dest{i+1}-node-uuid"] = dest + content[f"dest{i+1}-ip-address"] = nodes[dest]["ip-address"] + content[f"dest{i+1}-ip-mask"] = str(nodes[dest]["ip-mask"]) + content[f"dest{i+1}-vlan-id"] = nodes[dest]["vlan-id"] + controller_ip = get_device_with_driver() + + provisionamiento["actions"].append({ + "type": "CONFIG_VPNL3", + "layer": "IP", + "content": content, + "controller_uuid": controller_ip + }) + + config_rules.append(provisionamiento) + + reply = PathCompReply() + reply_svc = Service() + reply_svc.CopyFrom(service) + + cr = ConfigRule() + cr.action = ConfigActionEnum.CONFIGACTION_SET + cr.custom.resource_key = "optical_path_result" + cr.custom.resource_value = json.dumps(config_rules) + reply_svc.service_config.config_rules.append(cr) + reply.services.append(reply_svc) + + return reply diff --git a/src/pathcomp/frontend/service/PathCompServiceServicerImpl.py b/src/pathcomp/frontend/service/PathCompServiceServicerImpl.py index 4e832c2586efee791f224c410aa7be793d3d7b41..d7e3f2daf81ee77e63611c6c51d1ac8b51a7ce72 100644 --- a/src/pathcomp/frontend/service/PathCompServiceServicerImpl.py +++ b/src/pathcomp/frontend/service/PathCompServiceServicerImpl.py @@ -28,6 +28,8 @@ from pathcomp.frontend.Config import is_forecaster_enabled #from context.client.ContextClient import ContextClient from pathcomp.frontend.service.TopologyTools import get_pathcomp_topology_details from pathcomp.frontend.service.algorithms.Factory import get_algorithm +from pathcomp.frontend.service.OpticalPathComp import compute_optical_path +from common.proto.context_pb2 import ServiceTypeEnum LOGGER = logging.getLogger(__name__) @@ -45,6 +47,11 @@ class PathCompServiceServicerImpl(PathCompServiceServicer): def Compute(self, request : PathCompRequest, context : grpc.ServicerContext) -> PathCompReply: LOGGER.debug('[Compute] begin ; request = {:s}'.format(grpc_message_to_json_string(request))) + if len(request.services) > 0 and request.services[0].service_type == ServiceTypeEnum.SERVICETYPE_OPTICAL_CONNECTIVITY: + LOGGER.info('[Compute] Intercepting OPTICAL_CONNECTIVITY request...') + return compute_optical_path(request.services[0]) + + #context_client = ContextClient() # TODO: improve definition of topologies; for interdomain the current topology design might be not convenient #if (len(request.services) == 1) and is_inter_domain(context_client, request.services[0].service_endpoint_ids): diff --git a/src/pathcomp/frontend/service/TopologyTools.py b/src/pathcomp/frontend/service/TopologyTools.py index 036f85a56a27197da61ae5c11d8e78003d23eb77..cf3e0dd9dea0575b12150c31ff2d590672135ffa 100644 --- a/src/pathcomp/frontend/service/TopologyTools.py +++ b/src/pathcomp/frontend/service/TopologyTools.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -import logging, math +import logging, math, os, socket, requests from typing import Dict, Optional from common.Constants import DEFAULT_CONTEXT_NAME, DEFAULT_TOPOLOGY_NAME, ServiceNameEnum from common.Settings import ENVVAR_SUFIX_SERVICE_HOST, ENVVAR_SUFIX_SERVICE_PORT_GRPC, find_environment_variables, get_env_var_name @@ -25,6 +25,7 @@ from common.tools.grpc.Tools import grpc_message_to_json_string from context.client.ContextClient import ContextClient from forecaster.client.ForecasterClient import ForecasterClient + LOGGER = logging.getLogger(__name__) def get_service_schedule(service : Service) -> Optional[Constraint_Schedule]: @@ -96,3 +97,36 @@ def get_pathcomp_topology_details(request : PathCompRequest, allow_forecasting : link.attributes.total_capacity_gbps = total_capacity_gbps return topology_details + +# The HOST IP of the TFS controller is needed to query the devices and get the +# IP address of the controller to be used in the path provisioning. +ENVVAR_TFS_API_HOST = '10.95.89.50' + + + +def get_device_with_driver(driver_name: str = 'DEVICEDRIVER_IETF_L3VPN', timeout: int = 10): + """Query the TFS controller REST API and return the _connect/address value of the first device with the given driver. + + Example: GET http:///tfs-api/devices + + Returns the _connect/address string or None. + """ + url = f'http://{ENVVAR_TFS_API_HOST}:80/tfs-api/devices' + try: + logging.debug("Requesting devices from %s", url) + resp = requests.get(url, timeout=timeout) + resp.raise_for_status() + data = resp.json() + except Exception as e: + logging.warning("Error fetching devices from %s: %s", url, e) + return None + + devices = data.get('devices', []) if isinstance(data, dict) else [] + for dev in devices: + drivers = dev.get('device_drivers') or [] + if isinstance(drivers, list) and driver_name in drivers: + device_name = dev.get('name') + return device_name + + logging.info("No device with driver %s found on %s", driver_name, url) + return None