Commit 37a7bf29 authored by Pablo Armingol's avatar Pablo Armingol
Browse files

NBI + DEVICE

NBI:
* New NBI for Optical Slices

DEVICE:
* Management of Optical Slices + Media Channels
* Management of import topology from TFS optical driver or real OLS
parent bf9f7c11
Loading
Loading
Loading
Loading
+89 −0
Original line number Diff line number Diff line
# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import json
import logging
import os
import requests

LOGGER = logging.getLogger(__name__)

def create_tapi_request(resource_value):
    """Create TAPI connectivity service request from resource value.

    Args:
        resource_value: Tuple of (resource_key, resource_data_dict)

    Returns:
        dict: TAPI connectivity service request payload
    """
    LOGGER.info("Creating TAPI request for resource_value: %s", resource_value)
    try:
        base_dir = os.path.dirname(os.path.abspath(__file__))
        json_path = os.path.join(base_dir, 'templates', 'lsp.json')
        with open(json_path, 'r', encoding='utf-8') as f:
            template = json.load(f)

        resource_key = resource_value[0]
        resource_data = json.loads(resource_value[1]) if isinstance(resource_value[1], str) else resource_value[1]

        LOGGER.info("Processing resource_key: %s", resource_key)
        LOGGER.info("Resource data: %s", resource_data)

        svc = template["tapi-connectivity:connectivity-service"][0]
        svc["connectivity-direction"] = resource_data["direction"]
        svc["layer-protocol-name"] = resource_data["layer_protocol_name"]
        svc["layer-protocol-qualifier"] = resource_data["layer_protocol_qualifier"]
        svc["requested-capacity"]["total-size"]["unit"] = "GHz"
        svc["requested-capacity"]["total-size"]["value"] = resource_data["bw"]
        svc["include-link"] = resource_data.get("link_uuid_path", [])
        svc["uuid"] = resource_data["uuid"]

        ep0 = svc["end-point"][0]
        ep0["service-interface-point"]["service-interface-point-uuid"] = resource_data["input_sip"]
        ep0["direction"] = resource_data["direction"]
        ep0["layer-protocol-name"] = resource_data["layer_protocol_name"]
        ep0["layer-protocol-qualifier"] = resource_data["layer_protocol_qualifier"]
        ep0["local-id"] = resource_data["input_sip"]

        media_spec = ep0["tapi-photonic-media:media-channel-connectivity-service-end-point-spec"]
        mc_config = media_spec["mc-config"]
        spectrum = mc_config["spectrum"]
        spectrum["lower-frequency"] = resource_data["lower_frequency_mhz"]
        spectrum["upper-frequency"] = resource_data["upper_frequency_mhz"]
        spectrum["frequency-constraint"]["adjustment-granularity"] = resource_data["granularity"]
        spectrum["frequency-constraint"]["grid-type"] = resource_data["grid_type"]

        ep1 = svc["end-point"][1]
        ep1["service-interface-point"]["service-interface-point-uuid"] = resource_data["output_sip"]
        ep1["direction"] = resource_data["direction"]
        ep1["layer-protocol-name"] = resource_data["layer_protocol_name"]
        ep1["layer-protocol-qualifier"] = resource_data["layer_protocol_qualifier"]
        ep1["local-id"] = resource_data["output_sip"]

        url = resource_data.get("url", "")

        LOGGER.info("URL: %s", url)
        LOGGER.info("Template: %s", json.dumps(template, indent = 2))
        headers = {
            "Content-Type": "application/yang-data+json",
            "Accept": "application/yang-data+json",
            "Expect": ""
        }
        result = requests.post(url, headers=headers, data=json.dumps(template), timeout=10)
        return result

    except (OSError, json.JSONDecodeError, KeyError) as e:
        LOGGER.error("Error creating TAPI request: %s", str(e), exc_info=True)
        raise
+135 −0
Original line number Diff line number Diff line
# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import logging, requests
from typing import Dict, List, Optional
from common.tools.rest_api.client.RestApiClient import RestApiClient
from device.service.driver_api.ImportTopologyEnum import ImportTopologyEnum

GET_CONTEXT_IDS_URL = '/tfs-api/context_ids'
GET_DEVICES_URL     = '/tfs-api/devices'
GET_LINKS_URL       = '/tfs-api/links'

