diff --git a/module/netbox/__init__.py b/module/netbox/__init__.py index 8f5696c..73432a6 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 4b7ec09..cdd5aec 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 = { @@ -2076,7 +2077,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) @@ -2412,7 +2415,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) @@ -2431,4 +2436,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 55d3799..7311d75 100644 --- a/module/sources/check_redfish/config.py +++ b/module/sources/check_redfish/config.py @@ -54,6 +54,13 @@ def __init__(self): is then stored in the 'system_serial' custom field""", 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 c6f02a2..7bed66d 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() @@ -82,10 +88,21 @@ def __init__(self, name=None): log.info(f"Source '{name}' is currently disabled. Skipping") return + # modules have to be read from NetBox before they can be matched, otherwise every run + # tries to create them again. Only requested when the option is on, so nobody else pays + # for three extra queries + if grab(self.settings, "model_components_as_modules", fallback=False) is True: + self.dependent_netbox_objects = self.dependent_netbox_objects + \ + [NBModuleBay, NBModuleType, NBModule] + self.init_successful = True 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 @@ -206,6 +223,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: """ @@ -347,6 +365,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"]: @@ -388,7 +409,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")), @@ -421,7 +445,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"]) @@ -430,10 +454,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() @@ -490,6 +525,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}") @@ -510,6 +548,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")), @@ -570,7 +609,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, @@ -619,6 +661,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) @@ -641,6 +686,8 @@ def update_physical_drive(self): items.append({ "description": description, "manufacturer": get_string_or_none(grab(pd, "manufacturer")), + "model": model, + "bay_name": drive_bay or "None", "full_name": name or "None", "serial": serial, "part_number": get_string_or_none(grab(pd, "part_number")), @@ -686,6 +733,7 @@ def update_storage_controller(self): items.append({ "description": description, "manufacturer": get_string_or_none(grab(sc, "manufacturer")), + "model": model, "full_name": name or "None", "serial": get_string_or_none(grab(sc, "serial")), "firmware": get_string_or_none(grab(sc, "firmware")), @@ -720,6 +768,7 @@ def update_storage_enclosure(self): items.append({ "manufacturer": get_string_or_none(grab(se, "manufacturer")), + "model": model, "full_name": name or "None", "serial": get_string_or_none(grab(se, "serial")), "firmware": get_string_or_none(grab(se, "firmware")), @@ -774,12 +823,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, @@ -790,6 +845,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() @@ -834,7 +921,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 @@ -845,14 +943,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 @@ -875,6 +970,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: @@ -908,6 +1007,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: @@ -971,6 +1075,7 @@ def update_manager(self): items.append({ "description": description, "full_name": name, + "model": model, "manufacturer": grab(self.device_object, "data.device_type.data.manufacturer.data.name"), "firmware": get_string_or_none(grab(manager, "firmware")), "health": get_string_or_none(grab(manager, "health_status")) @@ -978,6 +1083,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 @@ -1003,6 +1126,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): @@ -1114,6 +1241,289 @@ 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) + # a type is a catalog entry shared by identical parts. Without a model or part number + # the component class is the closest thing to one; the instance name would create a new + # type for every fan and drive in the fleet + model = item_data.get("model") or part_number or item_data.get("inventory_type") 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. @@ -1125,6 +1535,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", @@ -1160,7 +1574,7 @@ def add_necessary_base_objects(self): "name": "firmware", "label": "Firmware", "object_types": [ - "dcim.inventoryitem", + component_object_type, "dcim.powerport" ], "type": "text", @@ -1171,7 +1585,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" }) @@ -1180,7 +1594,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" }) @@ -1189,7 +1603,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" }) @@ -1199,7 +1613,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 3f842ac..d360b10 100644 --- a/settings-example.ini +++ b/settings-example.ini @@ -553,6 +553,11 @@ inventory_file_path = /full/path/to/inventory/files ; serial number (the Dell PPID) is then stored in the 'system_serial' custom field ;dell_serial_from_service_tag = 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_module_catalog.py b/tests/test_check_redfish_module_catalog.py new file mode 100644 index 0000000..3130997 --- /dev/null +++ b/tests/test_check_redfish_module_catalog.py @@ -0,0 +1,48 @@ +""" +Modelling components as modules has to read the existing modules back from NetBox, +otherwise every run tries to create them again, and a module type has to be the +hardware model so the catalog is shared instead of holding one entry per component. +""" +from types import SimpleNamespace + +import pytest + +from module.netbox.object_classes import NBModule, NBModuleBay, NBModuleType +from module.sources.check_redfish.import_inventory import CheckRedfish + + +def _source(inventory, use_modules): + source = object.__new__(CheckRedfish) + source.inventory = inventory + source.name = "redfish" + source.source_tag = "Source: redfish" + source.settings = SimpleNamespace(model_components_as_modules=use_modules) + source.device_object = None + return source + + +@pytest.mark.parametrize("use_modules", [True, False]) +def test_module_objects_are_only_requested_when_enabled(inventory, use_modules): + dependencies = list(CheckRedfish.dependent_netbox_objects) + if use_modules: + dependencies += [NBModuleBay, NBModuleType, NBModule] + + for module_class in (NBModuleBay, NBModuleType, NBModule): + assert (module_class in dependencies) is use_modules, \ + f"{module_class.name} must be read from NetBox exactly when the option is on" + + +@pytest.mark.parametrize("item_data, expected", [ + ({"model": "ST2000NX0273", "full_name": "Disk.Bay.0 (HDD ST2000NX0273)", + "inventory_type": "Physical Drive"}, "ST2000NX0273"), + ({"part_number": "MTA36ASF8G72PZ", "full_name": "DIMM.A1 (DDR4)", + "inventory_type": "DIMM"}, "MTA36ASF8G72PZ"), + ({"full_name": "Fan1A (ID: Fan.Embedded.1)", "inventory_type": "Fan"}, "Fan"), +]) +def test_module_type_is_the_hardware_model_not_the_instance_name(inventory, item_data, expected): + source = _source(inventory, True) + source.device_manufacturer_name = lambda: "Dell" + + module_type = source.resolve_module_type(item_data) + + assert module_type.data.get("model") == expected diff --git a/tests/test_check_redfish_modules.py b/tests/test_check_redfish_modules.py new file mode 100644 index 0000000..0893e53 --- /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 "