From cdb471cf9be254dbc92ffb2ebd34a35255e50cf5 Mon Sep 17 00:00:00 2001 From: Sergey Sannikov Date: Sat, 10 Oct 2026 04:42:34 +0400 Subject: [PATCH] vmware: sync Fibre Channel HBAs of hosts as interfaces Behind the new option sync_host_fc_adapters (off by default) every vim.host.FibreChannelHba of an ESXi host becomes a NetBox interface on the device: the adapter's device name, an FC interface type derived from the model's capability (the port's link speed only when the model names none, so a port that drops link keeps its type), the port WWN in the colon separated upper case form NetBox stores, model and driver as description, and enabled from the adapter status. Block and PCIe host bus adapters are ignored, as is an FC adapter that reports no port WWN. FC HBAs carry no MAC address, and interfaces that match nothing used to be paired 1:1 by name with whatever current interface was left, which on a host with a BMC interface from another source renamed that interface and moved its IP. Interfaces with a WWN now match by it when no interface of their name exists, and Fibre Channel ports (an FC interface type, or a WWN without a MAC address) take no part in the leftover pairing, on either side. Interfaces with a MAC address, converged adapter ports among them, are matched as before. The NetBox FC interface types are added to the known interface types. Fixes #343 --- module/netbox/object_classes.py | 10 +- module/sources/common/source_base.py | 66 ++++- module/sources/vmware/config.py | 13 + module/sources/vmware/connection.py | 135 +++++++++- settings-example.ini | 10 + tests/test_vmware_host_fc_adapters.py | 339 ++++++++++++++++++++++++++ 6 files changed, 565 insertions(+), 8 deletions(-) create mode 100644 tests/test_vmware_host_fc_adapters.py diff --git a/module/netbox/object_classes.py b/module/netbox/object_classes.py index e6e8c81..e9fd3fb 100644 --- a/module/netbox/object_classes.py +++ b/module/netbox/object_classes.py @@ -57,7 +57,15 @@ class to handle and determine NetBox interface types "200gbase-x-qsfp56": 200_000, "200gbase-x-cfp2": 200_000, "400gbase-x-qsfpdd": 400_000, - "400gbase-x-osfp": 400_000 + "400gbase-x-osfp": 400_000, + "1gfc-sfp": 1, + "2gfc-sfp": 2, + "4gfc-sfp": 4, + "8gfc-sfpp": 8, + "16gfc-sfpp": 16, + "32gfc-sfp28": 32, + "64gfc-qsfpp": 64, + "128gfc-qsfp28": 128 } # assign common types for an interface speed value diff --git a/module/sources/common/source_base.py b/module/sources/common/source_base.py index 7537f51..70f459f 100644 --- a/module/sources/common/source_base.py +++ b/module/sources/common/source_base.py @@ -95,6 +95,20 @@ def ip_is_primary_ip_of_object(self, ip_object, device_vm_object) -> bool: return False + @staticmethod + def is_fc_interface(interface_type, wwn=None, mac_address=None): + """ + Return True if the given interface is a Fibre Channel port. + + A Fibre Channel port is identified by a NetBox FC interface type (all FC types + contain 'gfc') or by a world wide name (WWN) without a MAC address. An interface + with both a MAC address and a WWN (a converged network adapter port) is an + Ethernet interface with a WWN and is matched like any other Ethernet interface. + FC ports never take part in the leftover 1:1 name pairing of + 'map_object_interfaces_to_current_interfaces'. + """ + return "gfc" in str(interface_type or "") or (wwn is not None and mac_address is None) + def map_object_interfaces_to_current_interfaces(self, device_vm_object, interface_data_dict=None, append_unmatched_interfaces=False): """ @@ -107,6 +121,14 @@ def map_object_interfaces_to_current_interfaces(self, device_vm_object, interfac MAC address of interfaces match exactly, distinguish between physical and virtual interfaces by MAC regardless of interface type MAC address of interfaces match exactly, type of interface does not matter + by WWN: + an interface with a world wide name matches the current interface with the + same WWN when no interface of that name exists yet + + Fibre Channel ports (FC interface type, or a WWN without a MAC address) never take + part in the leftover 1:1 name pairing below: an unmatched FC HBA is created as a + new interface, and an FC current interface is never handed to a discovered + Ethernet NIC. Interfaces with a MAC address are matched as before. If there are interfaces which don't match at all then the unmatched interfaces will be matched 1:1. Sort both lists (unmatched current interfaces, unmatched new interfaces) @@ -161,6 +183,11 @@ def map_object_interfaces_to_current_interfaces(self, device_vm_object, interfac current_object_interface_names = list() + # current interfaces indexed by WWN for WWN matching, and the names of the Fibre + # Channel ports among them, which never take part in the leftover 1:1 name pairing + current_interfaces_by_wwn = dict() + current_fc_interface_names = set() + return_data = dict() # grab current data @@ -172,6 +199,8 @@ def map_object_interfaces_to_current_interfaces(self, device_vm_object, interfac else: int_mac = grab(interface, "data.mac_address") int_name = grab(interface, "data.name") + int_wwn = grab(interface, "data.wwn") + int_type_string = grab(interface, "data.type", fallback="virtual") if interface_exclude_filter is not None and int_name is not None and \ interface_exclude_filter.match(int_name): @@ -179,7 +208,7 @@ def map_object_interfaces_to_current_interfaces(self, device_vm_object, interfac f"Excluding it from all interface matching attempts") continue int_type = "virtual" - if "virtual" not in str(grab(interface, "data.type", fallback="virtual")): + if "virtual" not in str(int_type_string): int_type = "physical" if int_mac is not None: @@ -190,6 +219,13 @@ def map_object_interfaces_to_current_interfaces(self, device_vm_object, interfac current_object_interfaces[int_name] = interface current_object_interface_names.append(int_name) + if int_wwn is not None: + current_interfaces_by_wwn[str(int_wwn).upper()] = interface + + # keep FC ports out of the leftover pairing + if self.is_fc_interface(int_type_string, int_wwn, int_mac): + current_fc_interface_names.add(int_name) + log.debug2("Found '%d' NICs in NetBox for '%s'" % (len(current_object_interface_names), device_vm_object.get_display_name())) @@ -200,8 +236,12 @@ def map_object_interfaces_to_current_interfaces(self, device_vm_object, interfac return_data[int_name] = None int_mac = grab(int_data, "mac_address", fallback="XX:XX:YY:YY:ZZ:ZZ") + int_wwn = grab(int_data, "wwn") + int_type_string = grab(int_data, "type", fallback="virtual") + is_fc = self.is_fc_interface(int_type_string, int_wwn, grab(int_data, "mac_address")) + int_type = "virtual" - if "virtual" not in str(grab(int_data, "type", fallback="virtual")): + if "virtual" not in str(int_type_string): int_type = "physical" # match simply by name @@ -210,13 +250,20 @@ def map_object_interfaces_to_current_interfaces(self, device_vm_object, interfac log.debug2(f"Found 1:1 name match for NIC '{int_name}'") matching_int = current_object_interfaces.get(int_name) + # match by WWN when no interface of that name exists yet + if matching_int is None and int_wwn is not None: + wwn_match = current_interfaces_by_wwn.get(str(int_wwn).upper()) + if wwn_match is not None and wwn_match not in return_data.values(): + log.debug2(f"Found 1:1 WWN match for interface '{int_name}'") + matching_int = wwn_match + # match mac by interface type - elif grab(current_object_interfaces, f"{int_type}.{int_mac}") is not None: + if matching_int is None and grab(current_object_interfaces, f"{int_type}.{int_mac}") is not None: log.debug2(f"Found 1:1 MAC address match for {int_type} NIC '{int_name}'") matching_int = grab(current_object_interfaces, f"{int_type}.{int_mac}") # match mac regardless of interface type - elif current_object_interfaces.get(int_mac) is not None and \ + if matching_int is None and current_object_interfaces.get(int_mac) is not None and \ current_object_interfaces.get(int_mac) not in return_data.values(): log.debug2(f"Found 1:1 MAC address match for NIC '{int_name}' (ignoring interface type)") matching_int = current_object_interfaces.get(int_mac) @@ -228,6 +275,11 @@ def map_object_interfaces_to_current_interfaces(self, device_vm_object, interfac if grab(matching_int, "data.name") in current_object_interface_names: current_object_interface_names.remove(grab(matching_int, "data.name")) + # an unmatched FC port is created as a new interface and never takes part in the + # leftover 1:1 name pairing + elif is_fc: + continue + # no match found, we match the leftovers just by #1 -> #1, #2 -> #2, ... else: unmatched_interface_names.append(int_name) @@ -240,7 +292,11 @@ def map_object_interfaces_to_current_interfaces(self, device_vm_object, interfac for int_name in unmatched_interface_names: return_data[int_name] = None else: - matching_nics = dict(zip(unmatched_interface_names, current_object_interface_names)) + # current FC ports are never handed to a discovered Ethernet NIC by the leftover pairing + leftover_current_interface_names = [name for name in current_object_interface_names + if name not in current_fc_interface_names] + + matching_nics = dict(zip(unmatched_interface_names, leftover_current_interface_names)) for new_int, current_int in matching_nics.items(): current_int_object = current_object_interfaces.get(current_int) diff --git a/module/sources/vmware/config.py b/module/sources/vmware/config.py index 72e32ac..8186b8d 100644 --- a/module/sources/vmware/config.py +++ b/module/sources/vmware/config.py @@ -570,6 +570,19 @@ def __init__(self): disabled by default.""", default_value=False ), + ConfigOption("sync_host_fc_adapters", + bool, + description="""Create NetBox interfaces for the Fibre Channel host bus adapters (HBAs) + of an ESXi host. For each FC HBA an interface is created with the adapter's device + name, an interface type derived from the adapter model's "G"/"Gb" capability + (the first number when several are given) and only falling back to the port speed + when the model gives none, the port WWN and the adapter model and driver as + description. Other host bus adapter types (block, PCIe) are ignored, as is an FC + adapter that reports no port WWN. 'host_interface_exclude_filter' applies to the + adapter names as well. The option is independent of 'skip_host_nics'. Disabled + by default.""", + default_value=False + ), # removed settings ConfigOption("netbox_host_device_role", diff --git a/module/sources/vmware/connection.py b/module/sources/vmware/connection.py index 60cf56d..aec286e 100644 --- a/module/sources/vmware/connection.py +++ b/module/sources/vmware/connection.py @@ -9,6 +9,7 @@ import datetime import pprint +import re import ssl from ipaddress import ip_address, ip_interface from urllib.parse import unquote @@ -111,6 +112,18 @@ class VMWareHandler(SourceBase): unknown_hardware_identifiers = ["Default string", "NA", "N/A", "None", "Null", "oem", "o.e.m", "to be filled by o.e.m.", "Unknown", "Generic Vendor", "Generic Model"] + # NetBox interface types for Fibre Channel HBAs, keyed by the adapter port speed in Gbit + fc_hba_interface_types = { + 1: "1gfc-sfp", + 2: "2gfc-sfp", + 4: "4gfc-sfp", + 8: "8gfc-sfpp", + 16: "16gfc-sfpp", + 32: "32gfc-sfp28", + 64: "64gfc-qsfpp", + 128: "128gfc-qsfp28" + } + def __init__(self, name=None): if name is None: @@ -847,6 +860,75 @@ def hardware_identifier_is_unknown(value): return value.lower() in [x.lower() for x in VMWareHandler.unknown_hardware_identifiers] + @staticmethod + def fc_hba_interface_type(speed, model=None): + """ + Determine a NetBox interface type for a Fibre Channel host bus adapter. + + The adapter model string is used first: the first "G"/"Gb" capability in + the model is taken, i.e. "QLE2700/QLE2800 32/64G SP/DP Fibre Channel Adapter" + yields 32. This keeps the NetBox type stable even when a port loses link and + reports speed 0. Only when the model gives no speed the live port speed reported + by vCenter is used (1, 2, 4, 8, 16, 32, 64 or 128 Gbit). If no speed can be + determined the type "other" is returned. + + Parameters + ---------- + speed: int, str + port speed in Gbit as reported by vCenter + model: str + adapter model string used to derive the speed + + Returns + ------- + str: a NetBox interface type + """ + + if isinstance(model, str): + speed_match = re.search(r"(\d+)(?:/\d+)*Gb?", model) + if speed_match is not None: + model_speed = int(speed_match.group(1)) + if model_speed in VMWareHandler.fc_hba_interface_types: + return VMWareHandler.fc_hba_interface_types[model_speed] + + try: + speed = int(speed) + except (TypeError, ValueError): + speed = None + + if speed in VMWareHandler.fc_hba_interface_types: + return VMWareHandler.fc_hba_interface_types[speed] + + return "other" + + @staticmethod + def fc_hba_wwn(port_wwn): + """ + Convert a port WWN from the decimal notation vCenter reports to the colon + separated 16 hex digit notation NetBox expects. + + Parameters + ---------- + port_wwn: int, str + port world wide name in decimal notation + + Returns + ------- + str, None: "xx:xx:xx:xx:xx:xx:xx:xx" or None if no WWN was provided + """ + + if port_wwn is None: + return None + + try: + port_wwn = int(port_wwn) + except (TypeError, ValueError): + return None + + hex_wwn = f"{port_wwn:016x}" + + return ":".join(hex_wwn[i:i + 2] for i in range(0, 16, 2)).upper() + def get_site_name(self, object_type, object_name, cluster_name=""): """ Return a site name for a NBCluster or NBDevice depending on config options @@ -1480,7 +1562,8 @@ def get_object_relation(self, name, relation, fallback=None): return resolved_name def add_device_vm_to_inventory(self, object_type, object_data, pnic_data=None, vnic_data=None, - nic_ips=None, p_ipv4=None, p_ipv6=None, vmware_object=None, disk_data=None): + nic_ips=None, p_ipv4=None, p_ipv6=None, vmware_object=None, disk_data=None, + fc_hba_data=None): """ Add/update device/VM object in inventory based on gathered data. @@ -1543,6 +1626,8 @@ def add_device_vm_to_inventory(self, object_type, object_data, pnic_data=None, v vmware object to pass on to 'add_update_interface' method to set up reevaluation disk_data: list data of discs which belong to a VM + fc_hba_data: dict + data of Fibre Channel host bus adapter interfaces, interface name as key Returns ------- @@ -1741,6 +1826,8 @@ def add_device_vm_to_inventory(self, object_type, object_data, pnic_data=None, v interface_exclude_filter = self.settings.vm_interface_exclude_filter else: nic_data = {**pnic_data, **vnic_data} + if fc_hba_data: + nic_data = {**nic_data, **fc_hba_data} interface_exclude_filter = self.settings.host_interface_exclude_filter # exclude discovered interfaces which match the exclude filter @@ -2585,6 +2672,49 @@ def add_host(self, obj): pnic_data_dict[pnic_name] = pnic_data + # collect Fibre Channel host bus adapters as interfaces if enabled + fc_hba_data_dict = dict() + if self.settings.sync_host_fc_adapters is True: + for hba in grab(obj, "config.storageDevice.hostBusAdapter", fallback=list()): + + # only Fibre Channel adapters are synced, block and PCIe adapters are ignored + if not isinstance(hba, vim.host.FibreChannelHba): + continue + + hba_device = get_string_or_none(grab(hba, "device")) + if hba_device is None: + continue + + # the WWN is what tells the interface apart from a NIC during matching + hba_wwn = self.fc_hba_wwn(grab(hba, "portWorldWideName")) + if hba_wwn is None: + log.debug(f"FC HBA '{hba_device}' of host '{name}' reports no port WWN. Skipping") + continue + + hba_model = get_string_or_none(grab(hba, "model")) + hba_driver = get_string_or_none(grab(hba, "driver")) + + if hba_model is not None and hba_driver is not None: + hba_description = f"{hba_model} ({hba_driver})" + elif hba_model is not None: + hba_description = hba_model + else: + hba_description = hba_driver + + fc_hba_data = { + "name": unquote(hba_device), + "device": None, # will be set once we found the correct device + "type": self.fc_hba_interface_type(grab(hba, "speed"), hba_model), + "enabled": get_string_or_none(grab(hba, "status")) == "online", + "mgmt_only": False, + "wwn": hba_wwn + } + + if hba_description is not None: + fc_hba_data["description"] = hba_description + + fc_hba_data_dict[hba_device] = fc_hba_data + host_primary_ip4 = None host_primary_ip6 = None @@ -2733,7 +2863,8 @@ def add_host(self, obj): device_object, interface_objects = \ self.add_device_vm_to_inventory(NBDevice, object_data=host_data, pnic_data=pnic_data_dict, vnic_data=vnic_data_dict, nic_ips=vnic_ips, - p_ipv4=host_primary_ip4, p_ipv6=host_primary_ip6, vmware_object=obj) + p_ipv4=host_primary_ip4, p_ipv6=host_primary_ip6, vmware_object=obj, + fc_hba_data=fc_hba_data_dict) # add cables to the switch ports which were reported via CDP/LLDP if device_object is not None: diff --git a/settings-example.ini b/settings-example.ini index 75b725f..bb9b579 100644 --- a/settings-example.ini +++ b/settings-example.ini @@ -533,6 +533,16 @@ password = super-secret ; usually maintained by hand, that's why this is disabled by default. ;sync_host_cables = False +; Create NetBox interfaces for the Fibre Channel host bus adapters (HBAs) of an ESXi host. +; For each FC HBA an interface is created with the adapter's device name, an interface +; type derived from the adapter model's "G"/"Gb" capability (the first number when +; several are given) and only falling back to the port speed when the model gives none, +; the port WWN and the adapter model and driver as description. Other host bus adapter +; types (block, PCIe) are ignored, as is an FC adapter that reports no port WWN. +; 'host_interface_exclude_filter' applies to the adapter names as well. The option is +; independent of 'skip_host_nics'. Disabled by default. +;sync_host_fc_adapters = False + [source/my-redfish-example] ; Defines if this source is enabled or not diff --git a/tests/test_vmware_host_fc_adapters.py b/tests/test_vmware_host_fc_adapters.py new file mode 100644 index 0000000..dc1f634 --- /dev/null +++ b/tests/test_vmware_host_fc_adapters.py @@ -0,0 +1,339 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020 - 2026 netbox-sync team. All rights reserved. +# +# netbox-sync.py +# +# This work is licensed under the terms of the MIT license. +# For a copy, see file LICENSE.txt included in this +# repository or visit: . + +""" +Fibre Channel HBAs as host interfaces (issue #343). + +Behind the opt-in `sync_host_fc_adapters` option each vim.host.FibreChannelHba of an +ESXi host is synced as a NetBox interface on that device. The type is derived from the +adapter model's "G"/"Gb" capability (falling back to the port speed when the +model gives none), the port WWN is converted from the decimal the vCenter API reports +to the colon separated upper case hex notation NetBox expects. +""" +from types import SimpleNamespace + +import pytest + +from module.netbox.object_classes import NBDevice, NBInterface, NBIPAddress, NBMACAddress +from module.sources import instantiate_sources +from module.sources.vmware.connection import VMWareHandler + + +def _find_device(inventory, name): + for device in inventory.get_all_items(NBDevice): + if device.data.get("name") == name: + return device + raise AssertionError(f"device '{name}' not found in inventory") + + +def _fc_interfaces(inventory, device): + return [interface for interface in inventory.get_all_interfaces(device) + if "gfc" in str(interface.data.get("type", ""))] + + +@pytest.mark.parametrize("speed, expected", [ + (1, "1gfc-sfp"), + (2, "2gfc-sfp"), + (4, "4gfc-sfp"), + (8, "8gfc-sfpp"), + (16, "16gfc-sfpp"), + (32, "32gfc-sfp28"), + (64, "64gfc-qsfpp"), + (128, "128gfc-qsfp28"), +]) +def test_fc_hba_type_from_port_speed(speed, expected): + assert VMWareHandler.fc_hba_interface_type(speed) == expected + + +def test_fc_hba_type_guessed_from_model_when_speed_is_zero(): + assert VMWareHandler.fc_hba_interface_type( + 0, "QLE2700/QLE2800 32/64G SP/DP Fibre Channel Adapter") == "32gfc-sfp28" + + +def test_fc_hba_type_model_takes_precedence_over_port_speed(): + # a port that lost link (speed 0) keeps the type derived from the model, and a live + # port reports the capability of the model, not the negotiated link speed + assert VMWareHandler.fc_hba_interface_type( + 16, "QLE2700/QLE2800 32/64G SP/DP Fibre Channel Adapter") == "32gfc-sfp28" + + +def test_fc_hba_type_parses_gb_model_capability(): + assert VMWareHandler.fc_hba_interface_type(0, "QLE2694 16Gb Fibre Channel Adapter") == "16gfc-sfpp" + + +def test_fc_hba_type_is_other_for_model_without_speed(): + assert VMWareHandler.fc_hba_interface_type(0, "Emulex LPe12000") == "other" + + +def test_fc_hba_type_is_other_when_speed_and_model_are_unknown(): + assert VMWareHandler.fc_hba_interface_type(None, None) == "other" + + +def test_fc_hba_wwn_is_converted_to_hex_notation(): + assert VMWareHandler.fc_hba_wwn(2378169741649783014) == "21:00:F4:C7:AA:9E:28:E6" + + +def test_fc_hba_wwn_is_zero_padded_to_16_hex_digits(): + assert VMWareHandler.fc_hba_wwn(111111) == "00:00:00:00:00:01:B2:07" + + +def test_fc_hba_wwn_none_stays_none(): + assert VMWareHandler.fc_hba_wwn(None) is None + + +def _matching_source(inventory): + """A VMware handler with nothing but the state interface matching needs.""" + source = object.__new__(VMWareHandler) + source.inventory = inventory + source.name = "test" + source.settings = SimpleNamespace( + host_interface_exclude_filter=None, + vm_interface_exclude_filter=None, + ) + return source + + +def test_fc_hbas_do_not_hijack_unmatched_nics(inventory): + # two plain NICs and a BMC port as they would be read from NetBox, then two FC HBAs + # that vCenter reports. The HBAs must be created as new interfaces, never paired 1:1 + # with the leftover NICs, so the BMC keeps its name and its IP (issue #343). + source = _matching_source(inventory) + device = inventory.add_object(NBDevice, data={"name": "esxi01"}, source=source) + + inventory.add_object(NBInterface, data={ + "name": "vmnic0", "device": device, "type": "1000base-t", "mac_address": "00:50:56:00:00:01", + }, source=source) + inventory.add_object(NBInterface, data={ + "name": "vmk0", "device": device, "type": "virtual", "mac_address": "00:50:56:00:00:02", + }, source=source) + bmc = inventory.add_object(NBInterface, data={ + "name": "iDRAC iDRAC9 (1:1)", "device": device, "type": "other", "mac_address": "38:68:DD:34:33:C5", + }, source=source) + inventory.add_object(NBIPAddress, data={"address": "10.0.0.10/24", "assigned_object_id": bmc}, + source=source) + + fc_hbas = { + "vmhba3": {"name": "vmhba3", "device": None, "type": "32gfc-sfp28", + "wwn": "21:00:F4:C7:AA:9E:28:E6"}, + "vmhba4": {"name": "vmhba4", "device": None, "type": "32gfc-sfp28", + "wwn": "21:00:F4:C7:AA:9E:28:E7"}, + } + + mapped = source.map_object_interfaces_to_current_interfaces(device, fc_hbas) + + # both HBAs become new interfaces; the BMC keeps its name and its IP + assert mapped["vmhba3"] is None + assert mapped["vmhba4"] is None + assert bmc not in mapped.values() + bmc_ips = bmc.get_ip_addresses() + assert len(bmc_ips) == 1 and bmc_ips[0].data.get("assigned_object_id") is bmc + + +def test_fc_hba_matches_existing_fc_interface_by_wwn(inventory): + # when the HBA was renamed (or re-enumerated) the WWN still finds its interface + source = _matching_source(inventory) + device = inventory.add_object(NBDevice, data={"name": "esxi01"}, source=source) + + existing = inventory.add_object(NBInterface, data={ + "name": "vmhba3", "device": device, "type": "32gfc-sfp28", "wwn": "21:00:F4:C7:AA:9E:28:E6", + }, source=source) + + mapped = source.map_object_interfaces_to_current_interfaces( + device, {"vmhba99": {"name": "vmhba99", "device": None, "type": "32gfc-sfp28", + "wwn": "21:00:F4:C7:AA:9E:28:E6"}}, True) + + assert mapped["vmhba99"] is existing + + +def test_fc_hbas_sharing_a_wwn_are_not_merged(inventory): + # two ports of one adapter report the same WWN; each still gets its own interface + source = _matching_source(inventory) + device = inventory.add_object(NBDevice, data={"name": "esxi01"}, source=source) + + existing = inventory.add_object(NBInterface, data={ + "name": "vmhba3", "device": device, "type": "32gfc-sfp28", "wwn": "21:00:F4:C7:AA:9E:28:E6", + }, source=source) + + mapped = source.map_object_interfaces_to_current_interfaces( + device, { + "vmhba64": {"name": "vmhba64", "device": None, "type": "32gfc-sfp28", + "wwn": "21:00:F4:C7:AA:9E:28:E6"}, + "vmhba65": {"name": "vmhba65", "device": None, "type": "32gfc-sfp28", + "wwn": "21:00:F4:C7:AA:9E:28:E6"}, + }, True) + + assert mapped["vmhba64"] is existing + assert mapped["vmhba65"] is None + + +def test_current_fc_interface_is_never_handed_to_an_ethernet_nic(inventory): + # the only leftover current interface is an FC port; a new NIC must not be paired with it + source = _matching_source(inventory) + device = inventory.add_object(NBDevice, data={"name": "esxi01"}, source=source) + inventory.add_object(NBInterface, data={ + "name": "vmhba3", "device": device, "type": "32gfc-sfp28", "wwn": "21:00:F4:C7:AA:9E:28:E6", + }, source=source) + + mapped = source.map_object_interfaces_to_current_interfaces( + device, {"vmnic8": {"name": "vmnic8", "device": None, "type": "1000base-t", + "mac_address": "00:50:56:00:00:08"}}) + + assert mapped["vmnic8"] is None + + +def test_fc_type_alone_marks_a_current_interface_as_fc(inventory): + # no WWN stored, the FC interface type is enough to keep it out of the leftover pairing + source = _matching_source(inventory) + device = inventory.add_object(NBDevice, data={"name": "esxi01"}, source=source) + inventory.add_object(NBInterface, data={"name": "vmhba3", "device": device, "type": "16gfc-sfpp"}, + source=source) + + mapped = source.map_object_interfaces_to_current_interfaces( + device, {"vmnic8": {"name": "vmnic8", "device": None, "type": "1000base-t", + "mac_address": "00:50:56:00:00:08"}}) + + assert mapped["vmnic8"] is None + + +def test_wwn_without_mac_alone_marks_an_interface_as_fc(inventory): + # an FC port whose type could not be determined still has a WWN and no MAC address + source = _matching_source(inventory) + device = inventory.add_object(NBDevice, data={"name": "esxi01"}, source=source) + inventory.add_object(NBInterface, data={ + "name": "fc0", "device": device, "type": "other", "wwn": "21:00:F4:C7:AA:9E:28:E6", + }, source=source) + inventory.add_object(NBInterface, data={"name": "eth9", "device": device, "type": "other"}, source=source) + + mapped = source.map_object_interfaces_to_current_interfaces( + device, { + "vmnic8": {"name": "vmnic8", "device": None, "type": "1000base-t", + "mac_address": "00:50:56:00:00:08"}, + "vmhba9": {"name": "vmhba9", "device": None, "type": "other", "wwn": "21:00:F4:C7:AA:9E:28:F0"}, + }) + + # the NIC is paired with the leftover Ethernet interface, never with the FC port; the FC + # port with an unknown type is created new instead of taking the leftover + assert mapped["vmnic8"].data.get("name") == "eth9" + assert mapped["vmhba9"] is None + + +def test_wwn_match_ignores_case(inventory): + source = _matching_source(inventory) + device = inventory.add_object(NBDevice, data={"name": "esxi01"}, source=source) + existing = inventory.add_object(NBInterface, data={ + "name": "vmhba3", "device": device, "type": "32gfc-sfp28", "wwn": "21:00:f4:c7:aa:9e:28:e6", + }, source=source) + + mapped = source.map_object_interfaces_to_current_interfaces( + device, {"vmhba99": {"name": "vmhba99", "device": None, "type": "32gfc-sfp28", + "wwn": "21:00:F4:C7:AA:9E:28:E6"}}, True) + + assert mapped["vmhba99"] is existing + + +def test_cna_port_with_mac_and_wwn_is_still_matched_by_mac(inventory): + # a converged adapter port (check_redfish reports MAC and WWN) whose name changed is + # found by its MAC address like any Ethernet interface + source = _matching_source(inventory) + device = inventory.add_object(NBDevice, data={"name": "srv01"}, source=source) + existing = inventory.add_object(NBInterface, data={ + "name": "NIC.Integrated.1-2 (old)", "device": device, "type": "other", + "mac_address": "3C:EC:EF:1A:2B:3C", + }, source=source) + mac_object = inventory.add_object(NBMACAddress, data={ + "mac_address": "3C:EC:EF:1A:2B:3C", "assigned_object_id": existing, "assigned_object_type": NBInterface, + }, source=source) + existing.update(data={"primary_mac_address": mac_object}, source=source) + + mapped = source.map_object_interfaces_to_current_interfaces( + device, {"NIC.Integrated.1-2": {"name": "NIC.Integrated.1-2", "device": None, "type": "other", + "mac_address": "3C:EC:EF:1A:2B:3C", + "wwn": "50:01:43:80:12:34:56:78"}}) + + assert mapped["NIC.Integrated.1-2"] is existing + + +def test_fc_adapters_are_not_synced_by_default(vcsim, inventory, load_config, vmware_settings): + load_config(vmware_settings) + sources = instantiate_sources() + assert sources and sources[0].init_successful + assert sources[0].settings.sync_host_fc_adapters is False + + inventory.resolve_relations() + sources[0].apply() + + # with the option off no FC adapter interfaces exist, matching the base behaviour + vmhba_interfaces = [interface for interface in inventory.get_all_items(NBInterface) + if str(interface.data.get("name", "")).startswith("vmhba")] + assert vmhba_interfaces == [] + + +def test_fc_adapters_synced_for_vc001(vcsim, inventory, load_config, vmware_settings): + if vcsim.name != "vc001": + pytest.skip("FC adapter specifics are asserted against the vc001 capture") + + load_config(vmware_settings + "\nsync_host_fc_adapters = True\n") + sources = instantiate_sources() + assert sources and sources[0].init_successful + + inventory.resolve_relations() + sources[0].apply() + + host = _find_device(inventory, "172.29.50.68") + fc_interfaces = _fc_interfaces(inventory, host) + + assert len(fc_interfaces) == 8 + + # the host also has a PCIe NVMe controller and two SATA controllers (vmhba0-2); only the + # Fibre Channel adapters become interfaces + hba_interface_names = {interface.data.get("name") for interface in inventory.get_all_interfaces(host) + if str(interface.data.get("name", "")).startswith("vmhba")} + assert hba_interface_names == {"vmhba3", "vmhba4", "vmhba5", "vmhba6", + "vmhba64", "vmhba65", "vmhba66", "vmhba67"} + + # every port is a QLE2700/QLE2800, whose "32/64G" capability wins over the + # negotiated link speed (16) and over the offline ports' speed 0 + assert {interface.data.get("type") for interface in fc_interfaces} == {"32gfc-sfp28"} + + by_name = {interface.data.get("name"): interface for interface in fc_interfaces} + assert by_name["vmhba3"].data.get("description") == \ + "QLE2700/QLE2800 32/64G SP/DP Fibre Channel Adapter (qlnativefc)" + + vmhba3 = by_name["vmhba3"] + assert vmhba3.data.get("wwn") == "21:00:F4:C7:AA:9E:28:E6" + assert vmhba3.data.get("enabled") is False + + vmhba4 = by_name["vmhba4"] + assert vmhba4.data.get("wwn") == "21:00:F4:C7:AA:9E:28:E7" + assert vmhba4.data.get("enabled") is True + + +def test_fc_adapters_synced_for_every_capture(vcsim, inventory, load_config, vmware_settings): + load_config(vmware_settings + "\nsync_host_fc_adapters = True\n") + sources = instantiate_sources() + assert sources and sources[0].init_successful + + inventory.resolve_relations() + sources[0].apply() + + fc_interfaces = [interface for interface in inventory.get_all_items(NBInterface) + if "gfc" in str(interface.data.get("type", ""))] + assert fc_interfaces, "the option on must create FC interfaces on every capture" + + +def test_host_interface_exclude_filter_applies_to_fc_adapters(vcsim, inventory, load_config, vmware_settings): + load_config(vmware_settings + "\nsync_host_fc_adapters = True\nhost_interface_exclude_filter = ^vmhba\n") + sources = instantiate_sources() + assert sources and sources[0].init_successful + + inventory.resolve_relations() + sources[0].apply() + + assert [interface for interface in inventory.get_all_items(NBInterface) + if str(interface.data.get("name", "")).startswith("vmhba")] == []