MAPPING_STATUS = {
    'DEVICEOPERATIONALSTATUS_UNDEFINED': 0,
    'DEVICEOPERATIONALSTATUS_DISABLED' : 1,
    'DEVICEOPERATIONALSTATUS_ENABLED'  : 2,
}

MAPPING_DRIVER = {
    'DEVICEDRIVER_UNDEFINED'            : 0,
    'DEVICEDRIVER_OPENCONFIG'           : 1,
    'DEVICEDRIVER_TRANSPORT_API'        : 2,
    'DEVICEDRIVER_P4'                   : 3,
    'DEVICEDRIVER_IETF_NETWORK_TOPOLOGY': 4,
    'DEVICEDRIVER_ONF_TR_532'           : 5,
    'DEVICEDRIVER_XR'                   : 6,
    'DEVICEDRIVER_IETF_L2VPN'           : 7,
    'DEVICEDRIVER_GNMI_OPENCONFIG'      : 8,
    'DEVICEDRIVER_OPTICAL_TFS'          : 9,
    'DEVICEDRIVER_IETF_ACTN'            : 10,
    'DEVICEDRIVER_OC'                   : 11,
    'DEVICEDRIVER_QKD'                  : 12,
    'DEVICEDRIVER_IETF_L3VPN'           : 13,
    'DEVICEDRIVER_IETF_SLICE'           : 14,
    'DEVICEDRIVER_NCE'                  : 15,
    'DEVICEDRIVER_SMARTNIC'             : 16,
    'DEVICEDRIVER_MORPHEUS'             : 17,
    'DEVICEDRIVER_RYU'                  : 18,
}

LOGGER = logging.getLogger(__name__)

class TfsApiClient(RestApiClient):
    def __init__(
        self, address : str, port : int, scheme : str = 'http',
        username : Optional[str] = None, password : Optional[str] = None,
        timeout : Optional[int] = 30
    ) -> None:
        super().__init__(
            address, port, scheme=scheme, username=username, password=password,
            timeout=timeout, verify_certs=False, allow_redirects=True, logger=LOGGER
        )

    def check_credentials(self) -> None:
        self.get(GET_CONTEXT_IDS_URL, expected_status_codes={requests.codes['OK']})
        LOGGER.info('Credentials checked')

    def get_devices_endpoints(
        self, import_topology : ImportTopologyEnum = ImportTopologyEnum.DEVICES
    ) -> List[Dict]:
        LOGGER.debug('[get_devices_endpoints] begin')
        MSG = '[get_devices_endpoints] import_topology={:s}'
        LOGGER.debug(MSG.format(str(import_topology)))

        if import_topology == ImportTopologyEnum.DISABLED:
            MSG = 'Unsupported import_topology mode: {:s}'
            raise Exception(MSG.format(str(import_topology)))

        devices = self.get(GET_DEVICES_URL, expected_status_codes={requests.codes['OK']})

        result = list()
        for json_device in devices['devices']:
            device_uuid : str = json_device['device_id']['device_uuid']['uuid']
            device_type : str = json_device['device_type']
            device_status = json_device['device_operational_status']
            device_url = '/devices/device[{:s}]'.format(device_uuid)
            device_data = {
                'uuid': json_device['device_id']['device_uuid']['uuid'],
                'name': json_device['name'],
                'type': device_type,
                'status': MAPPING_STATUS[device_status],
                'drivers': [
                    MAPPING_DRIVER[driver]
                    for driver in json_device['device_drivers']
                ],
            }
            result.append((device_url, device_data))

            for json_endpoint in json_device['device_endpoints']:
                endpoint_uuid = json_endpoint['endpoint_id']['endpoint_uuid']['uuid']
                endpoint_url = '/endpoints/endpoint[{:s}]'.format(endpoint_uuid)
                endpoint_data = {
                    'device_uuid': device_uuid,
                    'uuid': endpoint_uuid,
                    'name': json_endpoint['name'],
                    'type': json_endpoint['endpoint_type'],
                }
                result.append((endpoint_url, endpoint_data))

        if import_topology == ImportTopologyEnum.DEVICES:
            LOGGER.debug('[get_devices_endpoints] devices only; returning')
            return result

        links = self.get(GET_LINKS_URL, expected_status_codes={requests.codes['OK']})

        for json_link in links['links']:
            link_uuid : str = json_link['link_id']['link_uuid']['uuid']
            link_url = '/links/link[{:s}]'.format(link_uuid)
            link_endpoint_ids = [
                (
                    json_endpoint_id['device_id']['device_uuid']['uuid'],
                    json_endpoint_id['endpoint_uuid']['uuid'],
                )
                for json_endpoint_id in json_link['link_endpoint_ids']
            ]
            link_data = {
                'uuid': json_link['link_id']['link_uuid']['uuid'],
                'name': json_link['name'],
                'endpoints': link_endpoint_ids,
            }
            result.append((link_url, link_data))

        LOGGER.debug('[get_devices_endpoints] topology; returning')
        return result
