diff --git a/src/nbi/service/tfs_api/Resources.py b/src/nbi/service/tfs_api/Resources.py index 3e183220073c72d883240df9ce8a31413d56efd6..52f6867bb3caee5c2f49757927739ac54206af8f 100644 --- a/src/nbi/service/tfs_api/Resources.py +++ b/src/nbi/service/tfs_api/Resources.py @@ -240,7 +240,10 @@ class Services(_Resource): class Service(_Resource): def get(self, context_uuid : str, service_uuid : str): - return format_grpc_to_json(self.context_client.GetService(grpc_service_id(context_uuid, service_uuid))) + try: + return format_grpc_to_json(self.context_client.GetService(grpc_service_id(context_uuid, service_uuid))) + except grpc.RpcError as exc: + return _format_grpc_error(exc) def put(self, context_uuid : str, service_uuid : str): service = request.get_json() @@ -249,10 +252,87 @@ class Service(_Resource): if service_uuid != service['service_id']['service_uuid']['uuid']: raise BadRequest('Mismatching service_uuid') svc = format_service_custom_config_rules(service) - return format_grpc_to_json(self.service_client.UpdateService(grpc_service(svc))) + try: + return format_grpc_to_json(self.service_client.UpdateService(grpc_service(svc))) + except grpc.RpcError as exc: + return _format_grpc_error(exc) def delete(self, context_uuid : str, service_uuid : str): - return format_grpc_to_json(self.service_client.DeleteService(grpc_service_id(context_uuid, service_uuid))) + try: + return format_grpc_to_json(self.service_client.DeleteService(grpc_service_id(context_uuid, service_uuid))) + except grpc.RpcError as exc: + return _format_grpc_error(exc) + +def _decode_custom_value(value): + if not isinstance(value, str): + return value + try: + return json.loads(value) + except json.JSONDecodeError: + return value + +def _service_custom_settings(service): + settings = {} + for config_rule in service.get('service_config', {}).get('config_rules', []): + custom = config_rule.get('custom') + if custom is None: + continue + resource_key = custom.get('resource_key') + if resource_key is None: + continue + settings[resource_key] = _decode_custom_value(custom.get('resource_value')) + return settings + +def _slot_range(slots): + if not isinstance(slots, list) or len(slots) == 0: + return None + slot_values = sorted([int(slot) for slot in slots]) + if slot_values == list(range(slot_values[0], slot_values[-1] + 1)): + return { + 'n_start': slot_values[0], + 'n_end': slot_values[-1], + } + return None + +class OpticalServiceAllocation(_Resource): + def get(self, context_uuid : str, service_uuid : str): + service_id = grpc_service_id(context_uuid, service_uuid) + try: + service = grpc_message_to_json(self.context_client.GetService(service_id)) + connections = grpc_message_to_json(self.context_client.ListConnections(service_id)) + except grpc.RpcError as exc: + return _format_grpc_error(exc) + settings = _service_custom_settings(service) + allocation_settings = settings.get('/settings') + if not isinstance(allocation_settings, dict): + return { + 'error': 'OPTICAL_ALLOCATION_NOT_FOUND', + 'message': 'Service does not expose optical allocation settings', + }, 404 + if 'flow_id' not in allocation_settings: + return { + 'error': 'OPTICAL_ALLOCATION_NOT_FOUND', + 'message': 'Service settings do not include an optical flow identifier', + }, 404 + + slots = allocation_settings.get('slots') + allocation = { + 'service_id': service.get('service_id'), + 'service_status': service.get('service_status'), + 'service_type': service.get('service_type'), + 'flow_id': allocation_settings.get('flow_id'), + 'optical_band_id': allocation_settings.get('ob_id'), + 'band_type': allocation_settings.get('band_type'), + 'frequency': allocation_settings.get('frequency'), + 'bandwidth': allocation_settings.get('band'), + 'slots': slots, + 'slot_range': _slot_range(slots), + 'path': allocation_settings.get('path'), + 'links': allocation_settings.get('links'), + 'bidirectional': bool(allocation_settings.get('bidir', 0)), + 'connections': connections.get('connections', []), + } + return jsonify(allocation) class SliceIds(_Resource): def get(self, context_uuid : str): diff --git a/src/nbi/service/tfs_api/__init__.py b/src/nbi/service/tfs_api/__init__.py index 2e666f252cc77e41d1bb331d3eff4412c1c12287..9f01898173f5b589d8cae06dc98e198ad7e0abb2 100644 --- a/src/nbi/service/tfs_api/__init__.py +++ b/src/nbi/service/tfs_api/__init__.py @@ -24,7 +24,7 @@ from .Resources import ( OpticalSpectrumReservationRelease, OpticalSpectrumReservations, OpticalConnectivityCandidates, PolicyRule, PolicyRuleIds, PolicyRules, - Service, ServiceIds, Services, + Service, ServiceIds, Services, OpticalServiceAllocation, Slice, SliceIds, Slices, Topologies, Topology, TopologyDetails, TopologyIds, E2epathcomp @@ -51,6 +51,8 @@ _RESOURCES = [ ('api.service_ids', ServiceIds, '/context//service_ids'), ('api.services', Services, '/context//services'), ('api.service', Service, '/context//service/'), + ('api.optical_service_allocation', OpticalServiceAllocation, + '/context//service//optical_allocation'), ('api.slice_ids', SliceIds, '/context//slice_ids'), ('api.slices', Slices, '/context//slices'), diff --git a/src/nbi/tests/test_tfs_api_optical_allocation.py b/src/nbi/tests/test_tfs_api_optical_allocation.py new file mode 100644 index 0000000000000000000000000000000000000000..15157427708e0a7308a62c6bd8b1a4750fe9739e --- /dev/null +++ b/src/nbi/tests/test_tfs_api_optical_allocation.py @@ -0,0 +1,62 @@ +# 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 grpc + +from nbi.service.tfs_api.Resources import _format_grpc_error, _service_custom_settings, _slot_range + + +class _RpcError(grpc.RpcError): + def __init__(self, code, details): + super().__init__() + self._code = code + self._details = details + + def code(self): + return self._code + + def details(self): + return self._details + + +def test_slot_range_compacts_contiguous_slots(): + assert _slot_range([53, 51, 52]) == {'n_start': 51, 'n_end': 53} + + +def test_slot_range_returns_none_for_sparse_slots(): + assert _slot_range([51, 53]) is None + + +def test_service_custom_settings_decodes_json_values(): + service = { + 'service_config': { + 'config_rules': [{ + 'custom': { + 'resource_key': '/settings', + 'resource_value': '{"flow_id": 1, "slots": [51, 52]}', + }, + }], + }, + } + + settings = _service_custom_settings(service) + + assert settings['/settings'] == {'flow_id': 1, 'slots': [51, 52]} + + +def test_grpc_not_found_maps_to_http_404(): + body, status = _format_grpc_error(_RpcError(grpc.StatusCode.NOT_FOUND, 'service not found')) + + assert status == 404 + assert body['error'] == 'NOT_FOUND' diff --git a/src/opticalcontroller/OpticalController.py b/src/opticalcontroller/OpticalController.py index fd6b7f5a12ad061b8a41c41b4a65cea6396ad84d..9171d370369f5b74231e47b49d08bed217ca0090 100644 --- a/src/opticalcontroller/OpticalController.py +++ b/src/opticalcontroller/OpticalController.py @@ -70,8 +70,21 @@ class AddLightpath(Resource): rsa.g.printGraph() if rsa is not None: - flow_id = rsa.rsa_computation(src, dst, bitrate, bidir) + reservation = request.args.get("reservation") + if reservation is None and request.args.get("reservation_band") is not None: + reservation = { + "band": request.args.get("reservation_band"), + "n_start": request.args.get("reservation_start"), + "n_end": request.args.get("reservation_end"), + } + try: + flow_id = rsa.rsa_computation(src, dst, bitrate, bidir, reservation=reservation) + except ValueError as exc: + LOGGER.warning("Invalid optical spectrum reservation: %s", str(exc)) + return str(exc), 400 if rsa.db_flows[flow_id]["op-mode"] == 0: + if reservation is not None: + return 'Requested optical spectrum is unavailable', 409 return 'No path found', 404 t1 = time.time() * 1000.0 elapsed = t1 - t0 diff --git a/src/opticalcontroller/RSA.py b/src/opticalcontroller/RSA.py index d9c73a1d323f66b15ec9e7f03ddeebfb5a768cb5..d5b84f5daa62d3df641ac9ef9991d82164ff5482 100644 --- a/src/opticalcontroller/RSA.py +++ b/src/opticalcontroller/RSA.py @@ -904,7 +904,7 @@ class RSA(): return t_flows, band, slots, {}, {} - def rsa_computation(self, src, dst, rate, bidir): + def rsa_computation(self, src, dst, rate, bidir, reservation=None): if self.flow_id == 0: self.flow_id += 1 else: @@ -934,15 +934,16 @@ class RSA(): print(s_slots) #@Chafy if len(c_slots) > 0 or len(l_slots) > 0 or len(s_slots) > 0: - flow_list, band_range, slots, fiber_f, fiber_b = self.select_slots_and_ports(links, num_slots, c_slots, - l_slots, s_slots, bidir) + flow_list, band_range, slots, fiber_f, fiber_b = self.select_slots_and_ports( + links, num_slots, c_slots, l_slots, s_slots, bidir, reservation=reservation + ) + if flow_list is None: + self.null_values(self.flow_id) + return self.flow_id f0, band = frequency_converter(band_range, slots) if debug: print(f0, band) print("INFO: RSA completed for normal wavelenght connection") - if flow_list is None: - self.null_values(self.flow_id) - return self.flow_id slots_i = [] for i in slots: slots_i.append(int(i)) diff --git a/src/service/service/ServiceServiceServicerImpl.py b/src/service/service/ServiceServiceServicerImpl.py index b7864f521c02044da569561e6ac4b5abf5995cd2..688dcfe9a9a3bb01a03cabd61074fac70e993c67 100644 --- a/src/service/service/ServiceServiceServicerImpl.py +++ b/src/service/service/ServiceServiceServicerImpl.py @@ -45,7 +45,7 @@ from .tools.GeodesicDistance import gps_distance from .tools.OpticalTools import ( add_flex_lightpath, add_lightpath, delete_lightpath, adapt_reply, get_device_name_from_uuid, get_optical_band, refresh_opticalcontroller, DelFlexLightpath , extend_optical_band, - reconfig_flex_lightpath, adapt_reply_ob, add_alien_flex_lightpath + reconfig_flex_lightpath, adapt_reply_ob, add_alien_flex_lightpath, load_opticalcontroller_reply ) from .tools.OpticalSpectrumReservation import parse_optical_spectrum_reservation_constraints @@ -350,7 +350,9 @@ class ServiceServiceServicerImpl(ServiceServiceServicer): spectrum_reservation=spectrum_reservation ) elif oc_type == 2: - reply_txt = add_lightpath(src, dst, bitrate, bidir) + reply_txt = add_lightpath( + src, dst, bitrate, bidir, spectrum_reservation=spectrum_reservation + ) else: reply_txt = add_flex_lightpath( src, dst, bitrate, bidir, preferred, ob_band, dj_optical_band_id, @@ -358,7 +360,7 @@ class ServiceServiceServicerImpl(ServiceServiceServicer): ) if reply_txt is None: return service_with_uuids.service_id - reply_json = json.loads(reply_txt) + reply_json = load_opticalcontroller_reply(reply_txt) LOGGER.debug('[optical] reply_json[{:s}]={:s}'.format(str(type(reply_json)), str(reply_json))) optical_band_txt = "" diff --git a/src/service/service/tools/OpticalTools.py b/src/service/service/tools/OpticalTools.py index e136154971b413eb0e1135e32bb682b662f7cdd4..fc1df76a5a6a63742b32c1c398f0ad049ba0b45d 100644 --- a/src/service/service/tools/OpticalTools.py +++ b/src/service/service/tools/OpticalTools.py @@ -16,7 +16,7 @@ import functools, json, logging, requests, uuid from typing import Dict, List, Optional, Tuple from urllib.parse import urlencode -from common.method_wrappers.ServiceExceptions import NotFoundException +from common.method_wrappers.ServiceExceptions import AlreadyExistsException, NotFoundException, OperationFailedException from common.proto.context_pb2 import( ConfigActionEnum, ConfigRule, ConfigRule_Custom, Connection, ContextId, Device, DeviceId, Empty, EndPointId, OpticalBand, OpticalBandId, OpticalBandList, @@ -42,6 +42,21 @@ LOGGER = logging.getLogger(__name__) TESTING = False +def load_opticalcontroller_reply(reply_txt : str) -> Dict: + try: + reply_json = json.loads(reply_txt) + except json.JSONDecodeError as exc: + raise OperationFailedException( + 'optical-controller-reply', + extra_details='reply={:s}'.format(str(reply_txt)) + ) from exc + if not isinstance(reply_json, dict): + raise OperationFailedException( + 'optical-controller-reply', + extra_details='reply={:s}'.format(str(reply_txt)) + ) + return reply_json + get_optical_controller_setting = functools.partial(get_env_var_name, ServiceNameEnum.OPTICALCONTROLLER) VAR_NAME_OPTICAL_CTRL_BASEURL_HTTP = get_optical_controller_setting(ENVVAR_SUFIX_SERVICE_BASEURL_HTTP) VAR_NAME_OPTICAL_CTRL_SCHEMA = get_optical_controller_setting('SCHEMA') @@ -73,6 +88,20 @@ def get_optical_controller_base_url() -> str: return base_url +def _raise_for_opticalcontroller_error(response, operation: str, spectrum_reservation: Optional[Dict] = None) -> None: + status_code = getattr(response, 'status_code', 200) + response_text = getattr(response, 'text', '') + if status_code == 409: + raise AlreadyExistsException( + 'OpticalSpectrumReservation', str(spectrum_reservation), extra_details=response_text + ) + if status_code >= 400: + raise OperationFailedException( + operation, + extra_details='HTTP {:d}: {:s}'.format(status_code, response_text) + ) + + def get_uuids_from_names( devices : List[Device], device_name : str, port_name : str ) -> Tuple[str, str]: @@ -168,6 +197,9 @@ def add_flex_lightpath( urlx = '{:s}?{:s}'.format(urlx, urlencode(query)) r = requests.put(urlx, headers=headers) LOGGER.debug(f"addpathlight {r}") + _raise_for_opticalcontroller_error( + r, 'optical-controller-add-flex-lightpath', spectrum_reservation=spectrum_reservation + ) reply = r.text return reply else: @@ -189,7 +221,7 @@ def add_alien_flex_lightpath(src, s_port, dst, d_port, band, ob_id, bidir=None) reply = r.text return reply -def add_lightpath(src, dst, bitrate, bidir) -> str: +def add_lightpath(src, dst, bitrate, bidir, spectrum_reservation: Optional[Dict] = None) -> str: if not TESTING: urlx = "" headers = {"Content-Type": "application/json"} @@ -197,8 +229,18 @@ def add_lightpath(src, dst, bitrate, bidir) -> str: if bidir is None: bidir = 1 urlx = "{:s}/AddLightpath/{:s}/{:s}/{:s}/{:s}".format(base_url, src, dst, str(bitrate), str(bidir)) + if spectrum_reservation is not None: + query = { + 'reservation_band': spectrum_reservation['band'], + 'reservation_start': str(spectrum_reservation['n_start']), + 'reservation_end': str(spectrum_reservation['n_end']), + } + urlx = '{:s}?{:s}'.format(urlx, urlencode(query)) r = requests.put(urlx, headers=headers) LOGGER.debug(f"addpathlight {r}") + _raise_for_opticalcontroller_error( + r, 'optical-controller-add-lightpath', spectrum_reservation=spectrum_reservation + ) reply = r.text return reply else: @@ -541,6 +583,9 @@ def adapt_reply( op_mode = r["op-mode"] if "op-mode" in r else None frequency = r["freq"] if "freq" in r else None flow_id = r["flow_id"] if "flow_id" in r else None + slots = r["slots"] if "slots" in r else None + links = r["links"] if "links" in r else None + path = r["path"] if "path" in r else None r_type = r["band_type"] if "band_type" in r else None if r_type == "l_slots": band_type = "L_BAND" @@ -553,6 +598,12 @@ def adapt_reply( val = {"target-output-power": "1.0", "frequency": frequency, "operational-mode": op_mode, "band": band, "flow_id": flow_id, "ob_id": ob_id, "band_type": band_type, "bidir": bidir_f} else: val = {"target-output-power": "1.0", "frequency": frequency, "operational-mode": op_mode, "band": band, "flow_id": flow_id, "band_type": band_type, "bidir": bidir_f} + if slots is not None: + val["slots"] = slots + if links is not None: + val["links"] = links + if path is not None: + val["path"] = path custom_rule = ConfigRule_Custom(resource_key="/settings", resource_value=json.dumps(val)) rule = ConfigRule(action=ConfigActionEnum.CONFIGACTION_SET, custom=custom_rule) service.service_config.config_rules.add().CopyFrom(rule) diff --git a/src/service/tests/test_optical_spectrum_reservation.py b/src/service/tests/test_optical_spectrum_reservation.py index fa1074d3b53b6891bf21fa75e236d9c59dbad875..83880f2bce339d1c8d5c127159ebb1792aeda288 100644 --- a/src/service/tests/test_optical_spectrum_reservation.py +++ b/src/service/tests/test_optical_spectrum_reservation.py @@ -12,6 +12,9 @@ # See the License for the specific language governing permissions and # limitations under the License. +import pytest + +from common.method_wrappers.ServiceExceptions import AlreadyExistsException from common.proto.context_pb2 import Constraint, Constraint_Custom from service.service.tools.OpticalSpectrumReservation import parse_optical_spectrum_reservation_constraints from service.service.tools import OpticalTools @@ -72,3 +75,54 @@ def test_add_flex_lightpath_forwards_reservation_query(monkeypatch): assert "reservation_band=c_slots" in captured["url"] assert "reservation_start=51" in captured["url"] assert "reservation_end=66" in captured["url"] + + +def test_add_lightpath_forwards_reservation_query(monkeypatch): + captured = {} + + class Response: + text = "{}" + + def put(url, headers=None): + captured["url"] = url + captured["headers"] = headers + return Response() + + monkeypatch.setattr(OpticalTools, "get_optical_controller_base_url", lambda: "http://optical/OpticalTFS") + monkeypatch.setattr(OpticalTools.requests, "put", put) + monkeypatch.setattr(OpticalTools, "TESTING", False) + + OpticalTools.add_lightpath( + "T1", + "T2", + 100, + 0, + spectrum_reservation={"band": "c_slots", "n_start": 51, "n_end": 66}, + ) + + assert captured["url"].startswith("http://optical/OpticalTFS/AddLightpath/T1/T2/100/0?") + assert "reservation_band=c_slots" in captured["url"] + assert "reservation_start=51" in captured["url"] + assert "reservation_end=66" in captured["url"] + + +def test_add_lightpath_maps_reservation_conflict_to_already_exists(monkeypatch): + class Response: + status_code = 409 + text = "Requested optical spectrum is unavailable" + + def put(url, headers=None): + return Response() + + monkeypatch.setattr(OpticalTools, "get_optical_controller_base_url", lambda: "http://optical/OpticalTFS") + monkeypatch.setattr(OpticalTools.requests, "put", put) + monkeypatch.setattr(OpticalTools, "TESTING", False) + + with pytest.raises(AlreadyExistsException): + OpticalTools.add_lightpath( + "T1", + "T2", + 100, + 0, + spectrum_reservation={"band": "c_slots", "n_start": 51, "n_end": 66}, + )