From e2f44851ffbd49adf20e118c3f327e484f66d137 Mon Sep 17 00:00:00 2001 From: gifrerenom Date: Tue, 16 Jun 2026 14:21:58 +0000 Subject: [PATCH 01/11] Optical Controller component: - Added optional spectrum reservation --- src/opticalcontroller/OpticalController.py | 13 ++- src/opticalcontroller/RSA.py | 41 +++++---- .../tests/test_spectrum_reservation.py | 90 +++++++++++++++++++ src/opticalcontroller/tools.py | 79 +++++++++++++++- 4 files changed, 202 insertions(+), 21 deletions(-) create mode 100644 src/opticalcontroller/tests/test_spectrum_reservation.py diff --git a/src/opticalcontroller/OpticalController.py b/src/opticalcontroller/OpticalController.py index 51a560f31..ca3d8e318 100644 --- a/src/opticalcontroller/OpticalController.py +++ b/src/opticalcontroller/OpticalController.py @@ -13,7 +13,7 @@ # limitations under the License. import logging, time -from flask import Flask +from flask import Flask, request from flask import render_template from common.DeviceTypes import DeviceTypeEnum from flask_restplus import Resource, Api @@ -88,7 +88,16 @@ class AddFlexLightpath(Resource): # rsa.g.printGraph() if rsa is not None: - flow_id, optical_band_id = rsa.rsa_fs_computation(src, dst, bitrate, bidir, band, obx_idx, pref) + 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"), + } + flow_id, optical_band_id = rsa.rsa_fs_computation( + src, dst, bitrate, bidir, band, obx_idx, pref, reservation=reservation + ) if flow_id is not None: if rsa.db_flows[flow_id]["op-mode"] == 0: return 'No path found', 404 diff --git a/src/opticalcontroller/RSA.py b/src/opticalcontroller/RSA.py index 106c73696..9a48a9e34 100644 --- a/src/opticalcontroller/RSA.py +++ b/src/opticalcontroller/RSA.py @@ -614,10 +614,13 @@ class RSA(): return fiber_list #function invoked for lightpaths and OB - def select_slots_and_ports(self, links, n_slots, c, l, s, bidir, preferred=None): + def select_slots_and_ports(self, links, n_slots, c, l, s, bidir, preferred=None, reservation=None): if debug: print (links, n_slots, c, l, s, bidir, self.c_slot_number, self.l_slot_number, self.s_slot_number) - band, slots = slot_selection(c, l, s, n_slots, self.c_slot_number, self.l_slot_number, self.s_slot_number, preferred) + band, slots = slot_selection( + c, l, s, n_slots, self.c_slot_number, self.l_slot_number, self.s_slot_number, + preferred, reservation=reservation + ) if debug: print (band, slots) if band is None: @@ -686,10 +689,13 @@ class RSA(): return t_flows, band, slots, {}, {} #function ivoked for fs lightpaths only - def select_slots_and_ports_fs(self, links, n_slots, c, l, s, bidir, o_band_id): + def select_slots_and_ports_fs(self, links, n_slots, c, l, s, bidir, o_band_id, reservation=None): if debug: print(self.links_dict) - band, slots = slot_selection(c, l, s, n_slots, self.c_slot_number, self.l_slot_number, self.s_slot_number) + band, slots = slot_selection( + c, l, s, n_slots, self.c_slot_number, self.l_slot_number, self.s_slot_number, + reservation=reservation + ) if band is None: print("ERROR: No slots available in the three bands") return None, None, None, None, None @@ -953,7 +959,7 @@ class RSA(): #self.db_flows[flow_id]["parent_opt_band"] = 0 #self.db_flows[flow_id]["new_optical_band"] = 0 - def create_optical_band(self, links, path, bidir, num_slots, old_band_x=None, preferred=None): + def create_optical_band(self, links, path, bidir, num_slots, old_band_x=None, preferred=None, reservation=None): print("INFO: Creating optical-band of {} slots".format(num_slots)) if self.opt_band_id == 0: self.opt_band_id += 1 @@ -1008,7 +1014,9 @@ class RSA(): print(l_slots) print(s_slots) 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, preferred) + flow_list, band_range, slots, fiber_f, fiber_b = self.select_slots_and_ports( + links, num_slots, c_slots, l_slots, s_slots, bidir, preferred, reservation=reservation + ) if debug: print(flow_list, band_range, slots, fiber_f, fiber_b) f0, band = frequency_converter(band_range, slots) @@ -1203,7 +1211,7 @@ class RSA(): self.null_values(self.flow_id) return self.flow_id - def rsa_fs_computation(self, src, dst, rate, bidir, band, bandx_id, preferred=None): + def rsa_fs_computation(self, src, dst, rate, bidir, band, bandx_id, preferred=None, reservation=None): if band is not None: num_slots_ob = map_band_to_slot(band) print(band, num_slots_ob) @@ -1227,7 +1235,9 @@ class RSA(): if len(path) < 1: self.null_values_ob(self.opt_band_id) return self.opt_band_id, [] - optical_band_id, temp_links = self.create_optical_band(links, path, bidir, num_slots_ob, old_band_x, preferred) + optical_band_id, temp_links = self.create_optical_band( + links, path, bidir, num_slots_ob, old_band_x, preferred, reservation=reservation + ) return None, optical_band_id print("INFO: TP to TP connection") if self.flow_id == 0: @@ -1285,7 +1295,7 @@ class RSA(): flow_list, band_range, slots, fiber_f, fiber_b = self.select_slots_and_ports_fs(temp_links2, num_slots, c_slots, l_slots, s_slots, bidir, - ob_id) + ob_id, reservation=reservation) f0, band = frequency_converter(band_range, slots) if debug: print(f0, band) @@ -1345,7 +1355,7 @@ class RSA(): temp_links2, num_slots, c_slots, l_slots, s_slots, bidir, - ob_id) + ob_id, reservation=reservation) f0, band = frequency_converter(band_range, slots) if debug: print(f0, band) @@ -1399,7 +1409,7 @@ class RSA(): flow_list, band_range, slots, fiber_f, fiber_b = self.select_slots_and_ports_fs(temp_links2, num_slots, c_slots, l_slots, s_slots, bidir, - ob_id) + ob_id, reservation=reservation) f0, band = frequency_converter(band_range, slots) if debug: print(f0, band) @@ -1465,7 +1475,7 @@ class RSA(): temp_links2, num_slots, c_slots, l_slots, s_slots, bidir, - ob_id) + ob_id, reservation=reservation) f0, band = frequency_converter(band_range, slots) if debug: print(f0, band) @@ -1510,7 +1520,7 @@ class RSA(): print("INFO: optical-band width specified") #if no OB I create a new one links, path = self.compute_path(src, dst) - optical_band_id, temp_links = self.create_optical_band(links, path, bidir, num_slots_ob) + optical_band_id, temp_links = self.create_optical_band(links, path, bidir, num_slots_ob, reservation=reservation) op, num_slots = map_rate_to_slot(rate) if debug: print(temp_links) @@ -1520,8 +1530,9 @@ class RSA(): print(l_slots) print(s_slots) 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_fs(temp_links, num_slots, c_slots, - l_slots, s_slots, bidir, optical_band_id) + flow_list, band_range, slots, fiber_f, fiber_b = self.select_slots_and_ports_fs( + temp_links, num_slots, c_slots, l_slots, s_slots, bidir, optical_band_id, reservation=reservation + ) f0, band = frequency_converter(band_range, slots) if debug: print(f0, band) diff --git a/src/opticalcontroller/tests/test_spectrum_reservation.py b/src/opticalcontroller/tests/test_spectrum_reservation.py new file mode 100644 index 000000000..e19e319e9 --- /dev/null +++ b/src/opticalcontroller/tests/test_spectrum_reservation.py @@ -0,0 +1,90 @@ +# 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 pytest + +from opticalcontroller.tools import parse_slot_reservation, slot_selection + + +def test_parse_compact_optical_spectrum_reservation(): + reservation = parse_slot_reservation("C_BAND:51-66") + + assert reservation == {"band": "c_slots", "n_start": 51, "n_end": 66} + + +def test_parse_dict_optical_spectrum_reservation(): + reservation = parse_slot_reservation({"band": "l_slots", "n_start": "4", "n_end": "11"}) + + assert reservation == {"band": "l_slots", "n_start": 4, "n_end": 11} + + +def test_slot_selection_without_reservation_keeps_first_fit(): + band, slots = slot_selection( + c=list(range(0, 10)), + l=list(range(20, 30)), + s=list(range(40, 50)), + n_slots=4, + Nc=10, + Nl=10, + Ns=10, + ) + + assert band == "c_slots" + assert slots == [0, 1, 2, 3] + + +def test_slot_selection_with_reservation_consumes_requested_band_and_range(): + band, slots = slot_selection( + c=list(range(0, 100)), + l=list(range(0, 100)), + s=list(range(0, 100)), + n_slots=4, + Nc=100, + Nl=100, + Ns=100, + reservation={"band": "l_slots", "n_start": 20, "n_end": 30}, + ) + + assert band == "l_slots" + assert slots == [20, 21, 22, 23] + + +def test_slot_selection_with_unavailable_reservation_returns_none(): + band, slots = slot_selection( + c=[0, 1, 2, 3], + l=[10, 11, 13, 14], + s=[20, 21, 22, 23], + n_slots=3, + Nc=4, + Nl=4, + Ns=4, + reservation={"band": "l_slots", "n_start": 10, "n_end": 12}, + ) + + assert band is None + assert slots is None + + +def test_slot_selection_rejects_too_narrow_reservation(): + with pytest.raises(ValueError): + slot_selection( + c=list(range(0, 100)), + l=[], + s=[], + n_slots=4, + Nc=100, + Nl=0, + Ns=0, + reservation={"band": "c_slots", "n_start": 20, "n_end": 22}, + ) diff --git a/src/opticalcontroller/tools.py b/src/opticalcontroller/tools.py index 78a47da8e..d3d40a6c1 100644 --- a/src/opticalcontroller/tools.py +++ b/src/opticalcontroller/tools.py @@ -12,7 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -import numpy as np +import json, logging, numpy as np +from typing import Dict, List, Optional, Tuple from opticalcontroller.variables import * import json , logging from context.client.ContextClient import ContextClient @@ -251,9 +252,68 @@ def get_links_to_node(topology, node): return result -def slot_selection(c, l, s, n_slots, Nc, Nl, Ns, preferred=None): +def _normalize_reservation_band(band: str) -> str: + normalized_band = str(band).strip().lower().replace("-", "_") + if normalized_band in {"c", "c_band", "c_slots"}: + return "c_slots" + if normalized_band in {"l", "l_band", "l_slots"}: + return "l_slots" + if normalized_band in {"s", "s_band", "s_slots"}: + return "s_slots" + raise ValueError("Unsupported optical spectrum band: {:s}".format(str(band))) + + +def parse_slot_reservation(reservation) -> Optional[Dict[str, int]]: + if reservation is None: + return None + + if isinstance(reservation, dict): + if len(reservation) == 0: + return None + band = reservation.get("band") + n_start = reservation.get("n_start", reservation.get("start")) + n_end = reservation.get("n_end", reservation.get("end")) + else: + reservation_text = str(reservation).strip() + if len(reservation_text) == 0: + return None + if reservation_text.startswith("{"): + return parse_slot_reservation(json.loads(reservation_text)) + if ":" not in reservation_text or "-" not in reservation_text: + raise ValueError( + "Optical spectrum reservation must be ':-' or JSON" + ) + band, slot_range = reservation_text.split(":", maxsplit=1) + n_start, n_end = slot_range.split("-", maxsplit=1) + + band = _normalize_reservation_band(band) + n_start = int(n_start) + n_end = int(n_end) + if n_start < 0 or n_end < n_start: + raise ValueError( + "Invalid optical spectrum reservation range: {:d}-{:d}".format(n_start, n_end) + ) + return {"band": band, "n_start": n_start, "n_end": n_end} + + +def _select_reserved_slots(available_slots: List[int], n_slots: int, reservation: Dict[str, int]) -> Optional[List[int]]: + requested_slots = list(range(reservation["n_start"], reservation["n_end"] + 1)) + if len(requested_slots) < n_slots: + raise ValueError( + "Optical spectrum reservation provides {:d} slots, but {:d} are required".format( + len(requested_slots), n_slots + ) + ) + + selected_slots = requested_slots[0:n_slots] + if not list_in_list(selected_slots, sorted(available_slots)): + return None + return selected_slots + + +def slot_selection(c, l, s, n_slots, Nc, Nl, Ns, preferred=None, reservation=None): # First Fit - + reservation = parse_slot_reservation(reservation) if isinstance(n_slots, int): slot_c = n_slots slot_l = n_slots @@ -262,6 +322,18 @@ def slot_selection(c, l, s, n_slots, Nc, Nl, Ns, preferred=None): slot_c = Nc slot_l = Nl slot_s = Ns + if reservation is not None: + band = reservation["band"] + if band == "c_slots": + selected_slots = _select_reserved_slots(c, slot_c, reservation) + elif band == "l_slots": + selected_slots = _select_reserved_slots(l, slot_l, reservation) + else: + selected_slots = _select_reserved_slots(s, slot_s, reservation) + if selected_slots is None: + return None, None + return band, selected_slots + if preferred == None or preferred == "ANY": if len(c) >= slot_c: return "c_slots", c[0: slot_c] @@ -371,4 +443,3 @@ def set_link_update (fib:dict,link:dict,test="updating"): except Exception as err: print (f"setOpticalLink {err}") - -- GitLab From 8ad856a8c61c4edf7f4f07fbddae18be5921a349 Mon Sep 17 00:00:00 2001 From: gifrerenom Date: Tue, 16 Jun 2026 14:23:12 +0000 Subject: [PATCH 02/11] Service component: - Added optional spectrum reservation --- .../service/ServiceServiceServicerImpl.py | 17 ++++- .../tools/OpticalSpectrumReservation.py | 75 +++++++++++++++++++ src/service/service/tools/OpticalTools.py | 14 +++- .../test_optical_spectrum_reservation.py | 74 ++++++++++++++++++ 4 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 src/service/service/tools/OpticalSpectrumReservation.py create mode 100644 src/service/tests/test_optical_spectrum_reservation.py diff --git a/src/service/service/ServiceServiceServicerImpl.py b/src/service/service/ServiceServiceServicerImpl.py index 35b279ab3..b7864f521 100644 --- a/src/service/service/ServiceServiceServicerImpl.py +++ b/src/service/service/ServiceServiceServicerImpl.py @@ -47,6 +47,7 @@ from .tools.OpticalTools import ( get_optical_band, refresh_opticalcontroller, DelFlexLightpath , extend_optical_band, reconfig_flex_lightpath, adapt_reply_ob, add_alien_flex_lightpath ) +from .tools.OpticalSpectrumReservation import parse_optical_spectrum_reservation_constraints @@ -54,6 +55,7 @@ LOGGER = logging.getLogger(__name__) METRICS_POOL = MetricsPool('Service', 'RPC') + class ServiceServiceServicerImpl(ServiceServiceServicer): def __init__(self, service_handler_factory : ServiceHandlerFactory) -> None: LOGGER.debug('Creating Servicer...') @@ -296,11 +298,16 @@ class ServiceServiceServicerImpl(ServiceServiceServicer): alien = 0 alien_band = 0 alien_optical_band_id = 0 + spectrum_reservation = parse_optical_spectrum_reservation_constraints(service.service_constraints) for constraint in service.service_constraints: + if constraint.WhichOneof('constraint') != 'custom': + continue if "alien" in constraint.custom.constraint_type: alien = 1 break for constraint in service.service_constraints: + if constraint.WhichOneof('constraint') != 'custom': + continue if alien == 1: if "alien_spectrum" in constraint.custom.constraint_type: alien_band = int(constraint.custom.constraint_value) @@ -338,11 +345,17 @@ class ServiceServiceServicerImpl(ServiceServiceServicer): reply_txt = add_alien_flex_lightpath(src, ports[0], dst, ports[1], alien_band, alien_optical_band_id, bidir) else: if oc_type == 1: - reply_txt = add_flex_lightpath(src, dst, bitrate, bidir, preferred, ob_band, dj_optical_band_id) + reply_txt = add_flex_lightpath( + src, dst, bitrate, bidir, preferred, ob_band, dj_optical_band_id, + spectrum_reservation=spectrum_reservation + ) elif oc_type == 2: reply_txt = add_lightpath(src, dst, bitrate, bidir) else: - reply_txt = add_flex_lightpath(src, dst, bitrate, bidir, preferred, ob_band, dj_optical_band_id) + reply_txt = add_flex_lightpath( + src, dst, bitrate, bidir, preferred, ob_band, dj_optical_band_id, + spectrum_reservation=spectrum_reservation + ) if reply_txt is None: return service_with_uuids.service_id reply_json = json.loads(reply_txt) diff --git a/src/service/service/tools/OpticalSpectrumReservation.py b/src/service/service/tools/OpticalSpectrumReservation.py new file mode 100644 index 000000000..d4e360840 --- /dev/null +++ b/src/service/service/tools/OpticalSpectrumReservation.py @@ -0,0 +1,75 @@ +# 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. + +from typing import Dict, Optional + +from common.method_wrappers.ServiceExceptions import InvalidArgumentException + + +def normalize_optical_spectrum_band(band: str) -> str: + normalized_band = str(band).strip().lower().replace("-", "_") + if normalized_band in {"c", "c_band", "c_slots"}: + return "c_slots" + if normalized_band in {"l", "l_band", "l_slots"}: + return "l_slots" + if normalized_band in {"s", "s_band", "s_slots"}: + return "s_slots" + raise InvalidArgumentException('optical spectrum band', str(band)) + + +def parse_optical_spectrum_reservation_text(reservation_text: str) -> Dict: + if ":" not in reservation_text or "-" not in reservation_text: + raise InvalidArgumentException( + 'optical-spectrum-reservation', reservation_text, + extra_details="Expected ':-'" + ) + band, slot_range = reservation_text.split(":", maxsplit=1) + n_start, n_end = slot_range.split("-", maxsplit=1) + reservation = { + "band": normalize_optical_spectrum_band(band), + "n_start": int(n_start), + "n_end": int(n_end), + } + if reservation["n_start"] < 0 or reservation["n_end"] < reservation["n_start"]: + raise InvalidArgumentException('optical-spectrum-reservation', reservation_text) + return reservation + + +def parse_optical_spectrum_reservation_constraints(service_constraints) -> Optional[Dict]: + reservation = {} + for constraint in service_constraints: + if constraint.WhichOneof('constraint') != 'custom': + continue + constraint_type = constraint.custom.constraint_type + constraint_value = constraint.custom.constraint_value + if constraint_type == 'optical-spectrum-reservation': + return parse_optical_spectrum_reservation_text(constraint_value) + if constraint_type == 'optical-spectrum-band': + reservation['band'] = normalize_optical_spectrum_band(constraint_value) + elif constraint_type == 'optical-spectrum-n-start': + reservation['n_start'] = int(constraint_value) + elif constraint_type == 'optical-spectrum-n-end': + reservation['n_end'] = int(constraint_value) + + if len(reservation) == 0: + return None + missing_fields = {'band', 'n_start', 'n_end'} - set(reservation.keys()) + if len(missing_fields) > 0: + raise InvalidArgumentException( + 'optical spectrum reservation', str(reservation), + extra_details='Missing fields: {:s}'.format(', '.join(sorted(missing_fields))) + ) + if reservation["n_start"] < 0 or reservation["n_end"] < reservation["n_start"]: + raise InvalidArgumentException('optical spectrum reservation', str(reservation)) + return reservation diff --git a/src/service/service/tools/OpticalTools.py b/src/service/service/tools/OpticalTools.py index fe277e83d..e13615497 100644 --- a/src/service/service/tools/OpticalTools.py +++ b/src/service/service/tools/OpticalTools.py @@ -14,7 +14,8 @@ # import functools, json, logging, requests, uuid -from typing import Dict, List, Tuple +from typing import Dict, List, Optional, Tuple +from urllib.parse import urlencode from common.method_wrappers.ServiceExceptions import NotFoundException from common.proto.context_pb2 import( ConfigActionEnum, ConfigRule, ConfigRule_Custom, Connection, ContextId, @@ -136,7 +137,9 @@ def reconfig_flex_lightpath(flow_id) -> str: return reply_bid_txt -def add_flex_lightpath(src, dst, bitrate, bidir, pref, ob_band, dj_optical_band_id) -> str: +def add_flex_lightpath( + src, dst, bitrate, bidir, pref, ob_band, dj_optical_band_id, spectrum_reservation: Optional[Dict] = None +) -> str: if not TESTING: urlx = "" headers = {"Content-Type": "application/json"} @@ -156,6 +159,13 @@ def add_flex_lightpath(src, dst, bitrate, bidir, pref, ob_band, dj_optical_band_ urlx = "{:s}/AddFlexLightpath/{:s}/{:s}/{:s}/{:s}/{:s}/{:s}".format(base_url, src, dst, str(bitrate), str(prefs), str(bidir), str(ob_band)) else: urlx = "{:s}/AddFlexLightpath/{:s}/{:s}/{:s}/{:s}/{:s}/{:s}/{:s}".format(base_url, src, dst, str(bitrate), str(prefs), str(bidir), str(ob_band), str(dj_optical_band_id)) + 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}") reply = r.text diff --git a/src/service/tests/test_optical_spectrum_reservation.py b/src/service/tests/test_optical_spectrum_reservation.py new file mode 100644 index 000000000..fa1074d3b --- /dev/null +++ b/src/service/tests/test_optical_spectrum_reservation.py @@ -0,0 +1,74 @@ +# 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. + +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 + + +def test_parse_compact_service_reservation_constraint(): + constraints = [ + Constraint(custom=Constraint_Custom( + constraint_type="optical-spectrum-reservation", + constraint_value="c_slots:51-66", + )) + ] + + reservation = parse_optical_spectrum_reservation_constraints(constraints) + + assert reservation == {"band": "c_slots", "n_start": 51, "n_end": 66} + + +def test_parse_split_service_reservation_constraints(): + constraints = [ + Constraint(custom=Constraint_Custom(constraint_type="optical-spectrum-band", constraint_value="L_BAND")), + Constraint(custom=Constraint_Custom(constraint_type="optical-spectrum-n-start", constraint_value="8")), + Constraint(custom=Constraint_Custom(constraint_type="optical-spectrum-n-end", constraint_value="23")), + ] + + reservation = parse_optical_spectrum_reservation_constraints(constraints) + + assert reservation == {"band": "l_slots", "n_start": 8, "n_end": 23} + + +def test_add_flex_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_flex_lightpath( + "T1", + "T2", + 100, + 0, + "ANY", + None, + None, + spectrum_reservation={"band": "c_slots", "n_start": 51, "n_end": 66}, + ) + + assert captured["url"].startswith("http://optical/OpticalTFS/AddFlexLightpath/T1/T2/100/ANY/0?") + assert "reservation_band=c_slots" in captured["url"] + assert "reservation_start=51" in captured["url"] + assert "reservation_end=66" in captured["url"] -- GitLab From e1bd9b9a4af118b8ec42d69da71cf3efe2ba4195 Mon Sep 17 00:00:00 2001 From: gifrerenom Date: Tue, 16 Jun 2026 15:28:37 +0000 Subject: [PATCH 03/11] Context component: - Implemented Spectrum reservation logic. --- proto/context.proto | 40 +++ src/context/client/ContextClient.py | 38 ++- .../service/ContextServiceServicerImpl.py | 38 ++- .../database/OpticalSpectrumReservation.py | 294 ++++++++++++++++++ .../models/OpticalSpectrumReservationModel.py | 93 ++++++ .../test_optical_spectrum_reservation.py | 103 ++++++ 6 files changed, 604 insertions(+), 2 deletions(-) create mode 100644 src/context/service/database/OpticalSpectrumReservation.py create mode 100644 src/context/service/database/models/OpticalSpectrumReservationModel.py create mode 100644 src/context/tests/test_optical_spectrum_reservation.py diff --git a/proto/context.proto b/proto/context.proto index 0625d0440..3550fb180 100644 --- a/proto/context.proto +++ b/proto/context.proto @@ -99,6 +99,11 @@ service ContextService { rpc GetOpticalBand (Empty ) returns (OpticalBandList) {} rpc SelectOpticalBand (OpticalBandId ) returns (OpticalBand) {} + rpc SetOpticalSpectrumReservation (OpticalSpectrumReservation ) returns (OpticalSpectrumReservationId ) {} + rpc GetOpticalSpectrumReservation (OpticalSpectrumReservationId) returns (OpticalSpectrumReservation ) {} + rpc ListOpticalSpectrumReservations (ContextId ) returns (OpticalSpectrumReservationList) {} + rpc ConsumeOpticalSpectrumReservation (OpticalSpectrumReservation ) returns (OpticalSpectrumReservationId ) {} + rpc ReleaseOpticalSpectrumReservation (OpticalSpectrumReservationId) returns (Empty ) {} rpc DeleteServiceConfigRule(ServiceConfigRule) returns (Empty ) {} } @@ -813,6 +818,41 @@ message OpticalBandList { } +enum OpticalSpectrumReservationStatusEnum { + OPTICALSPECTRUMRESERVATIONSTATUS_UNDEFINED = 0; + OPTICALSPECTRUMRESERVATIONSTATUS_RESERVED = 1; + OPTICALSPECTRUMRESERVATIONSTATUS_CONSUMED = 2; + OPTICALSPECTRUMRESERVATIONSTATUS_RELEASED = 3; + OPTICALSPECTRUMRESERVATIONSTATUS_EXPIRED = 4; +} + +message OpticalSpectrumReservationId { + ContextId context_id = 1; + Uuid reservation_uuid = 2; +} + +message OpticalSpectrumReservation { + OpticalSpectrumReservationId reservation_id = 1; + TopologyId topology_id = 2; + repeated LinkId optical_link_ids = 3; + string band = 4; + uint32 n_start = 5; + uint32 n_end = 6; + uint32 required_slots = 7; + ServiceId service_id = 8; + ConnectionId connection_id = 9; + string owner_id = 10; + string correlation_id = 11; + OpticalSpectrumReservationStatusEnum status = 12; + Timestamp created_at = 13; + Timestamp updated_at = 14; + Timestamp expires_at = 15; +} + +message OpticalSpectrumReservationList { + repeated OpticalSpectrumReservation reservations = 1; +} + ////////////////// Config Rule Delete //////////// diff --git a/src/context/client/ContextClient.py b/src/context/client/ContextClient.py index e85e99ad3..08bc91cad 100644 --- a/src/context/client/ContextClient.py +++ b/src/context/client/ContextClient.py @@ -28,7 +28,8 @@ from common.proto.context_pb2 import ( Service, ServiceConfigRule, ServiceEvent, ServiceFilter, ServiceId, ServiceIdList, ServiceList, Slice, SliceEvent, SliceFilter, SliceId, SliceIdList, SliceList, Topology, TopologyDetails, TopologyEvent, TopologyId, TopologyIdList, TopologyList, - OpticalBand, OpticalBandId, OpticalBandList + OpticalBand, OpticalBandId, OpticalBandList, + OpticalSpectrumReservation, OpticalSpectrumReservationId, OpticalSpectrumReservationList ) from common.proto.context_pb2_grpc import ContextServiceStub from common.proto.context_policy_pb2_grpc import ContextPolicyServiceStub @@ -513,6 +514,41 @@ class ContextClient: response = self.stub.SetOpticalBand(request) LOGGER.debug('SetOpticalBand result: {:s}'.format(grpc_message_to_json_string(response))) return response + + @RETRY_DECORATOR + def SetOpticalSpectrumReservation(self, request : OpticalSpectrumReservation) -> OpticalSpectrumReservationId: + LOGGER.debug('SetOpticalSpectrumReservation request: {:s}'.format(grpc_message_to_json_string(request))) + response = self.stub.SetOpticalSpectrumReservation(request) + LOGGER.debug('SetOpticalSpectrumReservation result: {:s}'.format(grpc_message_to_json_string(response))) + return response + + @RETRY_DECORATOR + def GetOpticalSpectrumReservation(self, request : OpticalSpectrumReservationId) -> OpticalSpectrumReservation: + LOGGER.debug('GetOpticalSpectrumReservation request: {:s}'.format(grpc_message_to_json_string(request))) + response = self.stub.GetOpticalSpectrumReservation(request) + LOGGER.debug('GetOpticalSpectrumReservation result: {:s}'.format(grpc_message_to_json_string(response))) + return response + + @RETRY_DECORATOR + def ListOpticalSpectrumReservations(self, request : ContextId) -> OpticalSpectrumReservationList: + LOGGER.debug('ListOpticalSpectrumReservations request: {:s}'.format(grpc_message_to_json_string(request))) + response = self.stub.ListOpticalSpectrumReservations(request) + LOGGER.debug('ListOpticalSpectrumReservations result: {:s}'.format(grpc_message_to_json_string(response))) + return response + + @RETRY_DECORATOR + def ConsumeOpticalSpectrumReservation(self, request : OpticalSpectrumReservation) -> OpticalSpectrumReservationId: + LOGGER.debug('ConsumeOpticalSpectrumReservation request: {:s}'.format(grpc_message_to_json_string(request))) + response = self.stub.ConsumeOpticalSpectrumReservation(request) + LOGGER.debug('ConsumeOpticalSpectrumReservation result: {:s}'.format(grpc_message_to_json_string(response))) + return response + + @RETRY_DECORATOR + def ReleaseOpticalSpectrumReservation(self, request : OpticalSpectrumReservationId) -> Empty: + LOGGER.debug('ReleaseOpticalSpectrumReservation request: {:s}'.format(grpc_message_to_json_string(request))) + response = self.stub.ReleaseOpticalSpectrumReservation(request) + LOGGER.debug('ReleaseOpticalSpectrumReservation result: {:s}'.format(grpc_message_to_json_string(response))) + return response #--------------------------- Optical Link ------------------------ def GetOpticalLinkList(self, request: Empty) -> OpticalLinkList: diff --git a/src/context/service/ContextServiceServicerImpl.py b/src/context/service/ContextServiceServicerImpl.py index 99ef84617..514e8f67f 100644 --- a/src/context/service/ContextServiceServicerImpl.py +++ b/src/context/service/ContextServiceServicerImpl.py @@ -25,7 +25,8 @@ from common.proto.context_pb2 import ( Slice, SliceEvent, SliceFilter, SliceId, SliceIdList, SliceList, Topology, TopologyDetails, TopologyEvent, TopologyId, TopologyIdList, TopologyList, OpticalConfigList, OpticalConfigId, OpticalConfig, OpticalLink, OpticalLinkList, - OpticalBand, OpticalBandId, OpticalBandList + OpticalBand, OpticalBandId, OpticalBandList, + OpticalSpectrumReservation, OpticalSpectrumReservationId, OpticalSpectrumReservationList ) from common.proto.policy_pb2 import PolicyRuleIdList, PolicyRuleId, PolicyRuleList, PolicyRule from common.proto.context_pb2_grpc import ContextServiceServicer @@ -72,6 +73,11 @@ from .database.OpticalConfig import ( from .database.OpticalLink import ( optical_link_delete, optical_link_get, optical_link_list_objs, optical_link_set ) +from .database.OpticalSpectrumReservation import ( + optical_spectrum_reservation_consume, optical_spectrum_reservation_get, + optical_spectrum_reservation_list, optical_spectrum_reservation_release, + optical_spectrum_reservation_set +) LOGGER = logging.getLogger(__name__) @@ -381,6 +387,36 @@ class ContextServiceServicerImpl(ContextServiceServicer, ContextPolicyServiceSer result = set_optical_band(self.db_engine, request) return Empty() + @safe_and_metered_rpc_method(METRICS_POOL, LOGGER) + def SetOpticalSpectrumReservation( + self, request : OpticalSpectrumReservation, context : grpc.ServicerContext + ) -> OpticalSpectrumReservationId: + return optical_spectrum_reservation_set(self.db_engine, request) + + @safe_and_metered_rpc_method(METRICS_POOL, LOGGER) + def GetOpticalSpectrumReservation( + self, request : OpticalSpectrumReservationId, context : grpc.ServicerContext + ) -> OpticalSpectrumReservation: + return optical_spectrum_reservation_get(self.db_engine, request) + + @safe_and_metered_rpc_method(METRICS_POOL, LOGGER) + def ListOpticalSpectrumReservations( + self, request : ContextId, context : grpc.ServicerContext + ) -> OpticalSpectrumReservationList: + return optical_spectrum_reservation_list(self.db_engine, request) + + @safe_and_metered_rpc_method(METRICS_POOL, LOGGER) + def ConsumeOpticalSpectrumReservation( + self, request : OpticalSpectrumReservation, context : grpc.ServicerContext + ) -> OpticalSpectrumReservationId: + return optical_spectrum_reservation_consume(self.db_engine, request) + + @safe_and_metered_rpc_method(METRICS_POOL, LOGGER) + def ReleaseOpticalSpectrumReservation( + self, request : OpticalSpectrumReservationId, context : grpc.ServicerContext + ) -> Empty: + return optical_spectrum_reservation_release(self.db_engine, request) + #--------------------- Experimental Optical Link ------------------- diff --git a/src/context/service/database/OpticalSpectrumReservation.py b/src/context/service/database/OpticalSpectrumReservation.py new file mode 100644 index 000000000..694ad52c5 --- /dev/null +++ b/src/context/service/database/OpticalSpectrumReservation.py @@ -0,0 +1,294 @@ +# 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 datetime, json, logging +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy_cockroachdb import run_transaction +from typing import Dict, List, Optional, Set +from common.method_wrappers.ServiceExceptions import AlreadyExistsException, InvalidArgumentException, NotFoundException +from common.proto.context_pb2 import ( + ContextId, Empty, OpticalSpectrumReservation, OpticalSpectrumReservationId, OpticalSpectrumReservationList, + OpticalSpectrumReservationStatusEnum +) +from context.service.database.models.OpticalSpectrumReservationModel import OpticalSpectrumReservationModel +from .uuids._Builder import get_uuid_from_string, get_uuid_random +from .uuids.Context import context_get_uuid +from .uuids.Link import link_get_uuid +from .uuids.Service import service_get_uuid +from .uuids.Topology import topology_get_uuid + +LOGGER = logging.getLogger(__name__) + +ACTIVE_STATUSES = { + OpticalSpectrumReservationStatusEnum.OPTICALSPECTRUMRESERVATIONSTATUS_RESERVED, + OpticalSpectrumReservationStatusEnum.OPTICALSPECTRUMRESERVATIONSTATUS_CONSUMED, +} + +TERMINAL_STATUSES = { + OpticalSpectrumReservationStatusEnum.OPTICALSPECTRUMRESERVATIONSTATUS_RELEASED, + OpticalSpectrumReservationStatusEnum.OPTICALSPECTRUMRESERVATIONSTATUS_EXPIRED, +} + +VALID_BANDS = {'c_slots', 'l_slots', 's_slots'} + + +def _now() -> datetime.datetime: + return datetime.datetime.now(datetime.timezone.utc) + + +def _timestamp_to_datetime(timestamp) -> Optional[datetime.datetime]: + if timestamp.timestamp <= 0: + return None + return datetime.datetime.fromtimestamp(timestamp.timestamp, tz=datetime.timezone.utc) + + +def _reservation_get_uuid(request : OpticalSpectrumReservationId, allow_random : bool = False) -> str: + raw_reservation_uuid = request.reservation_uuid.uuid + if len(raw_reservation_uuid) > 0: + return get_uuid_from_string(raw_reservation_uuid) + if allow_random: + return get_uuid_random() + raise InvalidArgumentException('reservation_id.reservation_uuid.uuid', raw_reservation_uuid) + + +def _normalize_band(band : str) -> str: + normalized_band = str(band).strip().lower().replace('-', '_') + if normalized_band in {'c', 'c_band', 'c_slots'}: + return 'c_slots' + if normalized_band in {'l', 'l_band', 'l_slots'}: + return 'l_slots' + if normalized_band in {'s', 's_band', 's_slots'}: + return 's_slots' + raise InvalidArgumentException('band', str(band)) + + +def _normalize_link_uuids(request : OpticalSpectrumReservation) -> List[str]: + link_uuids = [ + link_get_uuid(link_id, allow_random=False) + for link_id in request.optical_link_ids + ] + if len(link_uuids) == 0: + raise InvalidArgumentException('optical_link_ids', '[]') + return sorted(set(link_uuids)) + + +def _normalize_status(status : int) -> int: + if status == OpticalSpectrumReservationStatusEnum.OPTICALSPECTRUMRESERVATIONSTATUS_UNDEFINED: + return OpticalSpectrumReservationStatusEnum.OPTICALSPECTRUMRESERVATIONSTATUS_RESERVED + if status in { + OpticalSpectrumReservationStatusEnum.OPTICALSPECTRUMRESERVATIONSTATUS_RESERVED, + OpticalSpectrumReservationStatusEnum.OPTICALSPECTRUMRESERVATIONSTATUS_CONSUMED, + OpticalSpectrumReservationStatusEnum.OPTICALSPECTRUMRESERVATIONSTATUS_RELEASED, + OpticalSpectrumReservationStatusEnum.OPTICALSPECTRUMRESERVATIONSTATUS_EXPIRED, + }: + return status + raise InvalidArgumentException('status', str(status)) + + +def _service_uuid(request : OpticalSpectrumReservation) -> Optional[str]: + if not request.HasField('service_id'): + return None + if len(request.service_id.service_uuid.uuid) == 0: + return None + _, service_uuid = service_get_uuid(request.service_id, allow_random=False) + return service_uuid + + +def _connection_uuid(request : OpticalSpectrumReservation) -> Optional[str]: + if not request.HasField('connection_id'): + return None + raw_connection_uuid = request.connection_id.connection_uuid.uuid + if len(raw_connection_uuid) == 0: + return None + return get_uuid_from_string(raw_connection_uuid) + + +def _ranges_overlap(a_start : int, a_end : int, b_start : int, b_end : int) -> bool: + return a_start <= b_end and b_start <= a_end + + +def _is_expired(obj : OpticalSpectrumReservationModel, now : datetime.datetime) -> bool: + return obj.expires_at is not None and obj.expires_at <= now + + +def _conflicts( + obj : OpticalSpectrumReservationModel, reservation_uuid : str, context_uuid : str, topology_uuid : str, + link_uuids : Set[str], band : str, n_start : int, n_end : int, now : datetime.datetime +) -> bool: + if obj.reservation_uuid == reservation_uuid: + return False + if obj.context_uuid != context_uuid or obj.topology_uuid != topology_uuid: + return False + if obj.band != band: + return False + if obj.status not in ACTIVE_STATUSES or _is_expired(obj, now): + return False + if len(link_uuids.intersection(obj.get_optical_link_uuids())) == 0: + return False + return _ranges_overlap(n_start, n_end, obj.n_start, obj.n_end) + + +def optical_spectrum_reservation_set( + db_engine : Engine, request : OpticalSpectrumReservation +) -> OpticalSpectrumReservationId: + context_uuid = context_get_uuid(request.reservation_id.context_id, allow_random=False, allow_default=True) + reservation_uuid = _reservation_get_uuid(request.reservation_id, allow_random=True) + topology_context_uuid, topology_uuid = topology_get_uuid(request.topology_id, allow_random=False, allow_default=True) + if topology_context_uuid != context_uuid: + raise InvalidArgumentException('topology_id.context_id', topology_context_uuid) + + band = _normalize_band(request.band) + n_start = int(request.n_start) + n_end = int(request.n_end) + if n_end < n_start: + raise InvalidArgumentException('n_end', str(n_end), extra_details='n_end must be >= n_start') + required_slots = int(request.required_slots) + if required_slots < 0: + raise InvalidArgumentException('required_slots', str(required_slots)) + + link_uuids = _normalize_link_uuids(request) + now = _now() + expires_at = _timestamp_to_datetime(request.expires_at) + status = _normalize_status(request.status) + data = { + 'reservation_uuid': reservation_uuid, + 'context_uuid': context_uuid, + 'topology_uuid': topology_uuid, + 'optical_link_uuids': json.dumps(link_uuids), + 'band': band, + 'n_start': n_start, + 'n_end': n_end, + 'required_slots': required_slots, + 'service_uuid': _service_uuid(request), + 'connection_uuid': _connection_uuid(request), + 'owner_id': request.owner_id or None, + 'correlation_id': request.correlation_id or None, + 'status': status, + 'created_at': now, + 'updated_at': now, + 'expires_at': expires_at, + } + + def callback(session : Session) -> Dict: + active_objects : List[OpticalSpectrumReservationModel] = session.query(OpticalSpectrumReservationModel).all() + for obj in active_objects: + if _conflicts(obj, reservation_uuid, context_uuid, topology_uuid, set(link_uuids), band, n_start, n_end, now): + raise AlreadyExistsException( + 'OpticalSpectrumReservation', obj.reservation_uuid, + extra_details='overlapping spectrum reservation' + ) + + stmt = insert(OpticalSpectrumReservationModel).values([data]) + stmt = stmt.on_conflict_do_update( + index_elements=[OpticalSpectrumReservationModel.reservation_uuid], + set_=dict( + context_uuid=stmt.excluded.context_uuid, + topology_uuid=stmt.excluded.topology_uuid, + optical_link_uuids=stmt.excluded.optical_link_uuids, + band=stmt.excluded.band, + n_start=stmt.excluded.n_start, + n_end=stmt.excluded.n_end, + required_slots=stmt.excluded.required_slots, + service_uuid=stmt.excluded.service_uuid, + connection_uuid=stmt.excluded.connection_uuid, + owner_id=stmt.excluded.owner_id, + correlation_id=stmt.excluded.correlation_id, + status=stmt.excluded.status, + updated_at=stmt.excluded.updated_at, + expires_at=stmt.excluded.expires_at, + ) + ) + session.execute(stmt) + return {'context_id': {'context_uuid': {'uuid': context_uuid}}, 'reservation_uuid': {'uuid': reservation_uuid}} + + reservation_id = run_transaction(sessionmaker(bind=db_engine), callback) + return OpticalSpectrumReservationId(**reservation_id) + + +def optical_spectrum_reservation_get( + db_engine : Engine, request : OpticalSpectrumReservationId +) -> OpticalSpectrumReservation: + context_uuid = context_get_uuid(request.context_id, allow_random=False, allow_default=True) + reservation_uuid = _reservation_get_uuid(request) + + def callback(session : Session) -> Optional[Dict]: + obj : Optional[OpticalSpectrumReservationModel] = session.query(OpticalSpectrumReservationModel)\ + .filter_by(context_uuid=context_uuid, reservation_uuid=reservation_uuid).one_or_none() + return None if obj is None else obj.dump() + + obj = run_transaction(sessionmaker(bind=db_engine), callback) + if obj is None: + raise NotFoundException('OpticalSpectrumReservation', reservation_uuid) + return OpticalSpectrumReservation(**obj) + + +def optical_spectrum_reservation_list( + db_engine : Engine, request : ContextId +) -> OpticalSpectrumReservationList: + context_uuid = context_get_uuid(request, allow_random=False, allow_default=True) + + def callback(session : Session) -> List[Dict]: + obj_list : List[OpticalSpectrumReservationModel] = session.query(OpticalSpectrumReservationModel)\ + .filter_by(context_uuid=context_uuid).all() + return [obj.dump() for obj in obj_list] + + reservations = run_transaction(sessionmaker(bind=db_engine), callback) + return OpticalSpectrumReservationList(reservations=reservations) + + +def _set_status( + db_engine : Engine, request : OpticalSpectrumReservationId, status : int, + service_uuid : Optional[str] = None, connection_uuid : Optional[str] = None +) -> OpticalSpectrumReservationId: + context_uuid = context_get_uuid(request.context_id, allow_random=False, allow_default=True) + reservation_uuid = _reservation_get_uuid(request) + now = _now() + + def callback(session : Session) -> Dict: + obj : Optional[OpticalSpectrumReservationModel] = session.query(OpticalSpectrumReservationModel)\ + .filter_by(context_uuid=context_uuid, reservation_uuid=reservation_uuid).one_or_none() + if obj is None: + raise NotFoundException('OpticalSpectrumReservation', reservation_uuid) + obj.status = status + obj.updated_at = now + if service_uuid is not None: + obj.service_uuid = service_uuid + if connection_uuid is not None: + obj.connection_uuid = connection_uuid + return obj.dump_id() + + reservation_id = run_transaction(sessionmaker(bind=db_engine), callback) + return OpticalSpectrumReservationId(**reservation_id) + + +def optical_spectrum_reservation_consume( + db_engine : Engine, request : OpticalSpectrumReservation +) -> OpticalSpectrumReservationId: + return _set_status( + db_engine, request.reservation_id, + OpticalSpectrumReservationStatusEnum.OPTICALSPECTRUMRESERVATIONSTATUS_CONSUMED, + service_uuid=_service_uuid(request), connection_uuid=_connection_uuid(request) + ) + + +def optical_spectrum_reservation_release( + db_engine : Engine, request : OpticalSpectrumReservationId +) -> Empty: + _set_status( + db_engine, request, + OpticalSpectrumReservationStatusEnum.OPTICALSPECTRUMRESERVATIONSTATUS_RELEASED + ) + return Empty() diff --git a/src/context/service/database/models/OpticalSpectrumReservationModel.py b/src/context/service/database/models/OpticalSpectrumReservationModel.py new file mode 100644 index 000000000..557af3373 --- /dev/null +++ b/src/context/service/database/models/OpticalSpectrumReservationModel.py @@ -0,0 +1,93 @@ +# 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 +from typing import Dict, List, Optional +from sqlalchemy import CheckConstraint, Column, DateTime, ForeignKey, Integer, String +from sqlalchemy.dialects.postgresql import UUID +from ._Base import _Base + + +class OpticalSpectrumReservationModel(_Base): + __tablename__ = 'optical_spectrum_reservation' + + reservation_uuid = Column(UUID(as_uuid=False), primary_key=True) + context_uuid = Column(ForeignKey('context.context_uuid', ondelete='CASCADE'), nullable=False, index=True) + topology_uuid = Column(UUID(as_uuid=False), nullable=False, index=True) + optical_link_uuids = Column(String, nullable=False) + band = Column(String, nullable=False) + n_start = Column(Integer, nullable=False) + n_end = Column(Integer, nullable=False) + required_slots = Column(Integer, nullable=False) + service_uuid = Column(UUID(as_uuid=False), nullable=True, index=True) + connection_uuid = Column(UUID(as_uuid=False), nullable=True, index=True) + owner_id = Column(String, nullable=True) + correlation_id = Column(String, nullable=True) + status = Column(Integer, nullable=False) + created_at = Column(DateTime, nullable=False) + updated_at = Column(DateTime, nullable=False) + expires_at = Column(DateTime, nullable=True) + + __table_args__ = ( + CheckConstraint(n_start >= 0, name='check_n_start_value'), + CheckConstraint(n_end >= n_start, name='check_n_end_value'), + CheckConstraint(required_slots >= 0, name='check_required_slots_value'), + ) + + def get_optical_link_uuids(self) -> List[str]: + return json.loads(self.optical_link_uuids) + + def dump_id(self) -> Dict: + return { + 'context_id': {'context_uuid': {'uuid': self.context_uuid}}, + 'reservation_uuid': {'uuid': self.reservation_uuid}, + } + + def _dump_optional_timestamp(self, value) -> Optional[Dict]: + if value is None: + return None + return {'timestamp': value.timestamp()} + + def dump(self) -> Dict: + result = { + 'reservation_id': self.dump_id(), + 'topology_id': { + 'context_id': {'context_uuid': {'uuid': self.context_uuid}}, + 'topology_uuid': {'uuid': self.topology_uuid}, + }, + 'optical_link_ids': [ + {'link_uuid': {'uuid': optical_link_uuid}} + for optical_link_uuid in self.get_optical_link_uuids() + ], + 'band': self.band, + 'n_start': self.n_start, + 'n_end': self.n_end, + 'required_slots': self.required_slots, + 'owner_id': self.owner_id or '', + 'correlation_id': self.correlation_id or '', + 'status': self.status, + 'created_at': {'timestamp': self.created_at.timestamp()}, + 'updated_at': {'timestamp': self.updated_at.timestamp()}, + } + if self.service_uuid is not None: + result['service_id'] = { + 'context_id': {'context_uuid': {'uuid': self.context_uuid}}, + 'service_uuid': {'uuid': self.service_uuid}, + } + if self.connection_uuid is not None: + result['connection_id'] = {'connection_uuid': {'uuid': self.connection_uuid}} + expires_at = self._dump_optional_timestamp(self.expires_at) + if expires_at is not None: + result['expires_at'] = expires_at + return result diff --git a/src/context/tests/test_optical_spectrum_reservation.py b/src/context/tests/test_optical_spectrum_reservation.py new file mode 100644 index 000000000..19fed22ca --- /dev/null +++ b/src/context/tests/test_optical_spectrum_reservation.py @@ -0,0 +1,103 @@ +# 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, pytest +from common.proto.context_pb2 import ( + Context, ContextId, LinkId, OpticalSpectrumReservation, OpticalSpectrumReservationId, + OpticalSpectrumReservationStatusEnum, ServiceId, Topology +) +from common.tools.object_factory.Context import json_context, json_context_id +from common.tools.object_factory.Link import json_link_id +from common.tools.object_factory.Service import json_service_id +from common.tools.object_factory.Topology import json_topology, json_topology_id +from context.client.ContextClient import ContextClient +from context.service.database.uuids.Context import context_get_uuid +from context.service.database.uuids.Link import link_get_uuid +from context.service.database.uuids.Service import service_get_uuid +from context.service.database.uuids.Topology import topology_get_uuid + + +CONTEXT_ID = json_context_id('spectrum-admin') +TOPOLOGY_ID = json_topology_id('spectrum-topology', context_id=CONTEXT_ID) +LINK_1_ID = json_link_id('roadm-a-roadm-b') +LINK_2_ID = json_link_id('roadm-b-roadm-c') + + +def _reservation(name, n_start, n_end, link_ids=None): + if link_ids is None: + link_ids = [LINK_1_ID, LINK_2_ID] + return OpticalSpectrumReservation( + reservation_id=OpticalSpectrumReservationId( + context_id=ContextId(**CONTEXT_ID), + reservation_uuid={'uuid': name}, + ), + topology_id=TOPOLOGY_ID, + optical_link_ids=[LinkId(**link_id) for link_id in link_ids], + band='c_slots', + n_start=n_start, + n_end=n_end, + required_slots=(n_end - n_start + 1), + owner_id='pytest', + correlation_id=name, + ) + + +def test_optical_spectrum_reservation_lifecycle(context_client : ContextClient) -> None: + context_client.SetContext(Context(**json_context('spectrum-admin', name='spectrum-admin'))) + context_client.SetTopology(Topology(**json_topology('spectrum-topology', context_id=CONTEXT_ID))) + + context_uuid = context_get_uuid(ContextId(**CONTEXT_ID), allow_random=False) + _, topology_uuid = topology_get_uuid(Topology(**json_topology('spectrum-topology', context_id=CONTEXT_ID)).topology_id) + link_1_uuid = link_get_uuid(LinkId(**LINK_1_ID), allow_random=False) + link_2_uuid = link_get_uuid(LinkId(**LINK_2_ID), allow_random=False) + + reservation = _reservation('reservation-a', 10, 25) + reservation_id = context_client.SetOpticalSpectrumReservation(reservation) + + stored = context_client.GetOpticalSpectrumReservation(reservation_id) + assert stored.reservation_id.context_id.context_uuid.uuid == context_uuid + assert stored.topology_id.topology_uuid.uuid == topology_uuid + assert stored.band == 'c_slots' + assert stored.n_start == 10 + assert stored.n_end == 25 + assert stored.required_slots == 16 + assert stored.status == OpticalSpectrumReservationStatusEnum.OPTICALSPECTRUMRESERVATIONSTATUS_RESERVED + assert {link_id.link_uuid.uuid for link_id in stored.optical_link_ids} == {link_1_uuid, link_2_uuid} + + reservations = context_client.ListOpticalSpectrumReservations(ContextId(**CONTEXT_ID)) + assert len(reservations.reservations) == 1 + + with pytest.raises(grpc.RpcError) as e: + context_client.SetOpticalSpectrumReservation(_reservation('reservation-overlap', 20, 30)) + assert e.value.code() == grpc.StatusCode.ALREADY_EXISTS + + non_overlapping_id = context_client.SetOpticalSpectrumReservation(_reservation('reservation-b', 26, 41)) + non_overlapping = context_client.GetOpticalSpectrumReservation(non_overlapping_id) + assert non_overlapping.status == OpticalSpectrumReservationStatusEnum.OPTICALSPECTRUMRESERVATIONSTATUS_RESERVED + + service_id = ServiceId(**json_service_id('optical-service-a', context_id=CONTEXT_ID)) + stored.service_id.CopyFrom(service_id) + consumed_id = context_client.ConsumeOpticalSpectrumReservation(stored) + consumed = context_client.GetOpticalSpectrumReservation(consumed_id) + _, service_uuid = service_get_uuid(service_id, allow_random=False) + assert consumed.status == OpticalSpectrumReservationStatusEnum.OPTICALSPECTRUMRESERVATIONSTATUS_CONSUMED + assert consumed.service_id.service_uuid.uuid == service_uuid + + context_client.ReleaseOpticalSpectrumReservation(consumed_id) + released = context_client.GetOpticalSpectrumReservation(consumed_id) + assert released.status == OpticalSpectrumReservationStatusEnum.OPTICALSPECTRUMRESERVATIONSTATUS_RELEASED + + replacement_id = context_client.SetOpticalSpectrumReservation(_reservation('reservation-replacement', 10, 25)) + replacement = context_client.GetOpticalSpectrumReservation(replacement_id) + assert replacement.status == OpticalSpectrumReservationStatusEnum.OPTICALSPECTRUMRESERVATIONSTATUS_RESERVED -- GitLab From de5e2e86c76383a43fc6f1daff9d3083adf0a819 Mon Sep 17 00:00:00 2001 From: gifrerenom Date: Tue, 16 Jun 2026 15:55:14 +0000 Subject: [PATCH 04/11] NBI component - TFS-API connector: - Implemented Spectrum reservation endpoints. --- src/common/tests/MockServicerImpl_Context.py | 68 +++++++++ src/nbi/service/tfs_api/Resources.py | 68 +++++++++ src/nbi/service/tfs_api/Tools.py | 10 ++ src/nbi/service/tfs_api/__init__.py | 10 ++ src/nbi/tests/test_tfs_api.py | 138 ++++++++++++++++++- 5 files changed, 293 insertions(+), 1 deletion(-) diff --git a/src/common/tests/MockServicerImpl_Context.py b/src/common/tests/MockServicerImpl_Context.py index e5b86bc58..c12f29d1c 100644 --- a/src/common/tests/MockServicerImpl_Context.py +++ b/src/common/tests/MockServicerImpl_Context.py @@ -22,6 +22,8 @@ from common.proto.context_pb2 import ( Empty, EventTypeEnum, Link, LinkEvent, LinkId, LinkIdList, LinkList, OpticalLink, OpticalLinkList, + OpticalSpectrumReservation, OpticalSpectrumReservationId, OpticalSpectrumReservationList, + OpticalSpectrumReservationStatusEnum, Service, ServiceEvent, ServiceFilter, ServiceId, ServiceIdList, ServiceList, Slice, SliceEvent, SliceFilter, SliceId, SliceIdList, SliceList, Topology, TopologyDetails, TopologyEvent, TopologyId, TopologyIdList, TopologyList @@ -765,3 +767,69 @@ class MockServicerImpl_Context(ContextServiceServicer): LOGGER.debug('[DeleteOpticalLink] reply={:s}'.format(grpc_message_to_json_string(reply))) return reply + + # ----- Optical Spectrum Reservation ------------------------------------------------------------------------------ + + def ListOpticalSpectrumReservations( + self, request : ContextId, context : grpc.ServicerContext + ) -> OpticalSpectrumReservationList: + LOGGER.debug('[ListOpticalSpectrumReservations] request={:s}'.format(grpc_message_to_json_string(request))) + reservations = self.obj_db.get_entries('optical_spectrum_reservation[{:s}]'.format( + str(request.context_uuid.uuid) + )) + reply = OpticalSpectrumReservationList(reservations=reservations) + LOGGER.debug('[ListOpticalSpectrumReservations] reply={:s}'.format(grpc_message_to_json_string(reply))) + return reply + + def GetOpticalSpectrumReservation( + self, request : OpticalSpectrumReservationId, context : grpc.ServicerContext + ) -> OpticalSpectrumReservation: + LOGGER.debug('[GetOpticalSpectrumReservation] request={:s}'.format(grpc_message_to_json_string(request))) + container_name = 'optical_spectrum_reservation[{:s}]'.format(str(request.context_id.context_uuid.uuid)) + reply = self.obj_db.get_entry(container_name, request.reservation_uuid.uuid, context) + LOGGER.debug('[GetOpticalSpectrumReservation] reply={:s}'.format(grpc_message_to_json_string(reply))) + return reply + + def SetOpticalSpectrumReservation( + self, request : OpticalSpectrumReservation, context : grpc.ServicerContext + ) -> OpticalSpectrumReservationId: + LOGGER.debug('[SetOpticalSpectrumReservation] request={:s}'.format(grpc_message_to_json_string(request))) + if request.status == OpticalSpectrumReservationStatusEnum.OPTICALSPECTRUMRESERVATIONSTATUS_UNDEFINED: + request.status = OpticalSpectrumReservationStatusEnum.OPTICALSPECTRUMRESERVATIONSTATUS_RESERVED + container_name = 'optical_spectrum_reservation[{:s}]'.format( + str(request.reservation_id.context_id.context_uuid.uuid) + ) + reply, _ = self._set( + request, container_name, request.reservation_id.reservation_uuid.uuid, 'reservation_id', TOPIC_CONTEXT + ) + LOGGER.debug('[SetOpticalSpectrumReservation] reply={:s}'.format(grpc_message_to_json_string(reply))) + return reply + + def ConsumeOpticalSpectrumReservation( + self, request : OpticalSpectrumReservation, context : grpc.ServicerContext + ) -> OpticalSpectrumReservationId: + LOGGER.debug('[ConsumeOpticalSpectrumReservation] request={:s}'.format(grpc_message_to_json_string(request))) + container_name = 'optical_spectrum_reservation[{:s}]'.format( + str(request.reservation_id.context_id.context_uuid.uuid) + ) + reservation = self.obj_db.get_entry(container_name, request.reservation_id.reservation_uuid.uuid, context) + reservation.status = OpticalSpectrumReservationStatusEnum.OPTICALSPECTRUMRESERVATIONSTATUS_CONSUMED + if request.HasField('service_id'): + reservation.service_id.CopyFrom(request.service_id) + if request.HasField('connection_id'): + reservation.connection_id.CopyFrom(request.connection_id) + LOGGER.debug('[ConsumeOpticalSpectrumReservation] reply={:s}'.format( + grpc_message_to_json_string(reservation.reservation_id) + )) + return reservation.reservation_id + + def ReleaseOpticalSpectrumReservation( + self, request : OpticalSpectrumReservationId, context : grpc.ServicerContext + ) -> Empty: + LOGGER.debug('[ReleaseOpticalSpectrumReservation] request={:s}'.format(grpc_message_to_json_string(request))) + container_name = 'optical_spectrum_reservation[{:s}]'.format(str(request.context_id.context_uuid.uuid)) + reservation = self.obj_db.get_entry(container_name, request.reservation_uuid.uuid, context) + reservation.status = OpticalSpectrumReservationStatusEnum.OPTICALSPECTRUMRESERVATIONSTATUS_RELEASED + reply = Empty() + LOGGER.debug('[ReleaseOpticalSpectrumReservation] reply={:s}'.format(grpc_message_to_json_string(reply))) + return reply diff --git a/src/nbi/service/tfs_api/Resources.py b/src/nbi/service/tfs_api/Resources.py index 2d74a6986..08b1f1a2e 100644 --- a/src/nbi/service/tfs_api/Resources.py +++ b/src/nbi/service/tfs_api/Resources.py @@ -33,6 +33,7 @@ from vnt_manager.client.VNTManagerClient import VNTManagerClient from .Tools import ( format_grpc_to_json, grpc_connection_id, grpc_context, grpc_context_id, grpc_device, grpc_device_id, grpc_link, grpc_link_id, grpc_policy_rule_id, + grpc_optical_spectrum_reservation, grpc_optical_spectrum_reservation_id, grpc_service_id, grpc_service, grpc_slice, grpc_slice_id, grpc_topology, grpc_topology_id ) @@ -348,6 +349,73 @@ class OpticalLink(_Resource): def get(self, link_uuid : str): return format_grpc_to_json(self.context_client.GetOpticalLink(grpc_link_id(link_uuid))) +class OpticalSpectrumReservations(_Resource): + def get(self, context_uuid : str): + return format_grpc_to_json( + self.context_client.ListOpticalSpectrumReservations(grpc_context_id(context_uuid)) + ) + + def post(self, context_uuid : str): + json_requests = request.get_json() + if 'reservations' in json_requests: + json_requests = json_requests['reservations'] + if isinstance(json_requests, dict): + json_requests = [json_requests] + for reservation in json_requests: + if context_uuid != reservation['reservation_id']['context_id']['context_uuid']['uuid']: + raise BadRequest('Mismatching context_uuid') + return jsonify([ + grpc_message_to_json(self.context_client.SetOpticalSpectrumReservation( + grpc_optical_spectrum_reservation(reservation) + )) + for reservation in json_requests + ]) + +class OpticalSpectrumReservation(_Resource): + def get(self, context_uuid : str, reservation_uuid : str): + return format_grpc_to_json(self.context_client.GetOpticalSpectrumReservation( + grpc_optical_spectrum_reservation_id(context_uuid, reservation_uuid) + )) + + def put(self, context_uuid : str, reservation_uuid : str): + reservation = request.get_json() + if context_uuid != reservation['reservation_id']['context_id']['context_uuid']['uuid']: + raise BadRequest('Mismatching context_uuid') + if reservation_uuid != reservation['reservation_id']['reservation_uuid']['uuid']: + raise BadRequest('Mismatching reservation_uuid') + return format_grpc_to_json(self.context_client.SetOpticalSpectrumReservation( + grpc_optical_spectrum_reservation(reservation) + )) + + def delete(self, context_uuid : str, reservation_uuid : str): + return format_grpc_to_json(self.context_client.ReleaseOpticalSpectrumReservation( + grpc_optical_spectrum_reservation_id(context_uuid, reservation_uuid) + )) + +class OpticalSpectrumReservationConsume(_Resource): + def post(self, context_uuid : str, reservation_uuid : str): + reservation = request.get_json() + if reservation is None: + reservation = { + 'reservation_id': { + 'context_id': {'context_uuid': {'uuid': context_uuid}}, + 'reservation_uuid': {'uuid': reservation_uuid}, + }, + } + if context_uuid != reservation['reservation_id']['context_id']['context_uuid']['uuid']: + raise BadRequest('Mismatching context_uuid') + if reservation_uuid != reservation['reservation_id']['reservation_uuid']['uuid']: + raise BadRequest('Mismatching reservation_uuid') + return format_grpc_to_json(self.context_client.ConsumeOpticalSpectrumReservation( + grpc_optical_spectrum_reservation(reservation) + )) + +class OpticalSpectrumReservationRelease(_Resource): + def post(self, context_uuid : str, reservation_uuid : str): + return format_grpc_to_json(self.context_client.ReleaseOpticalSpectrumReservation( + grpc_optical_spectrum_reservation_id(context_uuid, reservation_uuid) + )) + class ConnectionIds(_Resource): def get(self, context_uuid : str, service_uuid : str): return format_grpc_to_json(self.context_client.ListConnectionIds(grpc_service_id(context_uuid, service_uuid))) diff --git a/src/nbi/service/tfs_api/Tools.py b/src/nbi/service/tfs_api/Tools.py index 390ef9b01..e11620f8e 100644 --- a/src/nbi/service/tfs_api/Tools.py +++ b/src/nbi/service/tfs_api/Tools.py @@ -16,6 +16,7 @@ from typing import Dict from flask.json import jsonify from common.proto.context_pb2 import ( ConnectionId, Context, ContextId, Device, DeviceId, Link, LinkId, + OpticalSpectrumReservation, OpticalSpectrumReservationId, ServiceId, Slice, SliceId, Topology, TopologyId, Service ) from common.proto.policy_pb2 import PolicyRule, PolicyRuleId @@ -54,6 +55,15 @@ def grpc_link_id(link_uuid): def grpc_link(json_link : Dict): return Link(**json_link) +def grpc_optical_spectrum_reservation_id(context_uuid, reservation_uuid): + return OpticalSpectrumReservationId( + context_id=json_context_id(context_uuid), + reservation_uuid={'uuid': reservation_uuid}, + ) + +def grpc_optical_spectrum_reservation(json_reservation : Dict): + return OpticalSpectrumReservation(**json_reservation) + def grpc_service_id(context_uuid, service_uuid): return ServiceId(**json_service_id(service_uuid, context_id=json_context_id(context_uuid))) diff --git a/src/nbi/service/tfs_api/__init__.py b/src/nbi/service/tfs_api/__init__.py index aa842d90a..2bcf431ec 100644 --- a/src/nbi/service/tfs_api/__init__.py +++ b/src/nbi/service/tfs_api/__init__.py @@ -20,6 +20,8 @@ from .Resources import ( DummyContexts, Link, LinkIds, Links, OpticalLink, OpticalLinks, + OpticalSpectrumReservation, OpticalSpectrumReservationConsume, + OpticalSpectrumReservationRelease, OpticalSpectrumReservations, PolicyRule, PolicyRuleIds, PolicyRules, Service, ServiceIds, Services, Slice, SliceIds, Slices, @@ -62,6 +64,14 @@ _RESOURCES = [ ('api.link', Link, '/link/'), ('api.optical_links', OpticalLinks, '/optical_links'), ('api.optical_link', OpticalLink, '/optical_link/'), + ('api.optical_spectrum_reservations', OpticalSpectrumReservations, + '/context//optical_spectrum_reservations'), + ('api.optical_spectrum_reservation', OpticalSpectrumReservation, + '/context//optical_spectrum_reservation/'), + ('api.optical_spectrum_reservation_consume', OpticalSpectrumReservationConsume, + '/context//optical_spectrum_reservation//consume'), + ('api.optical_spectrum_reservation_release', OpticalSpectrumReservationRelease, + '/context//optical_spectrum_reservation//release'), ('api.connection_ids', ConnectionIds, '/context//service//connection_ids'), ('api.connections', Connections, '/context//service//connections'), diff --git a/src/nbi/tests/test_tfs_api.py b/src/nbi/tests/test_tfs_api.py index 25158fa2a..2ab5bde49 100644 --- a/src/nbi/tests/test_tfs_api.py +++ b/src/nbi/tests/test_tfs_api.py @@ -41,7 +41,7 @@ from nbi.service.NbiApplication import NbiApplication from .PrepareTestScenario import ( # pylint: disable=unused-import # be careful, order of symbols is important here! nbi_application, context_client, - do_rest_get_request + do_rest_delete_request, do_rest_get_request, do_rest_post_request, do_rest_put_request ) @@ -175,6 +175,142 @@ def test_rest_get_optical_link( validate_optical_link(reply) +# ----- Optical Spectrum Reservation ---------------------------------------------------------------------------------- + +def _optical_spectrum_reservation(context_uuid : str, reservation_uuid : str): + return { + 'reservation_id': { + 'context_id': {'context_uuid': {'uuid': context_uuid}}, + 'reservation_uuid': {'uuid': reservation_uuid}, + }, + 'topology_id': { + 'context_id': {'context_uuid': {'uuid': context_uuid}}, + 'topology_uuid': {'uuid': DEFAULT_TOPOLOGY_NAME}, + }, + 'optical_link_ids': [ + {'link_uuid': {'uuid': 'OL:R1/502==R2/501'}}, + ], + 'band': 'c_slots', + 'n_start': 10, + 'n_end': 25, + 'required_slots': 16, + 'owner_id': 'nbi-unit-test', + 'correlation_id': reservation_uuid, + 'status': 1, + } + +def _validate_optical_spectrum_reservation(message, reservation_uuid : str, status : str): + assert isinstance(message, dict) + assert message['reservation_id']['reservation_uuid']['uuid'] == reservation_uuid + assert message['band'] == 'c_slots' + assert message['n_start'] == 10 + assert message['n_end'] == 25 + assert message['required_slots'] == 16 + assert message['status'] == status + assert len(message['optical_link_ids']) == 1 + +def test_rest_optical_spectrum_reservation_lifecycle( + nbi_application : NbiApplication # pylint: disable=redefined-outer-name +) -> None: + context_uuid = urllib.parse.quote(DEFAULT_CONTEXT_NAME) + reservation_uuid = 'reservation-nbi-a' + reservation_uuid_quoted = urllib.parse.quote(reservation_uuid, safe='') + reservation = _optical_spectrum_reservation(DEFAULT_CONTEXT_NAME, reservation_uuid) + + reply = do_rest_post_request( + '/tfs-api/context/{:s}/optical_spectrum_reservations'.format(context_uuid), + reservation + ) + assert isinstance(reply, list) + assert len(reply) == 1 + assert reply[0]['reservation_uuid']['uuid'] == reservation_uuid + + reply = do_rest_get_request( + '/tfs-api/context/{:s}/optical_spectrum_reservations'.format(context_uuid) + ) + assert isinstance(reply, dict) + assert len(reply['reservations']) == 1 + _validate_optical_spectrum_reservation( + reply['reservations'][0], reservation_uuid, status='OPTICALSPECTRUMRESERVATIONSTATUS_RESERVED' + ) + + reply = do_rest_get_request( + '/tfs-api/context/{:s}/optical_spectrum_reservation/{:s}'.format( + context_uuid, reservation_uuid_quoted + ) + ) + _validate_optical_spectrum_reservation( + reply, reservation_uuid, status='OPTICALSPECTRUMRESERVATIONSTATUS_RESERVED' + ) + + reservation['owner_id'] = 'nbi-unit-test-updated' + reply = do_rest_put_request( + '/tfs-api/context/{:s}/optical_spectrum_reservation/{:s}'.format( + context_uuid, reservation_uuid_quoted + ), + reservation + ) + assert reply['reservation_uuid']['uuid'] == reservation_uuid + + reply = do_rest_get_request( + '/tfs-api/context/{:s}/optical_spectrum_reservation/{:s}'.format( + context_uuid, reservation_uuid_quoted + ) + ) + _validate_optical_spectrum_reservation( + reply, reservation_uuid, status='OPTICALSPECTRUMRESERVATIONSTATUS_RESERVED' + ) + assert reply['owner_id'] == 'nbi-unit-test-updated' + + consume_request = { + 'reservation_id': reservation['reservation_id'], + 'service_id': { + 'context_id': {'context_uuid': {'uuid': DEFAULT_CONTEXT_NAME}}, + 'service_uuid': {'uuid': 'SVC:R1/200==R2/200'}, + }, + } + reply = do_rest_post_request( + '/tfs-api/context/{:s}/optical_spectrum_reservation/{:s}/consume'.format( + context_uuid, reservation_uuid_quoted + ), + consume_request + ) + assert reply['reservation_uuid']['uuid'] == reservation_uuid + + reply = do_rest_get_request( + '/tfs-api/context/{:s}/optical_spectrum_reservation/{:s}'.format( + context_uuid, reservation_uuid_quoted + ) + ) + _validate_optical_spectrum_reservation( + reply, reservation_uuid, status='OPTICALSPECTRUMRESERVATIONSTATUS_CONSUMED' + ) + assert reply['service_id']['service_uuid']['uuid'] == 'SVC:R1/200==R2/200' + + reply = do_rest_post_request( + '/tfs-api/context/{:s}/optical_spectrum_reservation/{:s}/release'.format( + context_uuid, reservation_uuid_quoted + ) + ) + assert reply == {} + + reply = do_rest_get_request( + '/tfs-api/context/{:s}/optical_spectrum_reservation/{:s}'.format( + context_uuid, reservation_uuid_quoted + ) + ) + _validate_optical_spectrum_reservation( + reply, reservation_uuid, status='OPTICALSPECTRUMRESERVATIONSTATUS_RELEASED' + ) + + reply = do_rest_delete_request( + '/tfs-api/context/{:s}/optical_spectrum_reservation/{:s}'.format( + context_uuid, reservation_uuid_quoted + ) + ) + assert reply == {} + + # ----- Service -------------------------------------------------------------------------------------------------------- def test_rest_get_service_ids( -- GitLab From 60bdb07dccad861404144b743197ee6b7495eec1 Mon Sep 17 00:00:00 2001 From: gifrerenom Date: Tue, 16 Jun 2026 16:45:06 +0000 Subject: [PATCH 05/11] Context component: - Add optical link validation for spectrum reservations --- .../database/OpticalSpectrumReservation.py | 45 ++++++- .../test_optical_spectrum_reservation.py | 116 ++++++++++++++++-- 2 files changed, 151 insertions(+), 10 deletions(-) diff --git a/src/context/service/database/OpticalSpectrumReservation.py b/src/context/service/database/OpticalSpectrumReservation.py index 694ad52c5..aa5eb1362 100644 --- a/src/context/service/database/OpticalSpectrumReservation.py +++ b/src/context/service/database/OpticalSpectrumReservation.py @@ -23,7 +23,9 @@ from common.proto.context_pb2 import ( ContextId, Empty, OpticalSpectrumReservation, OpticalSpectrumReservationId, OpticalSpectrumReservationList, OpticalSpectrumReservationStatusEnum ) -from context.service.database.models.OpticalSpectrumReservationModel import OpticalSpectrumReservationModel +from .models.OpticalLinkModel import OpticalLinkModel +from .models.OpticalSpectrumReservationModel import OpticalSpectrumReservationModel +from .models.TopologyModel import TopologyOpticalLinkModel from .uuids._Builder import get_uuid_from_string, get_uuid_random from .uuids.Context import context_get_uuid from .uuids.Link import link_get_uuid @@ -141,6 +143,45 @@ def _conflicts( return _ranges_overlap(n_start, n_end, obj.n_start, obj.n_end) +def _validate_optical_link_slots( + session : Session, topology_uuid : str, link_uuids : List[str], band : str, n_start : int, n_end : int +) -> None: + optical_links : List[OpticalLinkModel] = session.query(OpticalLinkModel)\ + .filter(OpticalLinkModel.opticallink_uuid.in_(link_uuids)).all() + optical_link_by_uuid = {optical_link.opticallink_uuid: optical_link for optical_link in optical_links} + + for link_uuid in link_uuids: + if link_uuid not in optical_link_by_uuid: + raise NotFoundException('OpticalLink', link_uuid) + + topology_link_rows : List[TopologyOpticalLinkModel] = session.query(TopologyOpticalLinkModel)\ + .filter(TopologyOpticalLinkModel.topology_uuid == topology_uuid)\ + .filter(TopologyOpticalLinkModel.optical_link_uuid.in_(link_uuids)).all() + topology_link_uuids = {topology_link.optical_link_uuid for topology_link in topology_link_rows} + + missing_topology_links = sorted(set(link_uuids) - topology_link_uuids) + if len(missing_topology_links) > 0: + raise InvalidArgumentException( + 'optical_link_ids', str(missing_topology_links), + extra_details='optical links are not part of the requested topology' + ) + + for link_uuid in link_uuids: + optical_link = optical_link_by_uuid[link_uuid] + slots = getattr(optical_link, band) + if slots is None: + raise AlreadyExistsException( + 'OpticalSpectrumReservation', link_uuid, + extra_details='requested band {:s} is not available on optical link'.format(band) + ) + for slot_index in range(n_start, n_end + 1): + if int(slots.get(str(slot_index), 0)) != 1: + raise AlreadyExistsException( + 'OpticalSpectrumReservation', link_uuid, + extra_details='slot {:s}:{:d} is not available on optical link'.format(band, slot_index) + ) + + def optical_spectrum_reservation_set( db_engine : Engine, request : OpticalSpectrumReservation ) -> OpticalSpectrumReservationId: @@ -183,6 +224,8 @@ def optical_spectrum_reservation_set( } def callback(session : Session) -> Dict: + _validate_optical_link_slots(session, topology_uuid, link_uuids, band, n_start, n_end) + active_objects : List[OpticalSpectrumReservationModel] = session.query(OpticalSpectrumReservationModel).all() for obj in active_objects: if _conflicts(obj, reservation_uuid, context_uuid, topology_uuid, set(link_uuids), band, n_start, n_end, now): diff --git a/src/context/tests/test_optical_spectrum_reservation.py b/src/context/tests/test_optical_spectrum_reservation.py index 19fed22ca..9dfa606c5 100644 --- a/src/context/tests/test_optical_spectrum_reservation.py +++ b/src/context/tests/test_optical_spectrum_reservation.py @@ -12,10 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -import grpc, pytest +import datetime, grpc, pytest +from sqlalchemy.orm import sessionmaker from common.proto.context_pb2 import ( Context, ContextId, LinkId, OpticalSpectrumReservation, OpticalSpectrumReservationId, - OpticalSpectrumReservationStatusEnum, ServiceId, Topology + OpticalSpectrumReservationStatusEnum, ServiceId, Topology, TopologyId ) from common.tools.object_factory.Context import json_context, json_context_id from common.tools.object_factory.Link import json_link_id @@ -26,6 +27,8 @@ from context.service.database.uuids.Context import context_get_uuid from context.service.database.uuids.Link import link_get_uuid from context.service.database.uuids.Service import service_get_uuid from context.service.database.uuids.Topology import topology_get_uuid +from context.service.database.models.OpticalLinkModel import OpticalLinkModel +from context.service.database.models.TopologyModel import TopologyOpticalLinkModel CONTEXT_ID = json_context_id('spectrum-admin') @@ -34,15 +37,68 @@ LINK_1_ID = json_link_id('roadm-a-roadm-b') LINK_2_ID = json_link_id('roadm-b-roadm-c') -def _reservation(name, n_start, n_end, link_ids=None): +def _slot_map(available_slots): + return {str(slot_index): 1 for slot_index in available_slots} + + +def _set_context_topology(context_client : ContextClient) -> None: + context_client.SetContext(Context(**json_context('spectrum-admin', name='spectrum-admin'))) + context_client.SetTopology(Topology(**json_topology('spectrum-topology', context_id=CONTEXT_ID))) + + +def _set_optical_link(context_db_mb, link_name : str, available_slots=None, topology_id=None): + if available_slots is None: + available_slots = range(0, 80) + if topology_id is None: + topology_id = TOPOLOGY_ID + + link_id = LinkId(**json_link_id(link_name)) + link_uuid = link_get_uuid(link_id, allow_random=False) + _, topology_uuid = topology_get_uuid(TopologyId(**topology_id), allow_random=False) + now = datetime.datetime.now(datetime.timezone.utc) + + db_engine, _ = context_db_mb + Session = sessionmaker(bind=db_engine) + with Session() as session: + optical_link = session.query(OpticalLinkModel).filter_by(opticallink_uuid=link_uuid).one_or_none() + if optical_link is None: + optical_link = OpticalLinkModel( + opticallink_uuid=link_uuid, + name=link_name, + created_at=now, + ) + session.add(optical_link) + optical_link.updated_at = now + optical_link.length = 1 + optical_link.src_port = 'src' + optical_link.dst_port = 'dst' + optical_link.local_peer_port = 'local' + optical_link.remote_peer_port = 'remote' + optical_link.used = False + optical_link.c_slots = _slot_map(available_slots) + optical_link.l_slots = {} + optical_link.s_slots = {} + + topology_link = session.query(TopologyOpticalLinkModel)\ + .filter_by(topology_uuid=topology_uuid, optical_link_uuid=link_uuid).one_or_none() + if topology_link is None: + session.add(TopologyOpticalLinkModel(topology_uuid=topology_uuid, optical_link_uuid=link_uuid)) + session.commit() + + return link_id + + +def _reservation(name, n_start, n_end, link_ids=None, topology_id=None): if link_ids is None: link_ids = [LINK_1_ID, LINK_2_ID] + if topology_id is None: + topology_id = TOPOLOGY_ID return OpticalSpectrumReservation( reservation_id=OpticalSpectrumReservationId( context_id=ContextId(**CONTEXT_ID), reservation_uuid={'uuid': name}, ), - topology_id=TOPOLOGY_ID, + topology_id=topology_id, optical_link_ids=[LinkId(**link_id) for link_id in link_ids], band='c_slots', n_start=n_start, @@ -53,14 +109,15 @@ def _reservation(name, n_start, n_end, link_ids=None): ) -def test_optical_spectrum_reservation_lifecycle(context_client : ContextClient) -> None: - context_client.SetContext(Context(**json_context('spectrum-admin', name='spectrum-admin'))) - context_client.SetTopology(Topology(**json_topology('spectrum-topology', context_id=CONTEXT_ID))) +def test_optical_spectrum_reservation_lifecycle(context_client : ContextClient, context_db_mb) -> None: + _set_context_topology(context_client) + link_1_id = _set_optical_link(context_db_mb, 'roadm-a-roadm-b') + link_2_id = _set_optical_link(context_db_mb, 'roadm-b-roadm-c') context_uuid = context_get_uuid(ContextId(**CONTEXT_ID), allow_random=False) _, topology_uuid = topology_get_uuid(Topology(**json_topology('spectrum-topology', context_id=CONTEXT_ID)).topology_id) - link_1_uuid = link_get_uuid(LinkId(**LINK_1_ID), allow_random=False) - link_2_uuid = link_get_uuid(LinkId(**LINK_2_ID), allow_random=False) + link_1_uuid = link_get_uuid(link_1_id, allow_random=False) + link_2_uuid = link_get_uuid(link_2_id, allow_random=False) reservation = _reservation('reservation-a', 10, 25) reservation_id = context_client.SetOpticalSpectrumReservation(reservation) @@ -101,3 +158,44 @@ def test_optical_spectrum_reservation_lifecycle(context_client : ContextClient) replacement_id = context_client.SetOpticalSpectrumReservation(_reservation('reservation-replacement', 10, 25)) replacement = context_client.GetOpticalSpectrumReservation(replacement_id) assert replacement.status == OpticalSpectrumReservationStatusEnum.OPTICALSPECTRUMRESERVATIONSTATUS_RESERVED + + +def test_optical_spectrum_reservation_rejects_missing_optical_link(context_client : ContextClient) -> None: + _set_context_topology(context_client) + + with pytest.raises(grpc.RpcError) as e: + context_client.SetOpticalSpectrumReservation(_reservation( + 'reservation-missing-link', 10, 25, link_ids=[json_link_id('missing-optical-link')] + )) + assert e.value.code() == grpc.StatusCode.NOT_FOUND + + +def test_optical_spectrum_reservation_rejects_link_outside_topology(context_client : ContextClient, context_db_mb) -> None: + _set_context_topology(context_client) + context_client.SetTopology(Topology(**json_topology('other-topology', context_id=CONTEXT_ID))) + link_id = _set_optical_link(context_db_mb, 'roadm-outside-topology') + other_topology_id = json_topology_id('other-topology', context_id=CONTEXT_ID) + + with pytest.raises(grpc.RpcError) as e: + context_client.SetOpticalSpectrumReservation(_reservation( + 'reservation-outside-topology', 10, 25, + link_ids=[{'link_uuid': {'uuid': link_id.link_uuid.uuid}}], + topology_id=other_topology_id, + )) + assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT + + +def test_optical_spectrum_reservation_rejects_occupied_optical_link_slot( + context_client : ContextClient, context_db_mb +) -> None: + _set_context_topology(context_client) + link_id = _set_optical_link( + context_db_mb, 'roadm-occupied-slots', available_slots=list(range(0, 12)) + list(range(13, 80)) + ) + + with pytest.raises(grpc.RpcError) as e: + context_client.SetOpticalSpectrumReservation(_reservation( + 'reservation-occupied-slot', 10, 25, + link_ids=[{'link_uuid': {'uuid': link_id.link_uuid.uuid}}], + )) + assert e.value.code() == grpc.StatusCode.ALREADY_EXISTS -- GitLab From 057ff58742c0083a10f8183317f8af1cd1116060 Mon Sep 17 00:00:00 2001 From: gifrerenom Date: Tue, 16 Jun 2026 16:51:57 +0000 Subject: [PATCH 06/11] Add temporal redeploy script hacks --- deploy/all.sh | 4 ++-- my_deploy.sh | 10 +++++----- redeploy.sh | 17 +++++++++++++++++ 3 files changed, 24 insertions(+), 7 deletions(-) create mode 100644 redeploy.sh diff --git a/deploy/all.sh b/deploy/all.sh index 0f71d6ded..72c8643aa 100755 --- a/deploy/all.sh +++ b/deploy/all.sh @@ -250,7 +250,7 @@ export GRAF_EXT_PORT_HTTP=${GRAF_EXT_PORT_HTTP:-"3000"} ./deploy/nats.sh # Deploy QuestDB -./deploy/qdb.sh +#./deploy/qdb.sh # Deploy Apache Kafka ./deploy/kafka.sh @@ -259,7 +259,7 @@ export GRAF_EXT_PORT_HTTP=${GRAF_EXT_PORT_HTTP:-"3000"} # ./deploy/monitoring.sh # Expose Dashboard -./deploy/expose_dashboard.sh +#./deploy/expose_dashboard.sh # Deploy TeraFlowSDN ./deploy/tfs.sh diff --git a/my_deploy.sh b/my_deploy.sh index bc7e2dc97..dd467d85e 100644 --- a/my_deploy.sh +++ b/my_deploy.sh @@ -38,11 +38,11 @@ export TFS_COMPONENTS="context device pathcomp service nbi webui" # To manage optical connections, "service" requires "opticalcontroller" to be deployed # before "service", thus we "hack" the TFS_COMPONENTS environment variable prepending the # "opticalcontroller" only if "service" is already in TFS_COMPONENTS, and re-export it. -#if [[ "$TFS_COMPONENTS" == *"service"* ]]; then -# BEFORE="${TFS_COMPONENTS% service*}" -# AFTER="${TFS_COMPONENTS#* service}" -# export TFS_COMPONENTS="${BEFORE} opticalcontroller service ${AFTER}" -#fi +if [[ "$TFS_COMPONENTS" == *"service"* ]]; then + BEFORE="${TFS_COMPONENTS% service*}" + AFTER="${TFS_COMPONENTS#* service}" + export TFS_COMPONENTS="${BEFORE} opticalcontroller service ${AFTER}" +fi # Uncomment to activate ZTP #export TFS_COMPONENTS="${TFS_COMPONENTS} ztp" diff --git a/redeploy.sh b/redeploy.sh new file mode 100644 index 000000000..4ddb2f0a4 --- /dev/null +++ b/redeploy.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# 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. + +source my_deploy.sh +./deploy/all.sh -- GitLab From cb5aeddd6e023134298ae7660c7961b4475be385 Mon Sep 17 00:00:00 2001 From: gifrerenom Date: Tue, 16 Jun 2026 17:03:52 +0000 Subject: [PATCH 07/11] Update temporal redeploy script hacks --- my_deploy.sh | 2 +- redeploy.sh | 0 2 files changed, 1 insertion(+), 1 deletion(-) mode change 100644 => 100755 redeploy.sh diff --git a/my_deploy.sh b/my_deploy.sh index dd467d85e..5bbe31819 100644 --- a/my_deploy.sh +++ b/my_deploy.sh @@ -143,7 +143,7 @@ export CRDB_PASSWORD="tfs123" export CRDB_DEPLOY_MODE="single" # Disable flag for dropping database, if it exists. -export CRDB_DROP_DATABASE_IF_EXISTS="" +export CRDB_DROP_DATABASE_IF_EXISTS="YES" # Disable flag for re-deploying CockroachDB from scratch. export CRDB_REDEPLOY="" diff --git a/redeploy.sh b/redeploy.sh old mode 100644 new mode 100755 -- GitLab From 294ec19e75012c48a6ccbbf8babd4505deb2afae Mon Sep 17 00:00:00 2001 From: gifrerenom Date: Tue, 16 Jun 2026 17:23:03 +0000 Subject: [PATCH 08/11] NBI component - TFS-API connector: - Implement gRPC error mapping to HTTP status and add corresponding tests - Added bits and pieces for future integration tests --- src/nbi/service/tfs_api/Resources.py | 75 +++++-- src/nbi/tests/test_tfs_api.py | 28 +++ .../tests/test_tfs_api_grpc_error_mapping.py | 58 ++++++ src/tests/spectrum_negotiation/README.md | 41 ++++ .../live_reservation_validation.py | 196 ++++++++++++++++++ 5 files changed, 380 insertions(+), 18 deletions(-) create mode 100644 src/nbi/tests/test_tfs_api_grpc_error_mapping.py create mode 100644 src/tests/spectrum_negotiation/README.md create mode 100644 src/tests/spectrum_negotiation/live_reservation_validation.py diff --git a/src/nbi/service/tfs_api/Resources.py b/src/nbi/service/tfs_api/Resources.py index 08b1f1a2e..3711cc24d 100644 --- a/src/nbi/service/tfs_api/Resources.py +++ b/src/nbi/service/tfs_api/Resources.py @@ -15,6 +15,7 @@ import json import logging from typing import Dict, List +import grpc from flask.json import jsonify from flask_restful import Resource, request from werkzeug.exceptions import BadRequest @@ -39,6 +40,29 @@ from .Tools import ( LOGGER = logging.getLogger(__name__) +_GRPC_TO_HTTP_STATUS = { + grpc.StatusCode.INVALID_ARGUMENT: 400, + grpc.StatusCode.FAILED_PRECONDITION: 400, + grpc.StatusCode.NOT_FOUND: 404, + grpc.StatusCode.ALREADY_EXISTS: 409, + grpc.StatusCode.PERMISSION_DENIED: 403, + grpc.StatusCode.UNAUTHENTICATED: 401, + grpc.StatusCode.UNAVAILABLE: 503, + grpc.StatusCode.DEADLINE_EXCEEDED: 504, +} + + +def _format_grpc_error(exc : grpc.RpcError): + grpc_status = exc.code() + http_status = _GRPC_TO_HTTP_STATUS.get(grpc_status, 500) + details = exc.details() if hasattr(exc, 'details') else str(exc) + LOGGER.warning('Mapping gRPC error to HTTP status: grpc_status=%s http_status=%s details=%s', + grpc_status, http_status, details) + return jsonify({ + 'error': grpc_status.name if grpc_status is not None else 'UNKNOWN', + 'message': details, + }), http_status + class _Resource(Resource): def __init__(self) -> None: @@ -364,12 +388,15 @@ class OpticalSpectrumReservations(_Resource): for reservation in json_requests: if context_uuid != reservation['reservation_id']['context_id']['context_uuid']['uuid']: raise BadRequest('Mismatching context_uuid') - return jsonify([ - grpc_message_to_json(self.context_client.SetOpticalSpectrumReservation( - grpc_optical_spectrum_reservation(reservation) - )) - for reservation in json_requests - ]) + try: + return jsonify([ + grpc_message_to_json(self.context_client.SetOpticalSpectrumReservation( + grpc_optical_spectrum_reservation(reservation) + )) + for reservation in json_requests + ]) + except grpc.RpcError as exc: + return _format_grpc_error(exc) class OpticalSpectrumReservation(_Resource): def get(self, context_uuid : str, reservation_uuid : str): @@ -383,14 +410,20 @@ class OpticalSpectrumReservation(_Resource): raise BadRequest('Mismatching context_uuid') if reservation_uuid != reservation['reservation_id']['reservation_uuid']['uuid']: raise BadRequest('Mismatching reservation_uuid') - return format_grpc_to_json(self.context_client.SetOpticalSpectrumReservation( - grpc_optical_spectrum_reservation(reservation) - )) + try: + return format_grpc_to_json(self.context_client.SetOpticalSpectrumReservation( + grpc_optical_spectrum_reservation(reservation) + )) + except grpc.RpcError as exc: + return _format_grpc_error(exc) def delete(self, context_uuid : str, reservation_uuid : str): - return format_grpc_to_json(self.context_client.ReleaseOpticalSpectrumReservation( - grpc_optical_spectrum_reservation_id(context_uuid, reservation_uuid) - )) + try: + return format_grpc_to_json(self.context_client.ReleaseOpticalSpectrumReservation( + grpc_optical_spectrum_reservation_id(context_uuid, reservation_uuid) + )) + except grpc.RpcError as exc: + return _format_grpc_error(exc) class OpticalSpectrumReservationConsume(_Resource): def post(self, context_uuid : str, reservation_uuid : str): @@ -406,15 +439,21 @@ class OpticalSpectrumReservationConsume(_Resource): raise BadRequest('Mismatching context_uuid') if reservation_uuid != reservation['reservation_id']['reservation_uuid']['uuid']: raise BadRequest('Mismatching reservation_uuid') - return format_grpc_to_json(self.context_client.ConsumeOpticalSpectrumReservation( - grpc_optical_spectrum_reservation(reservation) - )) + try: + return format_grpc_to_json(self.context_client.ConsumeOpticalSpectrumReservation( + grpc_optical_spectrum_reservation(reservation) + )) + except grpc.RpcError as exc: + return _format_grpc_error(exc) class OpticalSpectrumReservationRelease(_Resource): def post(self, context_uuid : str, reservation_uuid : str): - return format_grpc_to_json(self.context_client.ReleaseOpticalSpectrumReservation( - grpc_optical_spectrum_reservation_id(context_uuid, reservation_uuid) - )) + try: + return format_grpc_to_json(self.context_client.ReleaseOpticalSpectrumReservation( + grpc_optical_spectrum_reservation_id(context_uuid, reservation_uuid) + )) + except grpc.RpcError as exc: + return _format_grpc_error(exc) class ConnectionIds(_Resource): def get(self, context_uuid : str, service_uuid : str): diff --git a/src/nbi/tests/test_tfs_api.py b/src/nbi/tests/test_tfs_api.py index 2ab5bde49..d4a97fa09 100644 --- a/src/nbi/tests/test_tfs_api.py +++ b/src/nbi/tests/test_tfs_api.py @@ -243,6 +243,34 @@ def test_rest_optical_spectrum_reservation_lifecycle( reply, reservation_uuid, status='OPTICALSPECTRUMRESERVATIONSTATUS_RESERVED' ) + overlap_uuid = 'reservation-nbi-overlap' + overlap_reservation = _optical_spectrum_reservation(DEFAULT_CONTEXT_NAME, overlap_uuid) + overlap_reservation['n_start'] = 20 + overlap_reservation['n_end'] = 30 + overlap_reservation['required_slots'] = 11 + overlap_reservation['correlation_id'] = overlap_uuid + reply = do_rest_post_request( + '/tfs-api/context/{:s}/optical_spectrum_reservations'.format(context_uuid), + overlap_reservation, + expected_status_codes={409} + ) + assert reply['error'] == 'ALREADY_EXISTS' + assert 'overlapping spectrum reservation' in reply['message'] + + occupied_uuid = 'reservation-nbi-occupied' + occupied_reservation = _optical_spectrum_reservation(DEFAULT_CONTEXT_NAME, occupied_uuid) + occupied_reservation['n_start'] = 0 + occupied_reservation['n_end'] = 0 + occupied_reservation['required_slots'] = 1 + occupied_reservation['correlation_id'] = occupied_uuid + reply = do_rest_post_request( + '/tfs-api/context/{:s}/optical_spectrum_reservations'.format(context_uuid), + occupied_reservation, + expected_status_codes={409} + ) + assert reply['error'] == 'ALREADY_EXISTS' + assert 'is not available on optical link' in reply['message'] + reservation['owner_id'] = 'nbi-unit-test-updated' reply = do_rest_put_request( '/tfs-api/context/{:s}/optical_spectrum_reservation/{:s}'.format( diff --git a/src/nbi/tests/test_tfs_api_grpc_error_mapping.py b/src/nbi/tests/test_tfs_api_grpc_error_mapping.py new file mode 100644 index 000000000..39b0e070c --- /dev/null +++ b/src/nbi/tests/test_tfs_api_grpc_error_mapping.py @@ -0,0 +1,58 @@ +# 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 flask import Flask +from nbi.service.tfs_api.Resources import _format_grpc_error + + +class _FakeRpcError(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 _mapped_error(code, details='mapped error'): + app = Flask(__name__) + with app.app_context(): + response, status = _format_grpc_error(_FakeRpcError(code, details)) + return response.get_json(), status + + +def test_grpc_already_exists_maps_to_http_conflict(): + reply, status = _mapped_error(grpc.StatusCode.ALREADY_EXISTS, 'overlapping spectrum reservation') + assert status == 409 + assert reply['error'] == 'ALREADY_EXISTS' + assert reply['message'] == 'overlapping spectrum reservation' + + +def test_grpc_invalid_argument_maps_to_http_bad_request(): + reply, status = _mapped_error(grpc.StatusCode.INVALID_ARGUMENT, 'invalid optical link') + assert status == 400 + assert reply['error'] == 'INVALID_ARGUMENT' + assert reply['message'] == 'invalid optical link' + + +def test_grpc_not_found_maps_to_http_not_found(): + reply, status = _mapped_error(grpc.StatusCode.NOT_FOUND, 'reservation not found') + assert status == 404 + assert reply['error'] == 'NOT_FOUND' + assert reply['message'] == 'reservation not found' diff --git a/src/tests/spectrum_negotiation/README.md b/src/tests/spectrum_negotiation/README.md new file mode 100644 index 000000000..7b439b1a8 --- /dev/null +++ b/src/tests/spectrum_negotiation/README.md @@ -0,0 +1,41 @@ +# Spectrum Negotiation Integration Tests + +This folder contains executable integration-test seeds for the optical +spectrum reservation workflow used by the prototype. + +These tests are intentionally not wired into GitLab CI yet. They require a +running TeraFlowSDN deployment with: + +- TFS-API reachable over HTTP. +- At least one context and topology loaded. +- Optical links exposed through `/tfs-api/optical_links`. +- Optical spectrum reservation endpoints exposed through `/tfs-api/context//...`. + +## Live Reservation Validation + +Run against a local TFS deployment: + +```bash +python3 src/tests/spectrum_negotiation/live_reservation_validation.py +``` + +Run against a specific testbed node: + +```bash +python3 src/tests/spectrum_negotiation/live_reservation_validation.py \ + --base-url http://172.16.0.101/tfs-api +``` + +The script validates: + +- TFS-API context and topology discovery. +- Optical link inventory discovery. +- Creation of a temporary optical spectrum reservation. +- Retrieval of the reservation in `RESERVED` state. +- Rejection of an overlapping reservation. +- Rejection of a reservation over an unavailable slot. +- Release of the temporary reservation. +- Final cleanup verification that no active `codex-live-test` reservation remains. + +Expected rejection status for overlapping or occupied-slot requests is HTTP +`409 Conflict`, mapped from gRPC `ALREADY_EXISTS`. diff --git a/src/tests/spectrum_negotiation/live_reservation_validation.py b/src/tests/spectrum_negotiation/live_reservation_validation.py new file mode 100644 index 000000000..7329abfad --- /dev/null +++ b/src/tests/spectrum_negotiation/live_reservation_validation.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +# 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 argparse +import json +import time +import urllib.error +import urllib.parse +import urllib.request + +DEFAULT_BASE_URL = 'http://127.0.0.1/tfs-api' +OWNER_ID = 'codex-live-test' + + +def http_request(base_url, method, path, payload=None, timeout=15): + data = None + headers = {} + if payload is not None: + data = json.dumps(payload).encode('utf-8') + headers['Content-Type'] = 'application/json' + request = urllib.request.Request(base_url + path, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + body = response.read().decode('utf-8') + return response.status, json.loads(body) if body else None + except urllib.error.HTTPError as exc: + body = exc.read().decode('utf-8') + try: + payload = json.loads(body) if body else None + except json.JSONDecodeError: + payload = body + return exc.code, payload + + +def first_context_and_topology(base_url): + status, contexts = http_request(base_url, 'GET', '/contexts') + assert status == 200, (status, contexts) + assert contexts.get('contexts'), contexts + context = contexts['contexts'][0] + assert context.get('topology_ids'), context + return ( + context['context_id']['context_uuid']['uuid'], + context['topology_ids'][0]['topology_uuid']['uuid'], + ) + + +def optical_link_slots(link): + return link.get('optical_details', {}).get('c_slots', {}) + + +def optical_link_uuid(link): + return link['link_id']['link_uuid']['uuid'] + + +def select_optical_link(base_url, n_start, n_end): + status, optical_links = http_request(base_url, 'GET', '/optical_links') + assert status == 200, (status, optical_links) + links = optical_links.get('optical_links', []) + assert links, 'No optical links exposed by TFS-API' + + candidate = None + unavailable = None + for link in links: + link_uuid = optical_link_uuid(link) + c_slots = optical_link_slots(link) + if candidate is None and all(c_slots.get(str(slot)) == 1 for slot in range(n_start, n_end + 1)): + candidate = (link_uuid, c_slots) + if unavailable is None: + for slot, state in c_slots.items(): + if state != 1: + unavailable = (link_uuid, int(slot), state) + break + if candidate and unavailable: + break + + assert candidate is not None, 'No optical link has the requested validation slots available' + return len(links), candidate[0], unavailable + + +def reservation_payload(context_uuid, topology_uuid, reservation_uuid, link_uuid, n_start, n_end): + return { + 'reservation_id': { + 'context_id': {'context_uuid': {'uuid': context_uuid}}, + 'reservation_uuid': {'uuid': reservation_uuid}, + }, + 'topology_id': { + 'context_id': {'context_uuid': {'uuid': context_uuid}}, + 'topology_uuid': {'uuid': topology_uuid}, + }, + 'optical_link_ids': [{'link_uuid': {'uuid': link_uuid}}], + 'band': 'c_slots', + 'n_start': n_start, + 'n_end': n_end, + 'required_slots': n_end - n_start + 1, + 'owner_id': OWNER_ID, + 'correlation_id': reservation_uuid, + } + + +def active_test_reservations(reply): + reservations = reply.get('reservations', []) + return [ + reservation for reservation in reservations + if reservation.get('owner_id') == OWNER_ID + and reservation.get('status') not in ('OPTICALSPECTRUMRESERVATIONSTATUS_RELEASED', 'RELEASED') + ] + + +def main(): + parser = argparse.ArgumentParser(description='Validate live optical spectrum reservation behavior through TFS-API.') + parser.add_argument('--base-url', default=DEFAULT_BASE_URL, help='TFS-API base URL, default: %(default)s') + parser.add_argument('--n-start', type=int, default=10, help='First validation slot, default: %(default)s') + parser.add_argument('--n-end', type=int, default=25, help='Last validation slot, default: %(default)s') + args = parser.parse_args() + + context_uuid, topology_uuid = first_context_and_topology(args.base_url) + link_count, link_uuid, unavailable = select_optical_link(args.base_url, args.n_start, args.n_end) + list_path = '/context/{:s}/optical_spectrum_reservations'.format(urllib.parse.quote(context_uuid)) + + status, before = http_request(args.base_url, 'GET', list_path) + assert status == 200, (status, before) + + reservation_uuid = 'live-reservation-{:d}'.format(int(time.time())) + reservation = reservation_payload( + context_uuid, topology_uuid, reservation_uuid, link_uuid, args.n_start, args.n_end + ) + created = False + try: + status, create_reply = http_request(args.base_url, 'POST', list_path, reservation) + assert status == 200, (status, create_reply) + created = True + + get_path = '/context/{:s}/optical_spectrum_reservation/{:s}'.format( + urllib.parse.quote(context_uuid), urllib.parse.quote(reservation_uuid) + ) + status, got = http_request(args.base_url, 'GET', get_path) + assert status == 200, (status, got) + assert got.get('status') in ('OPTICALSPECTRUMRESERVATIONSTATUS_RESERVED', 'RESERVED'), got + + overlap_uuid = reservation_uuid + '-overlap' + overlap = reservation_payload( + context_uuid, topology_uuid, overlap_uuid, link_uuid, args.n_start + 10, args.n_end + 5 + ) + status, overlap_reply = http_request(args.base_url, 'POST', list_path, overlap) + assert status == 409, (status, overlap_reply) + + if unavailable is not None: + _, occupied_slot, _ = unavailable + occupied_uuid = reservation_uuid + '-occupied' + occupied = reservation_payload( + context_uuid, topology_uuid, occupied_uuid, link_uuid, occupied_slot, occupied_slot + ) + status, occupied_reply = http_request(args.base_url, 'POST', list_path, occupied) + assert status == 409, (status, occupied_reply) + + finally: + if created: + release_path = '/context/{:s}/optical_spectrum_reservation/{:s}/release'.format( + urllib.parse.quote(context_uuid), urllib.parse.quote(reservation_uuid) + ) + status, release_reply = http_request(args.base_url, 'POST', release_path) + assert status == 200, (status, release_reply) + + get_path = '/context/{:s}/optical_spectrum_reservation/{:s}'.format( + urllib.parse.quote(context_uuid), urllib.parse.quote(reservation_uuid) + ) + status, got = http_request(args.base_url, 'GET', get_path) + assert status == 200, (status, got) + assert got.get('status') in ('OPTICALSPECTRUMRESERVATIONSTATUS_RELEASED', 'RELEASED'), got + + status, after = http_request(args.base_url, 'GET', list_path) + assert status == 200, (status, after) + assert not active_test_reservations(after), after + + print('LIVE_RESERVATION_VALIDATION_OK') + print('context_uuid={:s}'.format(context_uuid)) + print('topology_uuid={:s}'.format(topology_uuid)) + print('optical_links={:d}'.format(link_count)) + print('selected_link={:s}'.format(link_uuid)) + print('reserved_slots={:d}-{:d}'.format(args.n_start, args.n_end)) + + +if __name__ == '__main__': + main() -- GitLab From 1e2d06a53e93ec461e09bae16255540916c55ad3 Mon Sep 17 00:00:00 2001 From: gifrerenom Date: Tue, 16 Jun 2026 18:28:56 +0000 Subject: [PATCH 09/11] NBI component - TFS-API connector: - Refactor gRPC error handling to return response directly in Resources.py and simplify test case in test_tfs_api_grpc_error_mapping.py --- src/nbi/service/tfs_api/Resources.py | 4 ++-- src/nbi/tests/test_tfs_api_grpc_error_mapping.py | 8 ++------ 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/nbi/service/tfs_api/Resources.py b/src/nbi/service/tfs_api/Resources.py index 3711cc24d..536daca8d 100644 --- a/src/nbi/service/tfs_api/Resources.py +++ b/src/nbi/service/tfs_api/Resources.py @@ -58,10 +58,10 @@ def _format_grpc_error(exc : grpc.RpcError): details = exc.details() if hasattr(exc, 'details') else str(exc) LOGGER.warning('Mapping gRPC error to HTTP status: grpc_status=%s http_status=%s details=%s', grpc_status, http_status, details) - return jsonify({ + return { 'error': grpc_status.name if grpc_status is not None else 'UNKNOWN', 'message': details, - }), http_status + }, http_status class _Resource(Resource): diff --git a/src/nbi/tests/test_tfs_api_grpc_error_mapping.py b/src/nbi/tests/test_tfs_api_grpc_error_mapping.py index 39b0e070c..c201e46f9 100644 --- a/src/nbi/tests/test_tfs_api_grpc_error_mapping.py +++ b/src/nbi/tests/test_tfs_api_grpc_error_mapping.py @@ -12,9 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -import grpc -from flask import Flask from nbi.service.tfs_api.Resources import _format_grpc_error +import grpc class _FakeRpcError(grpc.RpcError): @@ -31,10 +30,7 @@ class _FakeRpcError(grpc.RpcError): def _mapped_error(code, details='mapped error'): - app = Flask(__name__) - with app.app_context(): - response, status = _format_grpc_error(_FakeRpcError(code, details)) - return response.get_json(), status + return _format_grpc_error(_FakeRpcError(code, details)) def test_grpc_already_exists_maps_to_http_conflict(): -- GitLab From 6947a3446f295cac39e9498f683dccc7d590b2b4 Mon Sep 17 00:00:00 2001 From: gifrerenom Date: Wed, 17 Jun 2026 06:14:20 +0000 Subject: [PATCH 10/11] Revert remporary deploy scripts --- deploy/all.sh | 4 ++-- my_deploy.sh | 12 ++++++------ redeploy.sh | 17 ----------------- 3 files changed, 8 insertions(+), 25 deletions(-) delete mode 100755 redeploy.sh diff --git a/deploy/all.sh b/deploy/all.sh index cc20f7f3e..ca0beea31 100755 --- a/deploy/all.sh +++ b/deploy/all.sh @@ -257,7 +257,7 @@ bash scripts/dockerhub_login.sh ./deploy/nats.sh # Deploy QuestDB -#./deploy/qdb.sh +./deploy/qdb.sh # Deploy Apache Kafka ./deploy/kafka.sh @@ -266,7 +266,7 @@ bash scripts/dockerhub_login.sh # ./deploy/monitoring.sh # Expose Dashboard -#./deploy/expose_dashboard.sh +./deploy/expose_dashboard.sh # Deploy TeraFlowSDN ./deploy/tfs.sh diff --git a/my_deploy.sh b/my_deploy.sh index 64b0fd06c..6a4918a82 100644 --- a/my_deploy.sh +++ b/my_deploy.sh @@ -43,11 +43,11 @@ export TFS_COMPONENTS="context device pathcomp service nbi webui" # To manage optical connections, "service" requires "opticalcontroller" to be deployed # before "service", thus we "hack" the TFS_COMPONENTS environment variable prepending the # "opticalcontroller" only if "service" is already in TFS_COMPONENTS, and re-export it. -if [[ "$TFS_COMPONENTS" == *"service"* ]]; then - BEFORE="${TFS_COMPONENTS% service*}" - AFTER="${TFS_COMPONENTS#* service}" - export TFS_COMPONENTS="${BEFORE} opticalcontroller service ${AFTER}" -fi +#if [[ "$TFS_COMPONENTS" == *"service"* ]]; then +# BEFORE="${TFS_COMPONENTS% service*}" +# AFTER="${TFS_COMPONENTS#* service}" +# export TFS_COMPONENTS="${BEFORE} opticalcontroller service ${AFTER}" +#fi # Uncomment to activate ZTP #export TFS_COMPONENTS="${TFS_COMPONENTS} ztp" @@ -148,7 +148,7 @@ export CRDB_PASSWORD="tfs123" export CRDB_DEPLOY_MODE="single" # Disable flag for dropping database, if it exists. -export CRDB_DROP_DATABASE_IF_EXISTS="YES" +export CRDB_DROP_DATABASE_IF_EXISTS="" # Disable flag for re-deploying CockroachDB from scratch. export CRDB_REDEPLOY="" diff --git a/redeploy.sh b/redeploy.sh deleted file mode 100755 index 4ddb2f0a4..000000000 --- a/redeploy.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/bash -# 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. - -source my_deploy.sh -./deploy/all.sh -- GitLab From 027a3305e8a2dd50d76d017df4f489e02daeca6e Mon Sep 17 00:00:00 2001 From: gifrerenom Date: Wed, 17 Jun 2026 07:25:37 +0000 Subject: [PATCH 11/11] Common - Tests - Mock Context Servicer: - Implement optical spectrum reservation validation and overlap checks --- src/common/tests/MockServicerImpl_Context.py | 56 ++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/src/common/tests/MockServicerImpl_Context.py b/src/common/tests/MockServicerImpl_Context.py index c12f29d1c..d29cb8d3b 100644 --- a/src/common/tests/MockServicerImpl_Context.py +++ b/src/common/tests/MockServicerImpl_Context.py @@ -770,6 +770,61 @@ class MockServicerImpl_Context(ContextServiceServicer): # ----- Optical Spectrum Reservation ------------------------------------------------------------------------------ + def _optical_spectrum_reservation_is_active(self, reservation : OpticalSpectrumReservation) -> bool: + return reservation.status in { + OpticalSpectrumReservationStatusEnum.OPTICALSPECTRUMRESERVATIONSTATUS_RESERVED, + OpticalSpectrumReservationStatusEnum.OPTICALSPECTRUMRESERVATIONSTATUS_CONSUMED, + } + + def _optical_spectrum_reservation_ranges_overlap( + self, a_start : int, a_end : int, b_start : int, b_end : int + ) -> bool: + return a_start <= b_end and b_start <= a_end + + def _validate_optical_spectrum_reservation( + self, request : OpticalSpectrumReservation, container_name : str, context : grpc.ServicerContext + ) -> None: + reservation_uuid = request.reservation_id.reservation_uuid.uuid + topology_uuid = request.topology_id.topology_uuid.uuid + band = str(request.band) + n_start = int(request.n_start) + n_end = int(request.n_end) + requested_link_uuids = {link_id.link_uuid.uuid for link_id in request.optical_link_ids} + + for link_uuid in requested_link_uuids: + optical_link = self.obj_db.get_entry('optical_link', link_uuid, context) + slots = getattr(optical_link.optical_details, band) + for slot_index in range(n_start, n_end + 1): + if int(slots.get(str(slot_index), 1)) != 1: + context.abort( + grpc.StatusCode.ALREADY_EXISTS, + 'slot {:s}:{:d} is not available on optical link({:s})'.format( + band, slot_index, link_uuid + ) + ) + + for reservation in self.obj_db.get_entries(container_name): + if reservation.reservation_id.reservation_uuid.uuid == reservation_uuid: + continue + if reservation.topology_id.topology_uuid.uuid != topology_uuid: + continue + if reservation.band != band: + continue + if not self._optical_spectrum_reservation_is_active(reservation): + continue + existing_link_uuids = {link_id.link_uuid.uuid for link_id in reservation.optical_link_ids} + if len(requested_link_uuids.intersection(existing_link_uuids)) == 0: + continue + if self._optical_spectrum_reservation_ranges_overlap( + n_start, n_end, int(reservation.n_start), int(reservation.n_end) + ): + context.abort( + grpc.StatusCode.ALREADY_EXISTS, + 'OpticalSpectrumReservation({:s}) already exists; overlapping spectrum reservation'.format( + reservation.reservation_id.reservation_uuid.uuid + ) + ) + def ListOpticalSpectrumReservations( self, request : ContextId, context : grpc.ServicerContext ) -> OpticalSpectrumReservationList: @@ -799,6 +854,7 @@ class MockServicerImpl_Context(ContextServiceServicer): container_name = 'optical_spectrum_reservation[{:s}]'.format( str(request.reservation_id.context_id.context_uuid.uuid) ) + self._validate_optical_spectrum_reservation(request, container_name, context) reply, _ = self._set( request, container_name, request.reservation_id.reservation_uuid.uuid, 'reservation_id', TOPIC_CONTEXT ) -- GitLab