From ab55df8a6c7934f2d329c3a6e3350a0fdd8d0c46 Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Wed, 9 Sep 2026 18:34:00 +0200 Subject: [PATCH 1/3] adds option to model check_redfish components as NetBox modules NetBox deprecated inventory items in 4.3 and points at modules as the replacement (bb-Ricardo/netbox-sync issue 473). Modules carry a type from a catalog, live in a named bay, and own their interfaces and power ports, so the hardware a server reports can be modelled as what it is rather than as free text. New option model_components_as_modules, default False, so nothing changes unless it is set. When it is set and NetBox is 4.3 or newer, every component check_redfish reports (CPU, DIMM, drive, controller, enclosure, NIC, PSU, fan, BMC) becomes a module in a module bay, typed by a module type. On an older NetBox the option is ignored and inventory items are used, so one configuration works across versions. update_all_items() dispatches to update_all_modules() and the inventory item path is untouched, which keeps the two backends side by side rather than replacing one. Object model: NBModuleType, NBModuleBay and NBModule, all gated on NetBox 4.3. NBModule has no name of its own, so it is keyed on its bay and derives its display name from it. NBInterface and NBPowerPort gain a module foreign key, and NBModule becomes a valid custom field target. The bay is the physical slot and is keyed on a stable identifier, never on a name carrying the installed part: the CPU socket, the DIMM slot label, the drive slot, the NIC adapter id, the PSU slot. A part swap then reuses the bay and re-points the module type instead of creating a second bay. A slot longer than the 64 characters NetBox stores is shortened to a prefix plus a digest of the full name, so two long slots sharing a prefix do not collapse onto one bay. Bay matching is strict. update_module() never moves a module between bays, so an unmatched current module is a removed component, marked absent and kept registered with this source, not a target to remap another component onto. NetBox requires a manufacturer on a module type. It is taken from redfish, then from an existing module type for the same model so a curated value is not clobbered, then from the device vendor. NIC ports and the BMC interface are attached to their parent module, and PSU power ports to their supply module. NetBox cascade-deletes module components, so those links are cleared again when the option is turned off or no parent module resolves, which stops a module prune removing a port this source still manages. With modules on, a NIC port is named by its stable redfish id and the long descriptive label moves to the description. settings-example.ini is regenerated with netbox-sync.py -g rather than hand edited. The tests drive the real CheckRedfish methods against the real inventory: the version and option matrix, the full module graph, idempotency, module type reuse and re-pointing, bay stability across part swaps, prefix-colliding long names, strict bay matching, absent components, interface and power port links in both directions, and the inventory item path still being used when the option is off. --- module/netbox/__init__.py | 5 +- module/netbox/object_classes.py | 97 +- module/sources/check_redfish/config.py | 7 + .../sources/check_redfish/import_inventory.py | 423 +++++++- settings-example.ini | 5 + tests/test_check_redfish_modules.py | 941 ++++++++++++++++++ 6 files changed, 1462 insertions(+), 16 deletions(-) create mode 100644 tests/test_check_redfish_modules.py diff --git a/module/netbox/__init__.py b/module/netbox/__init__.py index 8f5696c3..73432a65 100644 --- a/module/netbox/__init__.py +++ b/module/netbox/__init__.py @@ -40,7 +40,10 @@ NBMACAddress, NBFHRPGroupItem, NBInventoryItem, - NBPowerPort + NBPowerPort, + NBModuleType, + NBModuleBay, + NBModule ) primary_tag_name = "NetBox-synced" diff --git a/module/netbox/object_classes.py b/module/netbox/object_classes.py index 377c12b5..e319d438 100644 --- a/module/netbox/object_classes.py +++ b/module/netbox/object_classes.py @@ -1323,7 +1323,8 @@ def __init__(self, *args, **kwargs): NBPowerPort.object_type, NBClusterGroup.object_type, NBVMInterface.object_type, - NBVM.object_type + NBVM.object_type, + NBModule.object_type ] self.data_model = { @@ -2073,7 +2074,9 @@ def __init__(self, *args, **kwargs): "description": 200, "mark_connected": bool, "tags": NBTagList, - "parent": object + "parent": object, + # NetBox cascade-deletes module components, so the module owns its interfaces + "module": NBModule } super().__init__(*args, **kwargs) @@ -2409,7 +2412,9 @@ def __init__(self, *args, **kwargs): "allocated_draw": int, "mark_connected": bool, "tags": NBTagList, - "custom_fields": NBCustomField + "custom_fields": NBCustomField, + # the PSU module owns its power port, NetBox cascade-deletes it with the module + "module": NBModule } super().__init__(*args, **kwargs) @@ -2428,4 +2433,90 @@ def update(self, data=None, read_from_netbox=False, source=None): super().update(data=data, read_from_netbox=read_from_netbox, source=source) + +class NBModuleType(NetBoxObject): + name = "module type" + api_path = "dcim/module-types" + object_type = "dcim.moduletype" + # matched by model only, like NBDeviceType (server part models are effectively unique) + primary_key = "model" + prune = False + # modules replace the deprecated inventory items starting with NetBox 4.3 + min_netbox_version = "4.3" + + def __init__(self, *args, **kwargs): + self.data_model = { + "model": 100, + "manufacturer": NBManufacturer, + "part_number": 50, + "description": 200, + "comments": str, + "tags": NBTagList, + "custom_fields": NBCustomField + } + super().__init__(*args, **kwargs) + + +class NBModuleBay(NetBoxObject): + name = "module bay" + api_path = "dcim/module-bays" + object_type = "dcim.modulebay" + primary_key = "name" + secondary_key = "device" + prune = True + min_netbox_version = "4.3" + + def __init__(self, *args, **kwargs): + self.data_model = { + "device": NBDevice, + "name": 64, + "label": 64, + "position": 30, + "description": 200, + "tags": NBTagList, + "custom_fields": NBCustomField + } + super().__init__(*args, **kwargs) + + +class NBModule(NetBoxObject): + name = "module" + api_path = "dcim/modules" + object_type = "dcim.module" + # a module has no name of its own, it is identified by the bay it is installed in + primary_key = "module_bay" + secondary_key = "device" + prune = True + min_netbox_version = "4.3" + + def __init__(self, *args, **kwargs): + self.data_model = { + "device": NBDevice, + "module_bay": NBModuleBay, + "module_type": NBModuleType, + "status": ["offline", "active", "planned", "staged", "failed", "inventory", "decommissioning"], + "serial": 50, + "asset_tag": 50, + "description": 200, + "tags": NBTagList, + "custom_fields": NBCustomField + } + super().__init__(*args, **kwargs) + + def get_display_name(self, data=None, including_second_key=False): + + # a module has no name on its own, derive its display name from the module bay it lives in + this_data_set = data if data is not None else self.data + + if this_data_set is not None: + module_bay = this_data_set.get("module_bay") + if isinstance(module_bay, NetBoxObject): + return module_bay.get_display_name(including_second_key=including_second_key) + if isinstance(module_bay, dict): + bay_name = module_bay.get("name") or module_bay.get("display") + if bay_name is not None: + return bay_name + + return super().get_display_name(data=data, including_second_key=including_second_key) + # EOF diff --git a/module/sources/check_redfish/config.py b/module/sources/check_redfish/config.py index 07dd0a1c..d5c8d93b 100644 --- a/module/sources/check_redfish/config.py +++ b/module/sources/check_redfish/config.py @@ -46,6 +46,13 @@ def __init__(self): overwrites the device host name in NetBox""", default_value=False), + ConfigOption("model_components_as_modules", + bool, + description="""model discovered hardware components (CPUs, memory, drives, + controllers, NICs, ...) as NetBox modules instead of the deprecated inventory + items. Requires NetBox >= 4.3, on older versions inventory items are used""", + default_value=False), + ConfigOption("overwrite_power_supply_name", bool, description="""define if the name of the power supply discovered via check_redfish diff --git a/module/sources/check_redfish/import_inventory.py b/module/sources/check_redfish/import_inventory.py index e4a05426..e83f6940 100644 --- a/module/sources/check_redfish/import_inventory.py +++ b/module/sources/check_redfish/import_inventory.py @@ -9,6 +9,7 @@ import os import glob +import hashlib import json from packaging import version @@ -21,6 +22,11 @@ from module.netbox.inventory import NetBoxInventory from module.netbox import * +# NetBox stores dcim.modulebay.name at 64 chars. A longer name is shortened to a prefix plus a +# short digest of the full name, so two long slots sharing a prefix stay distinct bays. +MODULE_BAY_NAME_MAX_LENGTH = 64 +MODULE_BAY_NAME_HASH_LENGTH = 8 + log = get_logger() @@ -86,6 +92,10 @@ def __init__(self, name=None): self.interface_adapter_type_dict = dict() + # maps a network adapter id to the module bay name of its NIC module, so discovered + # ports can be attached to their parent module + self.nic_module_bay_by_adapter_id = dict() + def apply(self): """ Main source handler method. This method is called for each source from "main" program @@ -167,6 +177,7 @@ def reset_inventory_state(self): # reset interface types self.interface_adapter_type_dict = dict() + self.nic_module_bay_by_adapter_id = dict() def read_inventory_file_content(self, filename: str) -> bool: """ @@ -272,6 +283,9 @@ def update_power_supply(self): ps_index = 1 ps_items = list() + # each power port with the bay name of its supply, linked after update_all_items creates + # the modules further down + power_port_links = list() for ps in grab(self.inventory_file_content, "inventory.power_supply", fallback=list()): if grab(ps, "operation_status") in ["NotPresent", "Absent"]: @@ -313,7 +327,10 @@ def update_power_supply(self): ps_items.append({ "health": health_status, "description": description, + # the slot, not the AC/DC bearing display name, so a swap reuses the bay + "bay_name": ps_name, "full_name": name, + "model": model, "serial": get_string_or_none(grab(ps, "serial")), "manufacturer": get_string_or_none(grab(ps, "vendor")), "part_number": get_string_or_none(grab(ps, "part_number")), @@ -346,7 +363,7 @@ def update_power_supply(self): break if ps_object is None: - self.inventory.add_object(NBPowerPort, data=ps_data, source=self) + ps_object = self.inventory.add_object(NBPowerPort, data=ps_data, source=self) else: if self.settings.overwrite_power_supply_name is False: del(ps_data["name"]) @@ -355,10 +372,21 @@ def update_power_supply(self): ps_object.update(data=data_to_update, source=self) current_ps.remove(ps_object) + power_port_links.append((ps_object, ps_name)) + ps_index += 1 self.update_all_items(ps_items, "Power Supply") + # NetBox cascade-deletes a module's components, so the port must follow its PSU module; + # detach a stale link when modules are off or no module resolves + for power_port, bay_name in power_port_links: + psu_module = self.find_device_module_by_bay_name(bay_name) if self.use_modules() is True else None + if psu_module is not None: + power_port.update(data={"module": psu_module}, source=self) + else: + power_port.unset_attribute("module") + def update_fan(self): items = list() @@ -415,6 +443,9 @@ def update_memory(self): memory_size_total += size_in_mb + # the slot label is the stable bay identity, captured before the DIMM type is appended + dimm_bay = name + name_details = list() if dimm_type is not None: name_details.append(f"{dimm_type}") @@ -435,6 +466,7 @@ def update_memory(self): items.append({ "description": description, + "bay_name": dimm_bay or "None", "full_name": name or "None", "serial": get_string_or_none(grab(memory, "serial")), "manufacturer": get_string_or_none(grab(memory, "manufacturer")), @@ -495,7 +527,10 @@ def update_proc(self): items.append({ "description": description, "manufacturer": get_string_or_none(grab(processor, "manufacturer")), + # the socket is the stable bay identity, independent of the installed model + "bay_name": socket, "full_name": name, + "model": model, "serial": get_string_or_none(grab(processor, "serial")), "health": health_status, "size": size, @@ -544,6 +579,9 @@ def update_physical_drive(self): name = pd_name + # the drive slot is the stable bay identity, captured before type/model is appended + drive_bay = pd_name + name_details = list() if pd_type is not None: name_details.append(pd_type) @@ -566,6 +604,7 @@ def update_physical_drive(self): items.append({ "description": description, "manufacturer": get_string_or_none(grab(pd, "manufacturer")), + "bay_name": drive_bay or "None", "full_name": name or "None", "serial": serial, "part_number": get_string_or_none(grab(pd, "part_number")), @@ -699,12 +738,18 @@ def update_network_adapter(self): nic_type = NetBoxInterfaceType(name) + # the adapter id is the stable slot identity; adapter_name embeds a mutable label + stable_bay_name = adapter_id or adapter_name or "None" + if adapter_id is not None: self.interface_adapter_type_dict[adapter_id] = nic_type + self.nic_module_bay_by_adapter_id[adapter_id] = stable_bay_name items.append({ "manufacturer": manufacturer, + "bay_name": stable_bay_name, "full_name": name, + "model": model, "serial": serial, "part_number": get_string_or_none(grab(adapter, "part_number")), "firmware": firmware, @@ -715,6 +760,38 @@ def update_network_adapter(self): self.update_all_items(items, "NIC") + def find_device_module_by_bay_name(self, bay_name: str) -> NBModule: + """Return the module installed in the named bay on the current device, or None.""" + + if bay_name is None: + return None + + for module in self.inventory.get_all_items(NBModule): + if grab(module, "data.device") == self.device_object and \ + grab(module, "data.module_bay.data.name") == bay_name: + return module + + return None + + def interface_parent_module(self, adapter_id, mgmt_only: bool) -> NBModule: + """ + Determine the module a discovered interface belongs to: a management interface belongs to + the BMC/manager module, a regular NIC port to its network adapter's module. Returns None + when components are not modeled as modules or no matching module exists. + """ + + if self.use_modules() is not True: + return None + + if mgmt_only is True and self.manager_name is not None: + bay_name = self.manager_name + elif adapter_id is not None: + bay_name = self.nic_module_bay_by_adapter_id.get(adapter_id) + else: + bay_name = None + + return self.find_device_module_by_bay_name(bay_name) + def update_network_interface(self): port_data_dict = dict() @@ -759,7 +836,18 @@ def update_network_interface(self): if wwn is not None: discovered_int_list.append(wwn) - if port_name is not None: + # a port belonging to a manager is a BMC port + mgmt_only = len(manager_ids) > 0 + + friendly_name = port_name + name_from_stable_id = False + + if self.use_modules() and mgmt_only is False and port_id is not None: + # the redfish id (e.g. NIC.Integrated.1-1) is stable; the long label moves to + # the description + port_name = port_id + name_from_stable_id = True + elif port_name is not None: port_name += f" ({port_id})" else: port_name = port_id @@ -770,14 +858,11 @@ def update_network_interface(self): link_type = NetBoxInterfaceType(link_speed) description = list() + if name_from_stable_id is True and friendly_name is not None and friendly_name != port_name: + description.append(friendly_name) if hostname is not None: description.append(f"Hostname: {hostname}") - mgmt_only = False - # if number of managers belonging to this port is not 0 then it's a BMC port - if len(manager_ids) > 0: - mgmt_only = True - # get enabled state enabled = False @@ -800,6 +885,10 @@ def update_network_interface(self): "health": health_status } + parent_module = self.interface_parent_module(adapter_id, mgmt_only) + if parent_module is not None: + port_data_dict[port_name]["module"] = parent_module + if len(description) > 0: port_data_dict[port_name]["description"] = ", ".join(description) if mgmt_only is True: @@ -833,6 +922,11 @@ def update_network_interface(self): # get current object for this interface if it exists nic_object = data.get(port_name) + # clear a stale link when no parent module resolves, so a module prune cannot + # cascade-delete a port this source still manages + if nic_object is not None and "module" not in port_data: + nic_object.unset_attribute("module") + # unset "illegal" attributes for attribute in ["inventory_type", "health"]: if attribute in port_data: @@ -901,6 +995,24 @@ def update_manager(self): self.update_all_items(items, "Manager") + def use_modules(self) -> bool: + """ + Decide if discovered hardware components should be modeled as NetBox modules + instead of the deprecated inventory items. + + Modules are only used if explicitly enabled via config AND the connected NetBox + instance is recent enough to support the modules data model (>= 4.3). + + Returns + ------- + bool: True if components should be modeled as modules + """ + + if grab(self.settings, "model_components_as_modules", fallback=False) is not True: + return False + + return version.parse(self.inventory.netbox_api_version) >= version.parse("4.3") + def update_all_items(self, items, inventory_type): """ Updates all inventory items of a certain type. Both (current and supplied list of items) will @@ -926,6 +1038,10 @@ def update_all_items(self, items, inventory_type): for item in items: item["inventory_type"] = inventory_type + # model components as NetBox modules instead of the deprecated inventory items + if self.use_modules() is True: + return self.update_all_modules(items, inventory_type) + # get current inventory items for this device and type current_inventory_items = dict() for item in self.inventory.get_all_items(NBInventoryItem): @@ -1037,6 +1153,285 @@ def update_item(self, item_data: dict, inventory_object: NBInventoryItem = None) return + def get_current_modules_by_bay_name(self, inventory_type: str) -> dict: + """ + Collect all currently known modules of a certain component type for the current device, + keyed by the name of the module bay they are installed in. + + Parameters + ---------- + inventory_type: str + the component type to filter for (CPU, DIMM, Fan, ...) + + Returns + ------- + dict: module bay name -> NBModule, sorted by module bay name + """ + + current_modules = dict() + for module in self.inventory.get_all_items(NBModule): + if grab(module, "data.device") != self.device_object: + continue + if grab(module, "data.custom_fields.inventory_type") != inventory_type: + continue + + bay_name = grab(module, "data.module_bay.data.name") + if bay_name is not None: + current_modules[bay_name] = module + + return dict(sorted(current_modules.items())) + + def update_all_modules(self, items, inventory_type): + """ + Module based counterpart of 'update_all_items'. Updates all modules of a certain type. + Each component is represented by a module bay (the slot) holding a single module which is + typed by a module type (the catalog entry, e.g. the exact CPU/DIMM/NIC model). + + Both (current and supplied list of items) will be sorted by the module bay name and + matched 1:1, exactly like 'update_all_items' does for inventory items. + + Parameters + ---------- + items: list + a list of items to update + inventory_type: str + the component type this batch describes (CPU, DIMM, Fan, ...) + + Returns + ------- + None + """ + + # get current modules for this device and type, keyed by their module bay name + current_modules = self.get_current_modules_by_bay_name(inventory_type) + + # NB module object -> parsed data matching its module bay name + matched_modules = dict() + unmatched_module_items = list() + + # try to match items to existing modules by their stable module bay identity + for item in items: + + current_module = current_modules.get(self.module_bay_name(item)) + if current_module is not None: + matched_modules[current_module] = item + else: + unmatched_module_items.append(item) + + # sort unmatched items by module bay name for deterministic new-module creation order + unmatched_module_items.sort(key=lambda x: self.module_bay_name(x) or "") + + # strict by bay: update_module never moves a module, so an unmatched current module is a + # removed component, not a target to remap another component onto + for nb_module in current_modules.values(): + + if nb_module in matched_modules: + continue + + # unconditional: an object a run does not touch is tagged orphaned + nb_module.update(data={"custom_fields": {"health": "Absent"}}, source=self) + self.mark_module_bay_seen(nb_module) + + # update modules with matching NetBox module + for module_object, module_data in matched_modules.items(): + self.update_module(module_data, module_object) + + # create new module in NetBox + for unmatched_module_item in unmatched_module_items: + self.update_module(unmatched_module_item) + + def module_bay_name(self, item_data: dict) -> str: + """ + Return the stable module bay identity (the physical slot) for a component. + + The bay represents the slot, so it must be keyed on a stable identifier (CPU socket, + NIC slot, ...) that does not change when the installed part's model changes - otherwise + a model swap would rename the bay and churn it. Parsers provide it via 'bay_name'; we + fall back to the display name for components whose name is already slot based and does + not embed a model. + + The name is shortened to the module bay's max length (NetBox limits dcim.modulebay.name to + 64 chars). NetBox stores the shortened name, so the key used to match an existing bay must + be shortened the same way - otherwise a name longer than the limit never matches its stored + counterpart and the bay + module churn on every sync (the module path matches strictly, with + no alphabetical fallback like the inventory-item path has). Shortening keeps a prefix and + appends a deterministic hash of the full name so two distinct slots that happen to share the + first 64 chars (e.g. long drive/enclosure location strings) do not collapse onto one bay. + """ + + name = item_data.get("bay_name") or item_data.get("full_name") + if name is not None and len(name) > MODULE_BAY_NAME_MAX_LENGTH: + digest = hashlib.blake2s(name.encode("utf-8"), + digest_size=MODULE_BAY_NAME_HASH_LENGTH // 2).hexdigest() + prefix_length = MODULE_BAY_NAME_MAX_LENGTH - MODULE_BAY_NAME_HASH_LENGTH - 1 + name = f"{name[:prefix_length]}-{digest}" + return name + + def device_manufacturer_name(self) -> str: + """ + NetBox requires a manufacturer on every module type. Components like fans, PCIe extenders + or storage enclosures don't report one, so fall back to the device's own manufacturer + (the server vendor), or a generic placeholder when even that is unavailable. + """ + + device_manufacturer = grab(self.device_object, "data.device_type.data.manufacturer") + if isinstance(device_manufacturer, NetBoxObject): + return device_manufacturer.get_display_name() + + return "Unknown" + + def resolve_module_type(self, item_data: dict) -> NBModuleType: + """ + Find or create the module type (catalog entry) describing the installed part, e.g. the + exact CPU/DIMM/NIC model. Shared by create and update so a replaced part re-points to the + correct module type instead of keeping a stale reference. + """ + + part_number = item_data.get("part_number") + + # the module type model is the catalog identifier of the part (e.g. the exact CPU model) + model = item_data.get("model") or part_number or item_data.get("full_name") + module_type_data = {"model": model} + if part_number is not None: + module_type_data["part_number"] = part_number + + # NetBox requires a manufacturer: redfish, then the existing type's own value, then + # the device vendor + manufacturer = item_data.get("manufacturer") + if manufacturer is None: + existing_module_type = self.inventory.get_by_data(NBModuleType, data={"model": model}) + if existing_module_type is None or grab(existing_module_type, "data.manufacturer") is None: + manufacturer = self.device_manufacturer_name() + + if manufacturer is not None: + module_type_data["manufacturer"] = {"name": manufacturer} + + return self.inventory.add_update_object(NBModuleType, data=module_type_data, source=self) + + def update_module(self, item_data: dict, module_object: NBModule = None): + """ + Updates a single module with the supplied data. If no module is provided a new module bay, + module type and module will be created (see 'create_module'). + + Parameters + ---------- + item_data: dict + a dict with data for the component to update + module_object: NBModule, None + the NetBox module to update. + + Returns + ------- + None + """ + + description = item_data.get("description") + if isinstance(description, list): + description = ", ".join(description) + + # custom fields tracked on the module itself + module_custom_fields = { + "firmware": item_data.get("firmware"), + "health": item_data.get("health"), + "inventory_type": item_data.get("inventory_type"), + "inventory_size": item_data.get("size"), + "inventory_speed": item_data.get("speed") + } + + # create a new module (incl. its module bay and module type) + if module_object is None: + self.create_module(item_data, description, module_custom_fields) + return + + # the bay is the slot the module sits in and is still present, so mark it seen too + self.upsert_module_bay(item_data, description) + + # update an existing module; re-point the module type in case the installed part was + # replaced with a different model in the same bay + module_data = { + "custom_fields": module_custom_fields, + "module_type": self.resolve_module_type(item_data) + } + if item_data.get("serial") is not None: + module_data["serial"] = item_data.get("serial") + if description is not None and len(description) > 0: + module_data["description"] = description + + module_object.update(data=module_data, source=self) + + def upsert_module_bay(self, item_data: dict, description: str) -> NBModuleBay: + """ + Add or update the module bay (the physical slot) of a component and mark it as seen by + this source. + + Both the create and the update path go through here. tag_all_the_things() adds the + orphaned tag to every object carrying the primary tag whose source is unset after a run, + so a bay that a run never touches is tagged orphaned even while the module installed in + it stays healthy. + """ + + module_bay_data = { + "device": self.device_object, + "name": self.module_bay_name(item_data) + } + if item_data.get("label") is not None: + module_bay_data["label"] = item_data.get("label") + if description is not None and len(description) > 0: + module_bay_data["description"] = description + + return self.inventory.add_update_object(NBModuleBay, data=module_bay_data, source=self) + + def mark_module_bay_seen(self, module_object: NBModule) -> None: + """ + Register the bay a module sits in with this source without changing it. The slot outlives + the component installed in it, so it must not be orphan tagged once that component is gone. + """ + + module_bay = grab(module_object, "data.module_bay") + if module_bay is None: + return + + module_bay.update(data={"name": grab(module_bay, "data.name")}, source=self) + + def create_module(self, item_data: dict, description: str, module_custom_fields: dict): + """ + Create a new module for a discovered component. This creates (or reuses) the module type + (catalog entry), the module bay (the physical slot) and the module installed in that bay. + + Parameters + ---------- + item_data: dict + a dict with data for the component to create + description: str + the already compiled description string for this component + module_custom_fields: dict + the custom fields to store on the module + """ + + serial = item_data.get("serial") + has_description = description is not None and len(description) > 0 + + module_type = self.resolve_module_type(item_data) + + # the module bay represents the physical slot the component lives in; it is keyed on a + # stable slot identifier so a later model swap reuses the same bay instead of churning it + module_bay = self.upsert_module_bay(item_data, description) + + # the module is the actual installed component + module_data = { + "device": self.device_object, + "module_bay": module_bay, + "module_type": module_type, + "status": "active", + "custom_fields": module_custom_fields + } + if serial is not None: + module_data["serial"] = serial + if has_description is True: + module_data["description"] = description + + self.inventory.add_object(NBModule, data=module_data, source=self) + def add_necessary_base_objects(self): """ Adds/updates source tag and all custom fields necessary for this source. @@ -1048,6 +1443,10 @@ def add_necessary_base_objects(self): "description": f"Marks objects synced from check_redfish inventory '{self.name}' to this NetBox Instance." }) + # components are stored as modules (NetBox >= 4.3) or as the deprecated inventory items, + # so their custom fields must follow that choice + component_object_type = "dcim.module" if self.use_modules() is True else "dcim.inventoryitem" + self.add_update_custom_field({ "name": "host_cpu_cores", "label": "Physical CPU Cores", @@ -1083,7 +1482,7 @@ def add_necessary_base_objects(self): "name": "firmware", "label": "Firmware", "object_types": [ - "dcim.inventoryitem", + component_object_type, "dcim.powerport" ], "type": "text", @@ -1094,7 +1493,7 @@ def add_necessary_base_objects(self): self.add_update_custom_field({ "name": "inventory_type", "label": "Type", - "object_types": ["dcim.inventoryitem"], + "object_types": [component_object_type], "type": "text", "description": "Describes the type of inventory item" }) @@ -1103,7 +1502,7 @@ def add_necessary_base_objects(self): self.add_update_custom_field({ "name": "inventory_size", "label": "Size", - "object_types": ["dcim.inventoryitem"], + "object_types": [component_object_type], "type": "text", "description": "Describes the size of the inventory item if applicable" }) @@ -1112,7 +1511,7 @@ def add_necessary_base_objects(self): self.add_update_custom_field({ "name": "inventory_speed", "label": "Speed", - "object_types": ["dcim.inventoryitem"], + "object_types": [component_object_type], "type": "text", "description": "Describes the speed of the inventory item if applicable" }) @@ -1122,7 +1521,7 @@ def add_necessary_base_objects(self): "name": "health", "label": "Health", "object_types": [ - "dcim.inventoryitem", + component_object_type, "dcim.powerport", "dcim.device" ], diff --git a/settings-example.ini b/settings-example.ini index 9cb75451..a7c8d0fd 100644 --- a/settings-example.ini +++ b/settings-example.ini @@ -465,6 +465,11 @@ inventory_file_path = /full/path/to/inventory/files ; NetBox ;overwrite_host_name = False +; model discovered hardware components (CPUs, memory, drives, controllers, NICs, ...) as +; NetBox modules instead of the deprecated inventory items. Requires NetBox >= 4.3, on +; older versions inventory items are used +;model_components_as_modules = False + ; define if the name of the power supply discovered via check_redfish overwrites the power ; supply name in NetBox ;overwrite_power_supply_name = False diff --git a/tests/test_check_redfish_modules.py b/tests/test_check_redfish_modules.py new file mode 100644 index 00000000..0893e53e --- /dev/null +++ b/tests/test_check_redfish_modules.py @@ -0,0 +1,941 @@ +"""Modeling check_redfish hardware components as NetBox modules. + +Drives the real CheckRedfish methods against the real NetBoxInventory and NetBoxObject +classes. Only the NetBox REST API itself is out of scope. +""" + +import pytest + +from module.common.misc import grab +from module.netbox.object_classes import ( + NBDevice, + NBDeviceType, + NBInterface, + NBInventoryItem, + NBManufacturer, + NBModule, + NBModuleBay, + NBModuleType, + NBPowerPort, +) + + +@pytest.fixture +def modules_source(check_redfish_source): + """The shared check_redfish fixture, with the modules option and a NetBox version to test.""" + def _make(model_components_as_modules: bool, netbox_api_version: str, **extra: object): + context = check_redfish_source( + model_components_as_modules=model_components_as_modules, **extra) + context.inventory.netbox_api_version = netbox_api_version + return context.source, context.inventory, context.device + return _make + + +def cpu_item(bay_name="Socket 1", + model="Intel Xeon Gold 6248R", + serial="CPU-AAA", + manufacturer="Intel", + health="OK", + full_name=None): + """Build a normalized CPU component item as produced by CheckRedfish.update_proc(). + + bay_name is the stable physical slot (the module bay identity); full_name is the + display name and by default embeds the model, exactly like the real parser does. + """ + return { + "description": ["x86-64", "Cores: 24", "Threads: 48"], + "manufacturer": manufacturer, + "bay_name": bay_name, + "full_name": full_name if full_name is not None else f"{bay_name} ({model})", + "model": model, + "serial": serial, + "health": health, + "size": "24/48", + "speed": "3.0GHz", + } + + +@pytest.mark.parametrize("flag, api_version, expected", [ + (True, "4.3.0", True), + (True, "4.3.1", True), + (True, "5.0.0", True), + (True, "4.2.9", False), # NetBox too old -> fall back to inventory items + (True, "4.0.0", False), + (False, "4.3.0", False), # feature disabled -> inventory items + (False, "5.0.0", False), +]) +def test_use_modules_decision_matrix(modules_source, flag, api_version, expected): + source, _, _ = modules_source(flag, api_version) + assert source.use_modules() is expected + + +def test_creates_full_module_graph_for_cpu(modules_source): + source, inventory, device = modules_source(True, "4.3.0") + + source.update_all_items([cpu_item()], "CPU") + + modules = inventory.get_all_items(NBModule) + bays = inventory.get_all_items(NBModuleBay) + module_types = inventory.get_all_items(NBModuleType) + + # exactly one of each object is created and no deprecated inventory item is touched + assert len(modules) == 1 + assert len(bays) == 1 + assert len(module_types) == 1 + assert len(inventory.get_all_items(NBInventoryItem)) == 0 + + module = modules[0] + bay = bays[0] + module_type = module_types[0] + + # the module is wired to the device, its bay and its module type (same object instances) + assert module.data["device"] is device + assert module.data["module_bay"] is bay + assert module.data["module_type"] is module_type + assert module.data["status"] == "active" + assert module.data["serial"] == "CPU-AAA" + + # descriptive data lives in custom fields on the module + assert grab(module, "data.custom_fields.inventory_type") == "CPU" + assert grab(module, "data.custom_fields.inventory_size") == "24/48" + assert grab(module, "data.custom_fields.inventory_speed") == "3.0GHz" + assert grab(module, "data.custom_fields.health") == "OK" + + # the bay is the stable physical slot (model lives in the module type, not the bay name) + assert bay.data["name"] == "Socket 1" + assert bay.data["device"] is device + + # the module type is the catalog entry carrying the real CPU model + manufacturer + assert module_type.data["model"] == "Intel Xeon Gold 6248R" + assert grab(module_type, "data.manufacturer.data.name") == "Intel" + + # the module derives its display name from the bay (it has no name of its own) + assert module.get_display_name(including_second_key=True) == "Socket 1 (server01)" + + +def test_module_sync_is_idempotent(modules_source): + source, inventory, _ = modules_source(True, "4.3.0") + + source.update_all_items([cpu_item()], "CPU") + source.update_all_items([cpu_item()], "CPU") + + # a second run with identical data must not create duplicates + assert len(inventory.get_all_items(NBModule)) == 1 + assert len(inventory.get_all_items(NBModuleBay)) == 1 + assert len(inventory.get_all_items(NBModuleType)) == 1 + + +def test_same_model_reuses_module_type_across_devices(modules_source): + source, inventory, _ = modules_source(True, "4.3.0") + + # first device gets a CPU + source.update_all_items([cpu_item(serial="CPU-AAA")], "CPU") + + # a second device with the exact same CPU model + device2 = inventory.add_object(NBDevice, data={"name": "server02"}, source=source) + source.device_object = device2 + source.update_all_items([cpu_item(serial="CPU-BBB")], "CPU") + + # the module type (catalog entry) is shared, but each device gets its own bay + module + assert len(inventory.get_all_items(NBModuleType)) == 1 + assert len(inventory.get_all_items(NBModule)) == 2 + assert len(inventory.get_all_items(NBModuleBay)) == 2 + assert len(inventory.get_all_items(NBManufacturer)) == 1 + + +def test_different_model_creates_distinct_module_type(modules_source): + """This is the 'one server type, different CPUs' use case.""" + source, inventory, _ = modules_source(True, "4.3.0") + + source.update_all_items([cpu_item(model="Intel Xeon Gold 6248R", serial="CPU-AAA")], "CPU") + + device2 = inventory.add_object(NBDevice, data={"name": "server02"}, source=source) + source.device_object = device2 + source.update_all_items([cpu_item(model="Intel Xeon Gold 5318Y", serial="CPU-BBB")], "CPU") + + models = sorted(grab(mt, "data.model") for mt in inventory.get_all_items(NBModuleType)) + assert models == ["Intel Xeon Gold 5318Y", "Intel Xeon Gold 6248R"] + assert len(inventory.get_all_items(NBModule)) == 2 + + +def test_same_bay_new_model_updates_module_type(modules_source): + """Same device + same physical bay + a replaced CPU model across runs. + + Regression for CodeRabbit PR #1: the bay must be keyed on a stable slot (not the + model-bearing display name), so a model swap reuses the same bay and the module + re-points to the new module type instead of churning the bay or keeping a stale type. + """ + source, inventory, _ = modules_source(True, "4.3.0") + + # first run: CPU model A installed in socket "Socket 1" + source.update_all_items([cpu_item(bay_name="Socket 1", + model="Intel Xeon Gold 6248R", serial="CPU-AAA")], "CPU") + + # second run: same device, same socket, a different CPU model is now installed + source.update_all_items([cpu_item(bay_name="Socket 1", + model="Intel Xeon Gold 5318Y", serial="CPU-BBB")], "CPU") + + bays = inventory.get_all_items(NBModuleBay) + modules = inventory.get_all_items(NBModule) + + # the physical bay is stable: still a single bay holding a single module on the device + assert len(bays) == 1 + assert len(modules) == 1 + assert bays[0].data["name"] == "Socket 1" + + module = modules[0] + # the module re-points to the replaced part's module type (no stale catalog reference) + assert grab(module, "data.module_type.data.model") == "Intel Xeon Gold 5318Y" + assert module.data["serial"] == "CPU-BBB" + + +def test_missing_component_marks_module_health_absent(modules_source): + source, inventory, _ = modules_source(True, "4.3.0") + + cpu1 = cpu_item(bay_name="Socket 1", serial="CPU-AAA") + cpu2 = cpu_item(bay_name="Socket 2", serial="CPU-BBB") + source.update_all_items([cpu1, cpu2], "CPU") + + assert len(inventory.get_all_items(NBModule)) == 2 + + # second CPU disappears from the inventory file + source.update_all_items([cpu1], "CPU") + + modules_by_bay = { + grab(m, "data.module_bay.data.name"): m for m in inventory.get_all_items(NBModule) + } + assert grab(modules_by_bay["Socket 1"], "data.custom_fields.health") == "OK" + assert grab(modules_by_bay["Socket 2"], "data.custom_fields.health") == "Absent" + + +def test_mixed_bay_transition_does_not_remap_modules(modules_source): + """One bay disappears while a different new bay appears in the same sync. Because the module + bay is the authoritative physical slot (and update_module never moves a module between bays), + the removed bay must go Absent and the new bay must get its own module - the new component + must NOT be silently remapped onto the removed slot.""" + source, inventory, _ = modules_source(True, "4.3.0") + + source.update_all_items([ + cpu_item(bay_name="Socket 1", serial="CPU-AAA"), + cpu_item(bay_name="Socket 2", serial="CPU-BBB"), + ], "CPU") + assert len(inventory.get_all_items(NBModule)) == 2 + + # Socket 2 is removed and a brand new Socket 3 appears in the same run + source.update_all_items([ + cpu_item(bay_name="Socket 1", serial="CPU-AAA"), + cpu_item(bay_name="Socket 3", serial="CPU-CCC"), + ], "CPU") + + modules_by_bay = { + grab(m, "data.module_bay.data.name"): m for m in inventory.get_all_items(NBModule) + } + # three distinct bays now exist: the kept one, the removed one (Absent), and the new one + assert set(modules_by_bay) == {"Socket 1", "Socket 2", "Socket 3"} + assert grab(modules_by_bay["Socket 1"], "data.custom_fields.health") == "OK" + # the removed slot is marked Absent and keeps its own component data (not overwritten) + assert grab(modules_by_bay["Socket 2"], "data.custom_fields.health") == "Absent" + assert grab(modules_by_bay["Socket 2"], "data.serial") == "CPU-BBB" + # the new slot is its own active module carrying the new component's data + assert grab(modules_by_bay["Socket 3"], "data.serial") == "CPU-CCC" + assert grab(modules_by_bay["Socket 3"], "data.custom_fields.health") == "OK" + + +def fan_item(bay_name="System Board Fan1 (ID: 0.56)", health="OK"): + """A component that reports no manufacturer (fans, enclosures, PCIe extenders, ...).""" + return { + "description": ["Context: SystemBoard"], + "full_name": bay_name, + "health": health, + "speed": "9240RPM", + } + + +def test_component_without_manufacturer_uses_device_manufacturer(modules_source): + """NetBox requires a manufacturer on a module type. Components that report none (fans, + storage enclosures, PCIe extenders) must still get one, otherwise the module-type POST + fails with 'manufacturer required' and the whole module create cascade fails.""" + source, inventory, device = modules_source(True, "4.3.0") + + # give the device a manufacturer via its device type, like a real synced device has + manufacturer = inventory.add_object(NBManufacturer, data={"name": "Acme"}, source=source) + device_type = inventory.add_object( + NBDeviceType, data={"model": "PowerEdge R650", "manufacturer": manufacturer}, source=source) + device.update(data={"device_type": device_type}, source=source) + + source.update_all_items([fan_item()], "Fan") + + module_types = inventory.get_all_items(NBModuleType) + assert len(module_types) == 1 + assert len(inventory.get_all_items(NBModule)) == 1 + + # the (required) manufacturer is populated from the device's vendor + device_manufacturer = grab(device, "data.device_type.data.manufacturer.data.name") + assert grab(module_types[0], "data.manufacturer.data.name") == device_manufacturer + + +def test_component_without_manufacturer_falls_back_to_unknown(modules_source): + """When neither the component nor the device exposes a manufacturer, fall back to a + placeholder so the required module type field is always populated.""" + source, inventory, _ = modules_source(True, "4.3.0") # device has no device type / manufacturer + + source.update_all_items([fan_item()], "Fan") + + module_types = inventory.get_all_items(NBModuleType) + assert len(module_types) == 1 + assert grab(module_types[0], "data.manufacturer.data.name") == "Unknown" + assert len(inventory.get_all_items(NBModule)) == 1 + + +def test_existing_module_type_manufacturer_is_preserved(modules_source): + """If a module type for this model already exists in NetBox with a manufacturer (set by a + previous sync or curated by hand), a later sync of a component that reports no manufacturer + must reuse it, not overwrite it with the device-vendor / 'Unknown' fallback.""" + source, inventory, _ = modules_source(True, "4.3.0") + + # a module type for this model already exists in NetBox, manufacturer "Globex" + globex = inventory.add_object(NBManufacturer, data={"name": "Globex"}, source=source) + inventory.add_object( + NBModuleType, data={"model": "PCIe Extender", "manufacturer": globex}, source=source) + + # the PCIe extender reports no manufacturer + source.update_all_items([{ + "full_name": "PCIe Extender", + "model": "PCIe Extender", + "health": "OK", + "description": ["LDs: 1, PDs: 1"], + }], "Storage Controller") + + module_types = [mt for mt in inventory.get_all_items(NBModuleType) + if grab(mt, "data.model") == "PCIe Extender"] + assert len(module_types) == 1 + # the pre-existing manufacturer is preserved, not clobbered by the fallback + assert grab(module_types[0], "data.manufacturer.data.name") == "Globex" + + +def test_nic_and_bmc_interfaces_are_attached_to_their_modules(modules_source): + """NIC port interfaces are attached to their adapter's module and the BMC interface to the + manager module, so NetBox cascade-deletes them when the module is removed (module FK).""" + source, inventory, _ = modules_source(True, "4.3.0") + source.interface_adapter_type_dict = {} + source.nic_module_bay_by_adapter_id = {} + source.manager_name = None + source.settings.overwrite_interface_name = False + source.settings.overwrite_interface_attributes = False + source.settings.permitted_subnets = None # ports carry no IPs, so this is never dereferenced + source.settings.ip_tenant_inheritance_order = [] + + source.inventory_file_content = { + "inventory": { + "manager": [ + {"name": "iDRAC 9", "model": None, "licenses": [], "firmware": "7.0", + "health_status": "OK"} + ], + "network_adapter": [ + {"id": "NIC.Slot.1", "name": "NIC.Slot.1", "model": "BCM57414", + "manufacturer": "Broadcom", "operation_status": "Enabled", "num_ports": "2", + "serial": "NIC-AAA", "firmware": "1.0"} + ], + "network_port": [ + {"id": "NIC.Slot.1-1", "name": "Slot 1 Port 1", "adapter_id": "NIC.Slot.1", + "operation_status": "Enabled", "link_status": "Up", "addresses": [], + "capable_speed": 10000, "manager_ids": []}, + {"id": "NIC.1", "name": "iDRAC", "adapter_id": None, + "operation_status": "Enabled", "link_status": "Up", "addresses": [], + "capable_speed": 1000, "manager_ids": ["iDRAC.Embedded.1"]}, + ], + } + } + + source.update_manager() + source.update_network_adapter() + source.update_network_interface() + + interfaces = {grab(i, "data.name"): i for i in inventory.get_all_items(NBInterface)} + nic_interface = interfaces["NIC.Slot.1-1"] + bmc_interface = interfaces["iDRAC 9 (NIC.1)"] + + # the NIC port belongs to its adapter's module, the BMC port to the manager module + assert grab(nic_interface, "data.module.data.module_bay.data.name") == "NIC.Slot.1" + assert grab(bmc_interface, "data.module.data.module_bay.data.name") == "iDRAC 9" + + # and it really is the same module object created for this device + assert grab(nic_interface, "data.module") is source.find_device_module_by_bay_name("NIC.Slot.1") + + +def test_nic_port_interface_named_by_stable_redfish_id(modules_source): + """With modules on, a NIC port is named by its stable redfish id (e.g. NIC.Slot.1-1) rather + than the long human label prepended to it; the descriptive label moves to the description.""" + source, inventory, _ = modules_source(True, "4.3.0") + source.interface_adapter_type_dict = {} + source.nic_module_bay_by_adapter_id = {} + source.manager_name = None + source.settings.overwrite_interface_name = False + source.settings.overwrite_interface_attributes = False + source.settings.permitted_subnets = None + source.settings.ip_tenant_inheritance_order = [] + + source.inventory_file_content = { + "inventory": { + "network_adapter": [ + {"id": "NIC.Integrated.1", "name": "NIC.Integrated.1", "model": "BCM57412", + "manufacturer": "Broadcom", "operation_status": "Enabled", "num_ports": "1"} + ], + "network_port": [ + {"id": "NIC.Integrated.1-1", "name": "Integrated NIC 1 Port 1 Partition 1", + "adapter_id": "NIC.Integrated.1", "operation_status": "Enabled", + "link_status": "Up", "addresses": [], "capable_speed": 10000, + "manager_ids": []}, + ], + } + } + + source.update_network_adapter() + source.update_network_interface() + + interfaces = {grab(i, "data.name"): i for i in inventory.get_all_items(NBInterface)} + + # the stable id is the name; the description carries the human label, not the name + assert "NIC.Integrated.1-1" in interfaces + assert "Integrated NIC 1 Port 1 Partition 1 (NIC.Integrated.1-1)" not in interfaces + assert grab(interfaces["NIC.Integrated.1-1"], "data.description") == \ + "Integrated NIC 1 Port 1 Partition 1" + + +def test_nic_module_bay_stable_when_adapter_label_changes(modules_source): + """The NIC module bay is keyed on the stable adapter id (e.g. NIC.Slot.1), not the mutable + human label - so a relabeled adapter in the same physical slot reuses the bay instead of + churning a new one (which strict bay matching would otherwise mark the old one Absent for).""" + source, inventory, _ = modules_source(True, "4.3.0") + source.interface_adapter_type_dict = {} + source.nic_module_bay_by_adapter_id = {} + + def adapter(label): + return {"inventory": {"network_adapter": [ + {"id": "NIC.Slot.1", "name": label, "model": "BCM57414", "manufacturer": "Broadcom", + "operation_status": "Enabled", "num_ports": "2", "serial": "NIC-AAA"}]}} + + source.inventory_file_content = adapter("Broadcom Adapter") + source.update_network_adapter() + source.inventory_file_content = adapter("Broadcom Adapter rev2") + source.update_network_adapter() + + bays = inventory.get_all_items(NBModuleBay) + assert len(bays) == 1 + assert bays[0].data["name"] == "NIC.Slot.1" + assert len(inventory.get_all_items(NBModule)) == 1 + # and the in-memory adapter->bay map used for interface linking is the stable id too + assert source.nic_module_bay_by_adapter_id["NIC.Slot.1"] == "NIC.Slot.1" + + +def test_dimm_module_bay_stable_when_dimm_type_changes(modules_source): + """A DIMM's module bay is the stable slot (e.g. "DIMM A1"); the memory type appended to the + display name must not be part of the bay identity, so swapping the DIMM reuses the bay and + only re-points its module type instead of churning a new bay. Drives the real update_memory().""" + source, inventory, _ = modules_source(True, "4.3.0") + + def dimm(dimm_type, part): + return {"inventory": {"memory": [ + {"name": "DIMM A1", "type": dimm_type, "manufacturer": "Samsung", "part_number": part, + "serial": "DIMM-AAA", "size_in_mb": 32768, "speed": 3200, + "health_status": "OK", "operation_status": "GoodInUse"}]}} + + source.inventory_file_content = dimm("DDR4", "PN-DDR4") + source.update_memory() + source.inventory_file_content = dimm("DDR5", "PN-DDR5") + source.update_memory() + + bays = inventory.get_all_items(NBModuleBay) + assert len(bays) == 1 + assert bays[0].data["name"] == "DIMM A1" + assert len(inventory.get_all_items(NBModule)) == 1 + # the swap re-points the module type to the new part instead of creating a second bay + assert grab(inventory.get_all_items(NBModule)[0], "data.module_type.data.model") == "PN-DDR5" + + +def test_physical_drive_module_bay_stable_when_model_changes(modules_source): + """A physical drive's module bay is the stable slot; the type/model appended to the display + name must not churn the bay, so replacing the drive in a slot reuses the bay (a real swap also + brings a new serial). Drives the real update_physical_drive().""" + source, inventory, _ = modules_source(True, "4.3.0") + + def drive(model, serial): + return {"inventory": {"physical_drive": [ + {"name": "Solid State Disk", "id": "Disk.Bay.0", "location": "Slot 5", "type": "SSD", + "model": model, "manufacturer": "Samsung", "serial": serial, "part_number": "PN-DRV", + "size_in_byte": 512000000000, "health_status": "OK", "operation_status": "GoodInUse"}]}} + + source.inventory_file_content = drive("MZ-A", "DRV-AAA") + source.update_physical_drive() + source.inventory_file_content = drive("MZ-B", "DRV-BBB") + source.update_physical_drive() + + bays = inventory.get_all_items(NBModuleBay) + assert len(bays) == 1 + assert bays[0].data["name"] == "Solid State Disk Slot 5" + assert len(inventory.get_all_items(NBModule)) == 1 + assert grab(inventory.get_all_items(NBModule)[0], "data.serial") == "DRV-BBB" + + +def test_interfaces_not_attached_to_modules_when_feature_disabled(modules_source): + """With the modules feature off, interfaces must not get a module reference.""" + source, inventory, _ = modules_source(False, "4.3.0") + source.interface_adapter_type_dict = {} + source.nic_module_bay_by_adapter_id = {} + source.manager_name = None + source.settings.overwrite_interface_name = False + source.settings.overwrite_interface_attributes = False + source.settings.permitted_subnets = None + source.settings.ip_tenant_inheritance_order = [] + + source.inventory_file_content = { + "inventory": { + "network_adapter": [ + {"id": "NIC.Slot.1", "name": "NIC.Slot.1", "model": "BCM57414", + "manufacturer": "Broadcom", "operation_status": "Enabled", "num_ports": "2"} + ], + "network_port": [ + {"id": "NIC.Slot.1-1", "name": "Slot 1 Port 1", "adapter_id": "NIC.Slot.1", + "operation_status": "Enabled", "link_status": "Up", "addresses": [], + "capable_speed": 10000, "manager_ids": []}, + ], + } + } + + source.update_network_adapter() + source.update_network_interface() + + interfaces = inventory.get_all_items(NBInterface) + assert len(interfaces) == 1 + assert grab(interfaces[0], "data.module") is None + # with the feature off, the legacy "