+85 −26
Original line number Diff line number Diff line
@@ -12,14 +12,16 @@
# See the License for the specific language governing permissions and
# limitations under the License.

import logging, requests, threading
import json, logging, requests, threading
from requests.auth import HTTPBasicAuth
from typing import Any, Iterator, List, Optional, Tuple, Union
from common.method_wrappers.Decorator import MetricsPool, metered_subclass_method
from common.type_checkers.Checkers import chk_string, chk_type
from device.service.driver_api._Driver import _Driver
from device.service.driver_api._Driver import _Driver, RESOURCE_ENDPOINTS, RESOURCE_SERVICES
from device.service.driver_api.ImportTopologyEnum import ImportTopologyEnum, get_import_topology
from . import ALL_RESOURCE_KEYS
from .Tools import create_connectivity_service, find_key, config_getter, delete_connectivity_service, tapi_tequest
from .Tools import create_connectivity_service, find_key, config_getter, delete_connectivity_service
from .TfsApiClient import TfsApiClient

LOGGER = logging.getLogger(__name__)

@@ -38,6 +40,17 @@ class TransportApiDriver(_Driver):
        scheme = self.settings.get('scheme', 'http')
        self.__tapi_root = '{:s}://{:s}:{:d}'.format(scheme, self.address, int(self.port))
        self.__timeout = int(self.settings.get('timeout', 120))
        self.__import_topology = get_import_topology(self.settings, default=ImportTopologyEnum.DISABLED)
        self.__skip_tapi_queries = self.__import_topology != ImportTopologyEnum.DISABLED

        if self.__skip_tapi_queries:
            self.tac = TfsApiClient(address, port, scheme=scheme, username=username, password=password, timeout=self.__timeout)
            LOGGER.info('TransportApiDriver initialized for {:s}:{:s} with import_topology={:s}'.format(
                address, str(port), str(self.__import_topology)))
        else:
            self.tac = None
            LOGGER.info('TransportApiDriver initialized for {:s}:{:s} as real OLS device'.format(
                address, str(port)))

    def Connect(self) -> bool:
        url = self.__tapi_root + '/restconf/data/tapi-common:context'
@@ -70,6 +83,19 @@ class TransportApiDriver(_Driver):
        chk_type('resources', resource_keys, list)
        results = []
        with self.__lock:
            if self.__skip_tapi_queries:
                LOGGER.info('Importing topology from NBI for teraflowsdn device')
                if len(resource_keys) == 0: resource_keys = ALL_RESOURCE_KEYS
                for resource_key in resource_keys:
                    if resource_key == RESOURCE_ENDPOINTS or resource_key == RESOURCE_SERVICES:
                        try:
                            results.extend(self.tac.get_devices_endpoints(self.__import_topology))
                            LOGGER.info('Imported {:d} resources from NBI'.format(len(results)))
                        except Exception as e:
                            LOGGER.exception('Failed to import topology from NBI: {:s}'.format(str(e)))
                            results.append((resource_key, e))
                return results

            if len(resource_keys) == 0: resource_keys = ALL_RESOURCE_KEYS
            for i, resource_key in enumerate(resource_keys):
                str_resource_name = 'resource_key[#{:d}]'.format(i)
