Loading src/spine_leaf/Dockerfile +7 −0 Original line number Diff line number Diff line Loading @@ -20,6 +20,11 @@ RUN apt-get --yes --quiet --quiet update && \ ENV PYTHONUNBUFFERED=0 RUN GRPC_HEALTH_PROBE_VERSION=v0.2.0 && \ wget -qO/bin/grpc_health_probe https://github.com/grpc-ecosystem/grpc-health-probe/releases/download/${GRPC_HEALTH_PROBE_VERSION}/grpc_health_probe-linux-amd64 && \ chmod +x /bin/grpc_health_probe RUN python3 -m pip install --upgrade 'pip==25.2' RUN python3 -m pip install --upgrade 'setuptools==79.0.0' 'wheel==0.45.1' RUN python3 -m pip install --upgrade 'pip-tools==7.3.0' Loading Loading @@ -53,5 +58,7 @@ COPY src/logical_resources/client/. logical_resources/client/ COPY src/device/__init__.py device/__init__.py COPY src/device/client/. device/client/ COPY src/spine_leaf/. spine_leaf/ COPY src/service/__init__.py service/__init__.py COPY src/service/service/. service/service/ ENTRYPOINT ["python", "-m", "spine_leaf.service"] src/spine_leaf/scripts/fabric_config_builder.py +46 −17 Original line number Diff line number Diff line Loading @@ -53,8 +53,11 @@ class FabricConfigBuilder: )) # ---- VXLAN (leaf only) ---- vni = None if role in ("leaf", "gateway"): vni = device["vnis"][0] vnis = device.get("vnis", []) if vnis and len(vnis) > 0: vni = vnis[0] rules.append(( "/interfaces/vxlan", json.dumps({ Loading @@ -66,26 +69,52 @@ class FabricConfigBuilder: )) # ---- BGP sessions ---- # Prefer per-link underlay IP lists when available. If both local and remote expose # underlay_ips lists, pick the first element of each as the session addresses. Fall # back to single 'underlay_ip' or loopback as necessary. if role == "spine": for leaf in inventory["leaves"]: 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 = local_underlays[0] remote_ip = remote_underlays[0] else: local_ip = device.get('underlay_ip') or (local_underlays[0] if local_underlays else None) or lo remote_ip = leaf.get('underlay_ip') or (remote_underlays[0] if remote_underlays else None) or leaf.get('loopback_ip') rules.append(self._bgp_session( name=leaf["device_id"], local_ip=lo, remote_ip=leaf["loopback_ip"], local_ip=local_ip, remote_ip=remote_ip, remote_as=leaf["asn"] )) elif role in ("leaf", "gateway"): for spine in inventory["spines"]: spines = inventory.get("spines", []) for spine in spines: local_underlays = device.get('underlay_ips', []) remote_underlays = spine.get('underlay_ips', []) if local_underlays and remote_underlays: local_ip = local_underlays[0] remote_ip = remote_underlays[0] else: local_ip = device.get('underlay_ip') or (local_underlays[0] if local_underlays else None) or lo remote_ip = spine.get('underlay_ip') or (remote_underlays[0] if remote_underlays else None) or spine.get('loopback_ip') rules.append(self._bgp_session( name=spine["device_id"], local_ip=lo, remote_ip=spine["loopback_ip"], local_ip=local_ip, remote_ip=remote_ip, remote_as=spine["asn"] )) # ---- EVPN (leaf only) ---- if role in ("leaf", "gateway"): if role in ("leaf", "gateway") and vni is not None: rules.append(( "/network_instances/bgp_evpn", json.dumps({ Loading src/spine_leaf/scripts/resource_allocator.py +40 −7 Original line number Diff line number Diff line Loading @@ -73,10 +73,36 @@ class ResourceAllocator: reply = self.logical_resource_client.GetResourcesByType(ResourceTypeQuery(resource_type=resource_type)) return self._pick_first_available(reply) def allocate_for_role(self, device_uuid: str, fabric_id: str, role: str): resource_types = ['asn', 'loopback', 'ip'] if role in ('leaf', 'gateway'): resource_types.extend(['vlan', 'vni']) @staticmethod def _normalize_loopback(loopback_ip: str) -> str: return loopback_ip.split('/')[0] if loopback_ip else '' def _resource_profile(self, role: str, fabric: Optional[Dict]) -> Dict[str, object]: overlay_type = str((fabric or {}).get('overlay_type', '')).lower() underlay_type = str((fabric or {}).get('underlay_type', '')).lower() use_overlay = overlay_type in ('vxlan', 'bgp_evpn') profile = { 'resource_types': ['asn', 'loopback', 'ip'], 'routing_table': 'vrf-underlay' if role == 'spine' else 'vrf-dataplane', 'local_role': 'ebgp', 'multihop': True, 'afi': 'ip', 'include_overlay': False, } if role in ('leaf', 'gateway') and use_overlay: profile['resource_types'].extend(['vlan', 'vni']) profile['include_overlay'] = True profile['afi'] = 'ip,l2vpn' # Remove legacy IS-IS/OSPF automatic local_role mapping: use BGP underlay by default return profile def allocate_for_role(self, device_uuid: str, fabric_id: str, role: str, fabric: Optional[Dict] = None): profile = self._resource_profile(role, fabric) resource_types = profile['resource_types'] selected: Dict[str, str] = {} reserved: List[TypingTuple[str, str]] = [] Loading @@ -92,20 +118,27 @@ class ResourceAllocator: selected[resource_type] = value reserved.append((resource_type, value)) loopback_ip = selected.get('loopback', '') router_id = self._normalize_loopback(loopback_ip) config = ConfigSetting( device_uuid=device_uuid, endpoint_uuid=device_uuid, local_role=role, asn=int(selected.get('asn', '0') or 0), ip_address=selected.get('loopback', ''), ip_address=loopback_ip, router_id=router_id, remote_address=selected.get('ip', ''), vlan_tag=int(selected.get('vlan', '0') or 0), vni=int(selected.get('vni', '0') or 0), routing_table=str(profile['routing_table']), multihop=bool(profile['multihop']), afi=str(profile['afi']), vlan_tag=int(selected.get('vlan', '0') or 0) if profile['include_overlay'] else 0, vni=int(selected.get('vni', '0') or 0) if profile['include_overlay'] else 0, ) db_resources = { 'asn': str(config.asn), 'loopback_ip': config.ip_address, 'underlay_ip': config.remote_address, # Primary underlay IP for BGP sessions 'underlay_ips': [config.remote_address] if config.remote_address else [], 'vlans': [str(config.vlan_tag)] if config.vlan_tag else [], 'vnis': [str(config.vni)] if config.vni else [], Loading src/spine_leaf/service/SpineLeafServicerImpl.py +12 −14 Original line number Diff line number Diff line Loading @@ -49,15 +49,12 @@ class SpineLeafServicerImpl(SpineLeafServicer): def _allocate_for_role(self, request: SetDeviceRoleRequest, role: str, context: grpc.ServicerContext): fabric = self.db.get_fabric(request.fabric_id) if not fabric: context.set_code(grpc.StatusCode.NOT_FOUND) context.set_details(f'Fabric {request.fabric_id} not found') return None, None fabric = self.db.get_fabric(request.fabric_id) or {} config, resources = self.resource_allocator.allocate_for_role( request.device_uuid, request.fabric_id, role role, fabric, ) if not config or not resources: context.set_code(grpc.StatusCode.FAILED_PRECONDITION) Loading @@ -75,8 +72,9 @@ class SpineLeafServicerImpl(SpineLeafServicer): 'spine_leaf', request.underlay_type, request.overlay_type, '', '', str(request.spine_count), str(request.leaf_count), str(request.gateway_count), ) return FabricDefinitionResponse(fabric_id=fabric_id) Loading @@ -98,6 +96,8 @@ class SpineLeafServicerImpl(SpineLeafServicer): raise RuntimeError(msg) self.db.store_config(request.fabric_id, request.device_uuid, config) config_rules = self.db.build_driver_config_rules(request.fabric_id, request.device_uuid) LOGGER.info('Generated spine config rules for fabric=%s device=%s: %s', request.fabric_id, request.device_uuid, config_rules) return config except Exception as e: Loading Loading @@ -128,6 +128,8 @@ class SpineLeafServicerImpl(SpineLeafServicer): raise RuntimeError(msg) self.db.store_config(request.fabric_id, request.device_uuid, config) config_rules = self.db.build_driver_config_rules(request.fabric_id, request.device_uuid) LOGGER.info('Generated leaf config rules for fabric=%s device=%s: %s', request.fabric_id, request.device_uuid, config_rules) return config except Exception as e: Loading Loading @@ -158,6 +160,8 @@ class SpineLeafServicerImpl(SpineLeafServicer): raise RuntimeError(msg) self.db.store_config(request.fabric_id, request.device_uuid, config) config_rules = self.db.build_driver_config_rules(request.fabric_id, request.device_uuid) LOGGER.info('Generated gateway config rules for fabric=%s device=%s: %s', request.fabric_id, request.device_uuid, config_rules) return config except Exception as e: for rtype, value in self.db.get_reserved_resources_for_device( Loading Loading @@ -195,12 +199,6 @@ class SpineLeafServicerImpl(SpineLeafServicer): @safe_and_metered_rpc_method(METRICS_POOL, LOGGER) def GetFabricInventory(self, request: FabricScope, context: grpc.ServicerContext) -> FabricInventory: fabric = self.db.get_fabric(request.fabric_id) if not fabric: context.set_code(grpc.StatusCode.NOT_FOUND) context.set_details(f'Fabric {request.fabric_id} not found') return FabricInventory(fabric_id=request.fabric_id) inventory = self.db.get_fabric_inventory(request.fabric_id) response = FabricInventory(fabric_id=request.fabric_id) Loading src/spine_leaf/state_store.py +16 −2 Original line number Diff line number Diff line Loading @@ -12,9 +12,9 @@ # See the License for the specific language governing permissions and # limitations under the License. import uuid from typing import Dict, List, Optional, Tuple from service.service.tools.object_uuid import get_uuid_from_string class SpineLeafStateStore: #In-memory state for fabric/device roles and generated configs Loading @@ -23,6 +23,7 @@ class SpineLeafStateStore: self.fabrics: Dict[str, Dict] = {} self.device_roles: Dict[Tuple[str, str], Dict] = {} self.config_store: Dict[Tuple[str, str], Dict] = {} self.driver_config_rules: Dict[Tuple[str, str], List[Tuple[str, str]]] = {} self.reserved_resources: Dict[str, Dict[str, List[Tuple[str, str]]]] = {} def create_fabric( Loading @@ -34,7 +35,7 @@ class SpineLeafStateStore: asn_range: str, ip_range: str, ) -> Tuple[str, bool, str]: fabric_id = f"{fabric_name}-{uuid.uuid4().hex[:8]}" fabric_id = get_uuid_from_string(fabric_name) self.fabrics[fabric_id] = { 'name': fabric_name, 'topology_type': topology_type, Loading @@ -58,6 +59,7 @@ class SpineLeafStateStore: 'role': role, 'asn': resources.get('asn'), 'loopback_ip': resources.get('loopback_ip'), 'underlay_ip': resources.get('underlay_ip'), # Primary underlay IP for BGP sessions 'underlay_ips': resources.get('underlay_ips', []), 'vlans': resources.get('vlans', []), 'vnis': resources.get('vnis', []), Loading @@ -82,6 +84,16 @@ class SpineLeafStateStore: self.reserved_resources[fabric_id][device_id] = tracked return True, f'Device {device_id} set as {role} in fabric {fabric_id}' def build_driver_config_rules(self, fabric_id: str, device_id: str) -> List[Tuple[str, str]]: from spine_leaf.scripts.fabric_config_builder import FabricConfigBuilder rules = FabricConfigBuilder(self).build(fabric_id, device_id) self.driver_config_rules[(fabric_id, device_id)] = rules return rules def get_driver_config_rules(self, fabric_id: str, device_id: str) -> List[Tuple[str, str]]: return self.driver_config_rules.get((fabric_id, device_id), []) def get_device_role(self, fabric_id: str, device_id: str) -> Optional[Dict]: return self.device_roles.get((fabric_id, device_id)) Loading @@ -95,6 +107,8 @@ class SpineLeafStateStore: del self.reserved_resources[fabric_id][device_id] if key in self.config_store: del self.config_store[key] if key in self.driver_config_rules: del self.driver_config_rules[key] return True, f'Device {device_id} role unset in fabric {fabric_id}' Loading Loading
src/spine_leaf/Dockerfile +7 −0 Original line number Diff line number Diff line Loading @@ -20,6 +20,11 @@ RUN apt-get --yes --quiet --quiet update && \ ENV PYTHONUNBUFFERED=0 RUN GRPC_HEALTH_PROBE_VERSION=v0.2.0 && \ wget -qO/bin/grpc_health_probe https://github.com/grpc-ecosystem/grpc-health-probe/releases/download/${GRPC_HEALTH_PROBE_VERSION}/grpc_health_probe-linux-amd64 && \ chmod +x /bin/grpc_health_probe RUN python3 -m pip install --upgrade 'pip==25.2' RUN python3 -m pip install --upgrade 'setuptools==79.0.0' 'wheel==0.45.1' RUN python3 -m pip install --upgrade 'pip-tools==7.3.0' Loading Loading @@ -53,5 +58,7 @@ COPY src/logical_resources/client/. logical_resources/client/ COPY src/device/__init__.py device/__init__.py COPY src/device/client/. device/client/ COPY src/spine_leaf/. spine_leaf/ COPY src/service/__init__.py service/__init__.py COPY src/service/service/. service/service/ ENTRYPOINT ["python", "-m", "spine_leaf.service"]
src/spine_leaf/scripts/fabric_config_builder.py +46 −17 Original line number Diff line number Diff line Loading @@ -53,8 +53,11 @@ class FabricConfigBuilder: )) # ---- VXLAN (leaf only) ---- vni = None if role in ("leaf", "gateway"): vni = device["vnis"][0] vnis = device.get("vnis", []) if vnis and len(vnis) > 0: vni = vnis[0] rules.append(( "/interfaces/vxlan", json.dumps({ Loading @@ -66,26 +69,52 @@ class FabricConfigBuilder: )) # ---- BGP sessions ---- # Prefer per-link underlay IP lists when available. If both local and remote expose # underlay_ips lists, pick the first element of each as the session addresses. Fall # back to single 'underlay_ip' or loopback as necessary. if role == "spine": for leaf in inventory["leaves"]: 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 = local_underlays[0] remote_ip = remote_underlays[0] else: local_ip = device.get('underlay_ip') or (local_underlays[0] if local_underlays else None) or lo remote_ip = leaf.get('underlay_ip') or (remote_underlays[0] if remote_underlays else None) or leaf.get('loopback_ip') rules.append(self._bgp_session( name=leaf["device_id"], local_ip=lo, remote_ip=leaf["loopback_ip"], local_ip=local_ip, remote_ip=remote_ip, remote_as=leaf["asn"] )) elif role in ("leaf", "gateway"): for spine in inventory["spines"]: spines = inventory.get("spines", []) for spine in spines: local_underlays = device.get('underlay_ips', []) remote_underlays = spine.get('underlay_ips', []) if local_underlays and remote_underlays: local_ip = local_underlays[0] remote_ip = remote_underlays[0] else: local_ip = device.get('underlay_ip') or (local_underlays[0] if local_underlays else None) or lo remote_ip = spine.get('underlay_ip') or (remote_underlays[0] if remote_underlays else None) or spine.get('loopback_ip') rules.append(self._bgp_session( name=spine["device_id"], local_ip=lo, remote_ip=spine["loopback_ip"], local_ip=local_ip, remote_ip=remote_ip, remote_as=spine["asn"] )) # ---- EVPN (leaf only) ---- if role in ("leaf", "gateway"): if role in ("leaf", "gateway") and vni is not None: rules.append(( "/network_instances/bgp_evpn", json.dumps({ Loading
src/spine_leaf/scripts/resource_allocator.py +40 −7 Original line number Diff line number Diff line Loading @@ -73,10 +73,36 @@ class ResourceAllocator: reply = self.logical_resource_client.GetResourcesByType(ResourceTypeQuery(resource_type=resource_type)) return self._pick_first_available(reply) def allocate_for_role(self, device_uuid: str, fabric_id: str, role: str): resource_types = ['asn', 'loopback', 'ip'] if role in ('leaf', 'gateway'): resource_types.extend(['vlan', 'vni']) @staticmethod def _normalize_loopback(loopback_ip: str) -> str: return loopback_ip.split('/')[0] if loopback_ip else '' def _resource_profile(self, role: str, fabric: Optional[Dict]) -> Dict[str, object]: overlay_type = str((fabric or {}).get('overlay_type', '')).lower() underlay_type = str((fabric or {}).get('underlay_type', '')).lower() use_overlay = overlay_type in ('vxlan', 'bgp_evpn') profile = { 'resource_types': ['asn', 'loopback', 'ip'], 'routing_table': 'vrf-underlay' if role == 'spine' else 'vrf-dataplane', 'local_role': 'ebgp', 'multihop': True, 'afi': 'ip', 'include_overlay': False, } if role in ('leaf', 'gateway') and use_overlay: profile['resource_types'].extend(['vlan', 'vni']) profile['include_overlay'] = True profile['afi'] = 'ip,l2vpn' # Remove legacy IS-IS/OSPF automatic local_role mapping: use BGP underlay by default return profile def allocate_for_role(self, device_uuid: str, fabric_id: str, role: str, fabric: Optional[Dict] = None): profile = self._resource_profile(role, fabric) resource_types = profile['resource_types'] selected: Dict[str, str] = {} reserved: List[TypingTuple[str, str]] = [] Loading @@ -92,20 +118,27 @@ class ResourceAllocator: selected[resource_type] = value reserved.append((resource_type, value)) loopback_ip = selected.get('loopback', '') router_id = self._normalize_loopback(loopback_ip) config = ConfigSetting( device_uuid=device_uuid, endpoint_uuid=device_uuid, local_role=role, asn=int(selected.get('asn', '0') or 0), ip_address=selected.get('loopback', ''), ip_address=loopback_ip, router_id=router_id, remote_address=selected.get('ip', ''), vlan_tag=int(selected.get('vlan', '0') or 0), vni=int(selected.get('vni', '0') or 0), routing_table=str(profile['routing_table']), multihop=bool(profile['multihop']), afi=str(profile['afi']), vlan_tag=int(selected.get('vlan', '0') or 0) if profile['include_overlay'] else 0, vni=int(selected.get('vni', '0') or 0) if profile['include_overlay'] else 0, ) db_resources = { 'asn': str(config.asn), 'loopback_ip': config.ip_address, 'underlay_ip': config.remote_address, # Primary underlay IP for BGP sessions 'underlay_ips': [config.remote_address] if config.remote_address else [], 'vlans': [str(config.vlan_tag)] if config.vlan_tag else [], 'vnis': [str(config.vni)] if config.vni else [], Loading
src/spine_leaf/service/SpineLeafServicerImpl.py +12 −14 Original line number Diff line number Diff line Loading @@ -49,15 +49,12 @@ class SpineLeafServicerImpl(SpineLeafServicer): def _allocate_for_role(self, request: SetDeviceRoleRequest, role: str, context: grpc.ServicerContext): fabric = self.db.get_fabric(request.fabric_id) if not fabric: context.set_code(grpc.StatusCode.NOT_FOUND) context.set_details(f'Fabric {request.fabric_id} not found') return None, None fabric = self.db.get_fabric(request.fabric_id) or {} config, resources = self.resource_allocator.allocate_for_role( request.device_uuid, request.fabric_id, role role, fabric, ) if not config or not resources: context.set_code(grpc.StatusCode.FAILED_PRECONDITION) Loading @@ -75,8 +72,9 @@ class SpineLeafServicerImpl(SpineLeafServicer): 'spine_leaf', request.underlay_type, request.overlay_type, '', '', str(request.spine_count), str(request.leaf_count), str(request.gateway_count), ) return FabricDefinitionResponse(fabric_id=fabric_id) Loading @@ -98,6 +96,8 @@ class SpineLeafServicerImpl(SpineLeafServicer): raise RuntimeError(msg) self.db.store_config(request.fabric_id, request.device_uuid, config) config_rules = self.db.build_driver_config_rules(request.fabric_id, request.device_uuid) LOGGER.info('Generated spine config rules for fabric=%s device=%s: %s', request.fabric_id, request.device_uuid, config_rules) return config except Exception as e: Loading Loading @@ -128,6 +128,8 @@ class SpineLeafServicerImpl(SpineLeafServicer): raise RuntimeError(msg) self.db.store_config(request.fabric_id, request.device_uuid, config) config_rules = self.db.build_driver_config_rules(request.fabric_id, request.device_uuid) LOGGER.info('Generated leaf config rules for fabric=%s device=%s: %s', request.fabric_id, request.device_uuid, config_rules) return config except Exception as e: Loading Loading @@ -158,6 +160,8 @@ class SpineLeafServicerImpl(SpineLeafServicer): raise RuntimeError(msg) self.db.store_config(request.fabric_id, request.device_uuid, config) config_rules = self.db.build_driver_config_rules(request.fabric_id, request.device_uuid) LOGGER.info('Generated gateway config rules for fabric=%s device=%s: %s', request.fabric_id, request.device_uuid, config_rules) return config except Exception as e: for rtype, value in self.db.get_reserved_resources_for_device( Loading Loading @@ -195,12 +199,6 @@ class SpineLeafServicerImpl(SpineLeafServicer): @safe_and_metered_rpc_method(METRICS_POOL, LOGGER) def GetFabricInventory(self, request: FabricScope, context: grpc.ServicerContext) -> FabricInventory: fabric = self.db.get_fabric(request.fabric_id) if not fabric: context.set_code(grpc.StatusCode.NOT_FOUND) context.set_details(f'Fabric {request.fabric_id} not found') return FabricInventory(fabric_id=request.fabric_id) inventory = self.db.get_fabric_inventory(request.fabric_id) response = FabricInventory(fabric_id=request.fabric_id) Loading
src/spine_leaf/state_store.py +16 −2 Original line number Diff line number Diff line Loading @@ -12,9 +12,9 @@ # See the License for the specific language governing permissions and # limitations under the License. import uuid from typing import Dict, List, Optional, Tuple from service.service.tools.object_uuid import get_uuid_from_string class SpineLeafStateStore: #In-memory state for fabric/device roles and generated configs Loading @@ -23,6 +23,7 @@ class SpineLeafStateStore: self.fabrics: Dict[str, Dict] = {} self.device_roles: Dict[Tuple[str, str], Dict] = {} self.config_store: Dict[Tuple[str, str], Dict] = {} self.driver_config_rules: Dict[Tuple[str, str], List[Tuple[str, str]]] = {} self.reserved_resources: Dict[str, Dict[str, List[Tuple[str, str]]]] = {} def create_fabric( Loading @@ -34,7 +35,7 @@ class SpineLeafStateStore: asn_range: str, ip_range: str, ) -> Tuple[str, bool, str]: fabric_id = f"{fabric_name}-{uuid.uuid4().hex[:8]}" fabric_id = get_uuid_from_string(fabric_name) self.fabrics[fabric_id] = { 'name': fabric_name, 'topology_type': topology_type, Loading @@ -58,6 +59,7 @@ class SpineLeafStateStore: 'role': role, 'asn': resources.get('asn'), 'loopback_ip': resources.get('loopback_ip'), 'underlay_ip': resources.get('underlay_ip'), # Primary underlay IP for BGP sessions 'underlay_ips': resources.get('underlay_ips', []), 'vlans': resources.get('vlans', []), 'vnis': resources.get('vnis', []), Loading @@ -82,6 +84,16 @@ class SpineLeafStateStore: self.reserved_resources[fabric_id][device_id] = tracked return True, f'Device {device_id} set as {role} in fabric {fabric_id}' def build_driver_config_rules(self, fabric_id: str, device_id: str) -> List[Tuple[str, str]]: from spine_leaf.scripts.fabric_config_builder import FabricConfigBuilder rules = FabricConfigBuilder(self).build(fabric_id, device_id) self.driver_config_rules[(fabric_id, device_id)] = rules return rules def get_driver_config_rules(self, fabric_id: str, device_id: str) -> List[Tuple[str, str]]: return self.driver_config_rules.get((fabric_id, device_id), []) def get_device_role(self, fabric_id: str, device_id: str) -> Optional[Dict]: return self.device_roles.get((fabric_id, device_id)) Loading @@ -95,6 +107,8 @@ class SpineLeafStateStore: del self.reserved_resources[fabric_id][device_id] if key in self.config_store: del self.config_store[key] if key in self.driver_config_rules: del self.driver_config_rules[key] return True, f'Device {device_id} role unset in fabric {fabric_id}' Loading