Loading src/common/tools/descriptor/Loader.py +18 −5 Original line number Diff line number Diff line Loading @@ -585,6 +585,10 @@ class DescriptorLoader: hints['remote_as'] = first_remote_as if first_session_name: hints['bgp_session_name'] = first_session_name # include human-friendly device name if available device_name = name_by_device.get(device_uuid, '') if device_name: hints['device_name'] = device_name if inferred_sessions: hints['bgp_sessions'] = inferred_sessions Loading Loading @@ -652,6 +656,8 @@ class DescriptorLoader: LOGGER.info('SpineLeaf settings received for fabric %s: %s', fabric_id, settings) device_roles = fabric.get('device_roles', {}) # First pass: set roles for all devices so inventory is complete for subsequent generation set_successful = [] for device_identifier, device_role in device_roles.items(): device_uuid = self._resolve_device_uuid(str(device_identifier), device_map) if not device_uuid: Loading @@ -673,14 +679,21 @@ class DescriptorLoader: request_kwargs['role'] = json.dumps(hints) config = method(SetDeviceRoleRequest(**request_kwargs)) if config is not None and getattr(config, 'device_uuid', ''): set_successful.append((device_uuid, config)) num_ok += 1 except Exception as e: error_list.append(f'{fabric_id}/{device_identifier}: {str(e)}') # Second pass: deploy configs for all devices that were set successfully for device_uuid, config in set_successful: try: self.__spl_cli.DeployDeviceConfig(DeployDeviceConfigRequest( device_uuid=device_uuid, fabric_id=fabric_id, config=config, )) num_ok += 1 except Exception as e: error_list.append(f'{fabric_id}/{device_identifier}: {str(e)}') error_list.append(f'{fabric_id}/{device_uuid}: deploy failed: {str(e)}') self.__results.append(('spine_leaf', 'config', num_ok, error_list)) Loading src/spine_leaf/scripts/fabric_config_builder.py +25 −33 Original line number Diff line number Diff line Loading @@ -135,33 +135,25 @@ class FabricConfigBuilder: )) except Exception: continue else: leaves = inventory.get("leaves", []) for leaf in leaves: # prefer lists, then single value, then loopback local_ip = None remote_ip = None local_underlays = device.get('underlay_ips', []) remote_underlays = leaf.get('underlay_ips', []) if local_underlays and remote_underlays: local_ip = self._strip_prefix(local_underlays[0]) remote_ip = self._strip_prefix(remote_underlays[0]) else: local_ip = self._strip_prefix( device.get('underlay_ip') or (local_underlays[0] if local_underlays else None) or lo ) remote_ip = self._strip_prefix( leaf.get('underlay_ip') or (remote_underlays[0] if remote_underlays else None) or leaf.get('loopback_ip') ) elif role in ("leaf", "gateway"): sessions = device.get('bgp_sessions', []) or [] if sessions: for s in sessions: try: local_ip = self._strip_prefix(str(s.get('local.address') or s.get('local_address') or '')) remote_ip = self._strip_prefix(str(s.get('remote.address') or s.get('remote_address') or '')) remote_as = s.get('remote.as') or s.get('remote_as') or s.get('remote_asn') name = s.get('name') or s.get('peer') or None if local_ip and remote_ip and remote_as is not None: rules.append(self._bgp_session( name=leaf["device_id"], name=name or f'peer-{remote_ip}', local_ip=local_ip, remote_ip=remote_ip, remote_as=leaf["asn"] remote_as=remote_as, )) elif role in ("leaf", "gateway"): except Exception: continue else: spines = inventory.get("spines", []) for spine in spines: local_underlays = device.get('underlay_ips', []) Loading @@ -178,13 +170,13 @@ class FabricConfigBuilder: ) rules.append(self._bgp_session( name=spine["device_id"], name=spine.get("device_name", spine["device_id"]), local_ip=local_ip, remote_ip=remote_ip, remote_as=spine["asn"] )) if not spines: if not sessions and not inventory.get("spines", []): local_underlay = device.get('underlay_ip') or (device.get('underlay_ips', [None])[0] or '') local_ip = self._strip_prefix(local_underlay) or lo remote_ip = device.get('bgp_remote_address') or self._derive_p2p_peer(local_underlay) Loading src/spine_leaf/service/SpineLeafServicerImpl.py +11 −1 Original line number Diff line number Diff line Loading @@ -104,6 +104,10 @@ class SpineLeafServicerImpl(SpineLeafServicer): if session_name: resources['bgp_session_name'] = session_name device_name = str(hints.get('device_name', '') or '') if device_name: resources['device_name'] = device_name bgp_sessions = hints.get('bgp_sessions', []) if isinstance(bgp_sessions, list) and bgp_sessions: resources['bgp_sessions'] = bgp_sessions Loading Loading @@ -229,7 +233,13 @@ class SpineLeafServicerImpl(SpineLeafServicer): @safe_and_metered_rpc_method(METRICS_POOL, LOGGER) def DeployDeviceConfig(self, request: DeployDeviceConfigRequest, context: grpc.ServicerContext) -> DeployResponse: config = self.db.get_config(request.fabric_id, request.device_uuid) or request.config driver_config_rules = self.db.get_driver_config_rules(request.fabric_id, request.device_uuid) stored_rules = self.db.get_driver_config_rules(request.fabric_id, request.device_uuid) fresh_rules = self.db.build_driver_config_rules(request.fabric_id, request.device_uuid) merged = list(stored_rules or []) for r in fresh_rules: if r not in merged: merged.append(r) driver_config_rules = merged payload_json = build_deploy_payload( request.device_uuid, request.fabric_id, Loading src/spine_leaf/state_store.py +2 −0 Original line number Diff line number Diff line Loading @@ -57,6 +57,7 @@ class SpineLeafStateStore: self.device_roles[key] = { 'role': role, 'device_name': resources.get('device_name'), 'asn': resources.get('asn'), 'loopback_ip': resources.get('loopback_ip'), 'underlay_ip': resources.get('underlay_ip'), # Primary underlay IP for BGP sessions Loading Loading @@ -132,6 +133,7 @@ class SpineLeafStateStore: device_info = { 'device_id': device_id, 'device_name': role_cfg.get('device_name'), 'role': role_cfg['role'], 'asn': role_cfg['asn'], 'loopback_ip': role_cfg['loopback_ip'], Loading src/tests/Spine-Leaf-BGP-test/leaf-underlay-multi-test.py 0 → 100644 +213 −0 Original line number Diff line number Diff line # Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import json import logging import os import copy from common.proto.context_pb2 import ConfigRule from common.proto.spineleaf_pb2 import DeployDeviceConfigRequest, UnsetDeviceRoleRequest from common.tools.context_queries.Device import get_device from common.tools.object_factory.ConfigRule import ( json_config_rule_set, json_config_rule_delete, ) from context.client.ContextClient import ContextClient from device.client.DeviceClient import DeviceClient from spine_leaf.client.SpineLeafClient import SpineLeafClient logging.basicConfig(level=logging.INFO) LOGGER = logging.getLogger("leaf_underlay_apply") # ----------------------------- # ENV # ----------------------------- CONTEXTSERVICE_HOST = os.getenv("CONTEXTSERVICE_SERVICE_HOST", "10.152.183.46") CONTEXTSERVICE_PORT = int(os.getenv("CONTEXTSERVICE_SERVICE_PORT_GRPC", "1010")) DEVICESERVICE_HOST = os.getenv("DEVICESERVICE_SERVICE_HOST", "10.152.183.102") DEVICESERVICE_PORT = int(os.getenv("DEVICESERVICE_SERVICE_PORT_GRPC", "2020")) SPINE_LEAFSERVICE_HOST = os.getenv("SPINE_LEAFSERVICE_SERVICE_HOST", "10.152.183.147") SPINE_LEAFSERVICE_PORT = int(os.getenv("SPINE_LEAFSERVICE_SERVICE_PORT_GRPC", "10095")) FABRIC_ID = os.getenv("FABRIC_ID", "354a08b7-3730-5dd6-9472-47554115818b") # ----------------------------- # LEAF DEVICES ONLY # ----------------------------- LEAF_DEVICE_UUIDS = [ "1761a9f5-2672-5996-b3f3-3e91eaef20dc", "03d3c087-da28-539e-aae0-60da7b90bad6", "6ac9614b-6893-549d-b39c-d4af4320678a", "dc0d2a74-3415-5a4c-ac65-0f1ef1ca769b", ] # ----------------------------- # FLAGS # ----------------------------- def is_apply_enabled() -> bool: return os.getenv("APPLY_CONFIG", "false").lower() in {"1", "true", "yes"} def is_deconfigure_enabled() -> bool: return os.getenv("DECONFIGURE", "false").lower() in {"1", "true", "yes"} # ----------------------------- # PARSER # ----------------------------- def parse_rule(entry): if not isinstance(entry, (list, tuple)) or len(entry) != 2: return None key, value = entry if isinstance(value, str): try: value = json.loads(value) except Exception: return None if not isinstance(value, dict): return None return key, value # ----------------------------- # APPLY ENGINE # ----------------------------- class LeafUnderlayApplier: def __init__(self): self.spine_leaf_client = SpineLeafClient( host=SPINE_LEAFSERVICE_HOST, port=SPINE_LEAFSERVICE_PORT, ) def run(self): context_client = ContextClient( host=CONTEXTSERVICE_HOST, port=CONTEXTSERVICE_PORT, ) device_client = DeviceClient( host=DEVICESERVICE_HOST, port=DEVICESERVICE_PORT, ) try: for device_uuid in LEAF_DEVICE_UUIDS: self.process_device(context_client, device_client, device_uuid) finally: device_client.close() context_client.close() def process_device(self, context_client, device_client, device_uuid): LOGGER.info("Processing LEAF device: %s", device_uuid) request = DeployDeviceConfigRequest( fabric_id=FABRIC_ID, device_uuid=device_uuid, ) response = self.spine_leaf_client.DeployDeviceConfig(request) payload = json.loads(getattr(response, "message", "") or "{}") raw_rules = payload.get("driver_config_rules", []) set_rules = [] delete_rules = [] deconfigure = is_deconfigure_enabled() apply = is_apply_enabled() or deconfigure for entry in raw_rules: parsed = parse_rule(entry) if not parsed: continue key, value = parsed set_rules.append(ConfigRule(**json_config_rule_set(key, value))) if deconfigure: delete_rules.append(ConfigRule(**json_config_rule_delete(key, value))) LOGGER.info( "Device %s -> rules generated: set=%d delete=%d", device_uuid, len(set_rules), len(delete_rules), ) device = get_device( context_client, device_uuid, rw_copy=True, include_endpoints=False, include_config_rules=False, include_components=False, ) if device is None: LOGGER.error("Device not found: %s", device_uuid) return working_device = copy.deepcopy(device) # IMPORTANT: prevent duplication on rerun (clear protobuf repeated field) del working_device.device_config.config_rules[:] if not apply: LOGGER.warning("DRY RUN - no changes applied for %s", device_uuid) return if deconfigure: LOGGER.info("DECONFIGURING LEAF %s", device_uuid) working_device.device_config.config_rules.extend(delete_rules) result = device_client.ConfigureDevice(working_device) LOGGER.info("Deconfig result %s => %s", device_uuid, result) self.spine_leaf_client.UnsetDeviceRole( UnsetDeviceRoleRequest( fabric_id=FABRIC_ID, device_uuid=device_uuid, ) ) else: LOGGER.info("APPLYING LEAF UNDERLAY %s", device_uuid) working_device.device_config.config_rules.extend(set_rules) result = device_client.ConfigureDevice(working_device) LOGGER.info("Apply result %s => %s", device_uuid, result) # ----------------------------- # ENTRYPOINT # ----------------------------- if __name__ == "__main__": LeafUnderlayApplier().run() No newline at end of file Loading
src/common/tools/descriptor/Loader.py +18 −5 Original line number Diff line number Diff line Loading @@ -585,6 +585,10 @@ class DescriptorLoader: hints['remote_as'] = first_remote_as if first_session_name: hints['bgp_session_name'] = first_session_name # include human-friendly device name if available device_name = name_by_device.get(device_uuid, '') if device_name: hints['device_name'] = device_name if inferred_sessions: hints['bgp_sessions'] = inferred_sessions Loading Loading @@ -652,6 +656,8 @@ class DescriptorLoader: LOGGER.info('SpineLeaf settings received for fabric %s: %s', fabric_id, settings) device_roles = fabric.get('device_roles', {}) # First pass: set roles for all devices so inventory is complete for subsequent generation set_successful = [] for device_identifier, device_role in device_roles.items(): device_uuid = self._resolve_device_uuid(str(device_identifier), device_map) if not device_uuid: Loading @@ -673,14 +679,21 @@ class DescriptorLoader: request_kwargs['role'] = json.dumps(hints) config = method(SetDeviceRoleRequest(**request_kwargs)) if config is not None and getattr(config, 'device_uuid', ''): set_successful.append((device_uuid, config)) num_ok += 1 except Exception as e: error_list.append(f'{fabric_id}/{device_identifier}: {str(e)}') # Second pass: deploy configs for all devices that were set successfully for device_uuid, config in set_successful: try: self.__spl_cli.DeployDeviceConfig(DeployDeviceConfigRequest( device_uuid=device_uuid, fabric_id=fabric_id, config=config, )) num_ok += 1 except Exception as e: error_list.append(f'{fabric_id}/{device_identifier}: {str(e)}') error_list.append(f'{fabric_id}/{device_uuid}: deploy failed: {str(e)}') self.__results.append(('spine_leaf', 'config', num_ok, error_list)) Loading
src/spine_leaf/scripts/fabric_config_builder.py +25 −33 Original line number Diff line number Diff line Loading @@ -135,33 +135,25 @@ class FabricConfigBuilder: )) except Exception: continue else: leaves = inventory.get("leaves", []) for leaf in leaves: # prefer lists, then single value, then loopback local_ip = None remote_ip = None local_underlays = device.get('underlay_ips', []) remote_underlays = leaf.get('underlay_ips', []) if local_underlays and remote_underlays: local_ip = self._strip_prefix(local_underlays[0]) remote_ip = self._strip_prefix(remote_underlays[0]) else: local_ip = self._strip_prefix( device.get('underlay_ip') or (local_underlays[0] if local_underlays else None) or lo ) remote_ip = self._strip_prefix( leaf.get('underlay_ip') or (remote_underlays[0] if remote_underlays else None) or leaf.get('loopback_ip') ) elif role in ("leaf", "gateway"): sessions = device.get('bgp_sessions', []) or [] if sessions: for s in sessions: try: local_ip = self._strip_prefix(str(s.get('local.address') or s.get('local_address') or '')) remote_ip = self._strip_prefix(str(s.get('remote.address') or s.get('remote_address') or '')) remote_as = s.get('remote.as') or s.get('remote_as') or s.get('remote_asn') name = s.get('name') or s.get('peer') or None if local_ip and remote_ip and remote_as is not None: rules.append(self._bgp_session( name=leaf["device_id"], name=name or f'peer-{remote_ip}', local_ip=local_ip, remote_ip=remote_ip, remote_as=leaf["asn"] remote_as=remote_as, )) elif role in ("leaf", "gateway"): except Exception: continue else: spines = inventory.get("spines", []) for spine in spines: local_underlays = device.get('underlay_ips', []) Loading @@ -178,13 +170,13 @@ class FabricConfigBuilder: ) rules.append(self._bgp_session( name=spine["device_id"], name=spine.get("device_name", spine["device_id"]), local_ip=local_ip, remote_ip=remote_ip, remote_as=spine["asn"] )) if not spines: if not sessions and not inventory.get("spines", []): local_underlay = device.get('underlay_ip') or (device.get('underlay_ips', [None])[0] or '') local_ip = self._strip_prefix(local_underlay) or lo remote_ip = device.get('bgp_remote_address') or self._derive_p2p_peer(local_underlay) Loading
src/spine_leaf/service/SpineLeafServicerImpl.py +11 −1 Original line number Diff line number Diff line Loading @@ -104,6 +104,10 @@ class SpineLeafServicerImpl(SpineLeafServicer): if session_name: resources['bgp_session_name'] = session_name device_name = str(hints.get('device_name', '') or '') if device_name: resources['device_name'] = device_name bgp_sessions = hints.get('bgp_sessions', []) if isinstance(bgp_sessions, list) and bgp_sessions: resources['bgp_sessions'] = bgp_sessions Loading Loading @@ -229,7 +233,13 @@ class SpineLeafServicerImpl(SpineLeafServicer): @safe_and_metered_rpc_method(METRICS_POOL, LOGGER) def DeployDeviceConfig(self, request: DeployDeviceConfigRequest, context: grpc.ServicerContext) -> DeployResponse: config = self.db.get_config(request.fabric_id, request.device_uuid) or request.config driver_config_rules = self.db.get_driver_config_rules(request.fabric_id, request.device_uuid) stored_rules = self.db.get_driver_config_rules(request.fabric_id, request.device_uuid) fresh_rules = self.db.build_driver_config_rules(request.fabric_id, request.device_uuid) merged = list(stored_rules or []) for r in fresh_rules: if r not in merged: merged.append(r) driver_config_rules = merged payload_json = build_deploy_payload( request.device_uuid, request.fabric_id, Loading
src/spine_leaf/state_store.py +2 −0 Original line number Diff line number Diff line Loading @@ -57,6 +57,7 @@ class SpineLeafStateStore: self.device_roles[key] = { 'role': role, 'device_name': resources.get('device_name'), 'asn': resources.get('asn'), 'loopback_ip': resources.get('loopback_ip'), 'underlay_ip': resources.get('underlay_ip'), # Primary underlay IP for BGP sessions Loading Loading @@ -132,6 +133,7 @@ class SpineLeafStateStore: device_info = { 'device_id': device_id, 'device_name': role_cfg.get('device_name'), 'role': role_cfg['role'], 'asn': role_cfg['asn'], 'loopback_ip': role_cfg['loopback_ip'], Loading
src/tests/Spine-Leaf-BGP-test/leaf-underlay-multi-test.py 0 → 100644 +213 −0 Original line number Diff line number Diff line # Copyright 2022-2025 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import json import logging import os import copy from common.proto.context_pb2 import ConfigRule from common.proto.spineleaf_pb2 import DeployDeviceConfigRequest, UnsetDeviceRoleRequest from common.tools.context_queries.Device import get_device from common.tools.object_factory.ConfigRule import ( json_config_rule_set, json_config_rule_delete, ) from context.client.ContextClient import ContextClient from device.client.DeviceClient import DeviceClient from spine_leaf.client.SpineLeafClient import SpineLeafClient logging.basicConfig(level=logging.INFO) LOGGER = logging.getLogger("leaf_underlay_apply") # ----------------------------- # ENV # ----------------------------- CONTEXTSERVICE_HOST = os.getenv("CONTEXTSERVICE_SERVICE_HOST", "10.152.183.46") CONTEXTSERVICE_PORT = int(os.getenv("CONTEXTSERVICE_SERVICE_PORT_GRPC", "1010")) DEVICESERVICE_HOST = os.getenv("DEVICESERVICE_SERVICE_HOST", "10.152.183.102") DEVICESERVICE_PORT = int(os.getenv("DEVICESERVICE_SERVICE_PORT_GRPC", "2020")) SPINE_LEAFSERVICE_HOST = os.getenv("SPINE_LEAFSERVICE_SERVICE_HOST", "10.152.183.147") SPINE_LEAFSERVICE_PORT = int(os.getenv("SPINE_LEAFSERVICE_SERVICE_PORT_GRPC", "10095")) FABRIC_ID = os.getenv("FABRIC_ID", "354a08b7-3730-5dd6-9472-47554115818b") # ----------------------------- # LEAF DEVICES ONLY # ----------------------------- LEAF_DEVICE_UUIDS = [ "1761a9f5-2672-5996-b3f3-3e91eaef20dc", "03d3c087-da28-539e-aae0-60da7b90bad6", "6ac9614b-6893-549d-b39c-d4af4320678a", "dc0d2a74-3415-5a4c-ac65-0f1ef1ca769b", ] # ----------------------------- # FLAGS # ----------------------------- def is_apply_enabled() -> bool: return os.getenv("APPLY_CONFIG", "false").lower() in {"1", "true", "yes"} def is_deconfigure_enabled() -> bool: return os.getenv("DECONFIGURE", "false").lower() in {"1", "true", "yes"} # ----------------------------- # PARSER # ----------------------------- def parse_rule(entry): if not isinstance(entry, (list, tuple)) or len(entry) != 2: return None key, value = entry if isinstance(value, str): try: value = json.loads(value) except Exception: return None if not isinstance(value, dict): return None return key, value # ----------------------------- # APPLY ENGINE # ----------------------------- class LeafUnderlayApplier: def __init__(self): self.spine_leaf_client = SpineLeafClient( host=SPINE_LEAFSERVICE_HOST, port=SPINE_LEAFSERVICE_PORT, ) def run(self): context_client = ContextClient( host=CONTEXTSERVICE_HOST, port=CONTEXTSERVICE_PORT, ) device_client = DeviceClient( host=DEVICESERVICE_HOST, port=DEVICESERVICE_PORT, ) try: for device_uuid in LEAF_DEVICE_UUIDS: self.process_device(context_client, device_client, device_uuid) finally: device_client.close() context_client.close() def process_device(self, context_client, device_client, device_uuid): LOGGER.info("Processing LEAF device: %s", device_uuid) request = DeployDeviceConfigRequest( fabric_id=FABRIC_ID, device_uuid=device_uuid, ) response = self.spine_leaf_client.DeployDeviceConfig(request) payload = json.loads(getattr(response, "message", "") or "{}") raw_rules = payload.get("driver_config_rules", []) set_rules = [] delete_rules = [] deconfigure = is_deconfigure_enabled() apply = is_apply_enabled() or deconfigure for entry in raw_rules: parsed = parse_rule(entry) if not parsed: continue key, value = parsed set_rules.append(ConfigRule(**json_config_rule_set(key, value))) if deconfigure: delete_rules.append(ConfigRule(**json_config_rule_delete(key, value))) LOGGER.info( "Device %s -> rules generated: set=%d delete=%d", device_uuid, len(set_rules), len(delete_rules), ) device = get_device( context_client, device_uuid, rw_copy=True, include_endpoints=False, include_config_rules=False, include_components=False, ) if device is None: LOGGER.error("Device not found: %s", device_uuid) return working_device = copy.deepcopy(device) # IMPORTANT: prevent duplication on rerun (clear protobuf repeated field) del working_device.device_config.config_rules[:] if not apply: LOGGER.warning("DRY RUN - no changes applied for %s", device_uuid) return if deconfigure: LOGGER.info("DECONFIGURING LEAF %s", device_uuid) working_device.device_config.config_rules.extend(delete_rules) result = device_client.ConfigureDevice(working_device) LOGGER.info("Deconfig result %s => %s", device_uuid, result) self.spine_leaf_client.UnsetDeviceRole( UnsetDeviceRoleRequest( fabric_id=FABRIC_ID, device_uuid=device_uuid, ) ) else: LOGGER.info("APPLYING LEAF UNDERLAY %s", device_uuid) working_device.device_config.config_rules.extend(set_rules) result = device_client.ConfigureDevice(working_device) LOGGER.info("Apply result %s => %s", device_uuid, result) # ----------------------------- # ENTRYPOINT # ----------------------------- if __name__ == "__main__": LeafUnderlayApplier().run() No newline at end of file