@@ -84,31 +110,64 @@ class TransportApiDriver(_Driver):
        if len(resources) == 0:
            return results
        with self.__lock:
            if "tapi_lsp" in str(resources):
            for resource in resources:
                LOGGER.info('resource = {:s}'.format(str(resource)))

                resource_key = resource[0]

                # Handle optical slice resources
                if '/optical_slice/context/' in resource_key:
                    LOGGER.info('=' * 80)
                    LOGGER.info('OPTICAL SLICE RECEIVED')
                    LOGGER.info('=' * 80)
                    try:
                        tapi_tequest(resource)
                        results.append((resource, True))
                        optical_slice_data = json.loads(resource[1])

                        # Extract URL from tapi-common:context
                        url = optical_slice_data.get('url', '')
                        LOGGER.info('URL: %s', url)

                        # Log the full optical slice data
                        LOGGER.info('Optical Slice Data:')
                        optical_slice = optical_slice_data.get('data', {})
                        LOGGER.info(json.dumps(optical_slice, indent=2))
                        LOGGER.info('=' * 80)
                        # TODO DEMO TEST
                        headers = {
                            "Content-Type": "application/yang-data+json",
                            "Accept": "application/yang-data+json",
                            "Expect": ""
                        }
                        result = requests.post(url, headers=headers, data=json.dumps(optical_slice), timeout=10)
                        results.append(True)
                    except Exception as e:
                        LOGGER.error(f'Failed to parse optical slice data: {str(e)}')
                        results.append(e)
                    continue

                # Handle media channel resources
                elif '/media_channel/service/' in resource_key:
                    LOGGER.info('=' * 80)
                    LOGGER.info('MEDIA CHANNEL RECEIVED')
                    LOGGER.info('=' * 80)
                    try:
                        # Import here to avoid circular dependency
                        from .TapiRequestBuilder import create_tapi_request

                        # Generate complete TAPI request from resource data
                        result = create_tapi_request(resource)
                        # LOGGER.info('Result: {:s}'.format(result))
                        results.append(True)

                    except Exception as e:
                        MSG = "Invalid resource_value type: expected dict, got {:s}"
                        results.append((resource, e))
                        LOGGER.error('Failed to create TAPI request: {:s}'.format(str(e)), exc_info=True)
                        results.append(e)

                else:
                for resource in resources:
                    LOGGER.info('resource = {:s}'.format(str(resource)))
                    # Unknown resource type
                    LOGGER.warning(f'Unknown resource type: {resource_key}')
                    results.append(True)  # Don't fail, just log warning

                    uuid = find_key(resource, 'uuid')
                    input_sip = find_key(resource, 'input_sip_uuid')
                    output_sip = find_key(resource, 'output_sip_uuid')
                    capacity_value = find_key(resource, 'capacity_value')
                    capacity_unit = find_key(resource, 'capacity_unit')
                    layer_protocol_name = find_key(resource, 'layer_protocol_name')
                    layer_protocol_qualifier = find_key(resource, 'layer_protocol_qualifier')
                    direction = find_key(resource, 'direction')

                    data = create_connectivity_service(
                        self.__tapi_root, uuid, input_sip, output_sip, direction, capacity_value, capacity_unit,
                        layer_protocol_name, layer_protocol_qualifier, timeout=self.__timeout, auth=self.__auth)
                    results.extend(data)
        return results

    @metered_subclass_method(METRICS_POOL)
+52 −0
Original line number Diff line number Diff line
{
   "tapi-connectivity:connectivity-service" : [
      {
         "connectivity-direction" : "",
         "end-point" : [
            {
               "direction" : "",
               "layer-protocol-name" : "",
               "layer-protocol-qualifier" : "",
               "local-id" : "",
               "service-interface-point" : {
                  "service-interface-point-uuid" : ""
               },

               "tapi-photonic-media:media-channel-connectivity-service-end-point-spec":{
                  "mc-config":{
                     "spectrum":{
                        "lower-frequency": "",
                        "upper-frequency": "",
                        "frequency-constraint":{
                           "adjustment-granularity" : "",
                           "grid-type": ""
                        }
                     }
                  }
               }
            },
            {
               "direction" : "",
               "layer-protocol-name" : "",
               "layer-protocol-qualifier" : "",
               "local-id" : "",
               "service-interface-point" : {
                  "service-interface-point-uuid" : ""
               }
            }
         ],
         "layer-protocol-name" : "",
         "layer-protocol-qualifier" : "",
         "requested-capacity" : {
            "total-size" : {
               "unit" : "",
               "value" : ""
            }
         },
         "include-link":[
         ],
         "route-objective-function" : "10000",
         "uuid" : ""
      }
   ]
}
 No newline at end of file
+124 −0
Original line number Diff line number Diff line
# Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/)
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import logging
import json
from flask_restful import Resource, request
from common.proto.context_pb2 import ConfigActionEnum, ConfigRule, Device, Service, ServiceTypeEnum, ServiceStatusEnum
from device.client.DeviceClient import DeviceClient
from service.client.ServiceClient import ServiceClient
import requests
LOGGER = logging.getLogger(__name__)

class OpticalSliceService(Resource):
    def __init__(self):
        super().__init__()
        self.device_client = DeviceClient()
        self.service_client = ServiceClient()

    def post(self, sliceId: str):
        LOGGER.info("Received POST request for optical slice: %s", sliceId)

        data = request.get_json()
        LOGGER.info("Optical Slice data: %s", json.dumps(data, indent=2))

        if 'data' in data and 'tapi-common:context' in data['data']:
            context = data['data']['tapi-common:context']
        elif 'tapi-common:context' in data:
            context = data['tapi-common:context']
        else:
            return {'status': 'error', 'message': 'Missing tapi-common:context'}, 400

        slice_uuid = context.get('uuid')
        service_interface_points = context.get('service-interface-point', [])
        topology_context = context.get('tapi-topology:topology-context', {})

        LOGGER.info(f"Service Interface Points: {len(service_interface_points)}")

        try:
            service = Service()
            service.service_id.service_uuid.uuid = sliceId
            service.service_id.context_id.context_uuid.uuid = "admin"
            service.service_type = ServiceTypeEnum.SERVICETYPE_TAPI_CONNECTIVITY_SERVICE
            service.service_status.service_status = ServiceStatusEnum.SERVICESTATUS_ACTIVE
            service.name = f"OpticalSlice-{slice_uuid}"

            service_response = self.service_client.CreateService(service)
            LOGGER.info("Created TFS optical slice service: %s", service_response)

        except Exception as e:
            LOGGER.error("Failed to create TFS optical slice service: %s", str(e), exc_info=True)
            return {'status': 'error', 'message': f'Failed to create TFS service: {str(e)}'}, 500

        device_id_str = "TFS-TAPI"
        try:
            device = Device()
            device.device_id.device_uuid.uuid = device_id_str
            config_rule = ConfigRule()
            config_rule.action = ConfigActionEnum.CONFIGACTION_SET
            config_rule.custom.resource_key = f'/optical_slice/context/{slice_uuid}'
            config_rule.custom.resource_value = json.dumps(data)
            device.device_config.config_rules.append(config_rule)
            self.device_client.ConfigureDevice(device)
            LOGGER.info("Configured device %s with optical slice %s", device_id_str, sliceId)

        except Exception as e:
            LOGGER.error("Failed to configure device: %s", str(e))
            return {'status': 'error', 'message': f'Failed to configure device: {str(e)}'}, 500

        return {
            'status': 'success',
            'message': f'Optical slice created for {sliceId}',
            'sliceId': sliceId,
            'slice_uuid': slice_uuid
        }, 201

    def delete(self, sliceId: str):
        LOGGER.info("Received DELETE request for optical slice: %s", sliceId)

        try:
            from common.proto.context_pb2 import ServiceId

            service_id = ServiceId()
            service_id.service_uuid.uuid = sliceId
            service_id.context_id.context_uuid.uuid = "admin"

            self.service_client.DeleteService(service_id)
            LOGGER.info("Deleted TFS service: %s", sliceId)

        except Exception as e:
            LOGGER.error("Failed to delete TFS optical slice service: %s", str(e), exc_info=True)
            return {'status': 'error', 'message': f'Failed to delete TFS service: {str(e)}'}, 500

        headers = {
                    "Content-Type": "application/json",
                    "Expect": ""
                }
        try:
            # TODO Dynamic IP
            url = f'http://11.1.1.101:4900/restconf/data/tapi-common:context={sliceId}'
            response = requests.delete(url, headers=headers, timeout=10)
            LOGGER.info("Deleted optical slice from device %s: %s", sliceId, url)

        except Exception as e:
            LOGGER.warning("Failed to delete from device: %s", str(e))
            return {'status': 'error', 'message': f'Failed to delete from device: {str(e)}'}, 500


        return {
            'status': 'success',
            'message': f'Optical Slice deleted for {sliceId}',
            'sliceId': sliceId,
            'service_uuid': sliceId
        }, 200
Loading