From 7b157c234a780867973988368390ef3f0642ba04 Mon Sep 17 00:00:00 2001 From: lukify85 Date: Fri, 9 Oct 2026 14:28:46 -0400 Subject: [PATCH] Match ESXi hosts by serial/asset tag before MAC; add disable_host_mac_matching With MAC address pooling (e.g. Cisco UCS) the same MAC can be used by blades in different sites, so matching hosts by MAC first could attach a host to a device in the wrong site. Hosts are now matched by name+site, serial number (if match_host_by_serial), asset tag, MAC addresses, then primary IP. The new option 'disable_host_mac_matching' (default False) skips the MAC fallback for hosts. VM matching is unchanged and still controlled by match_vm_by_mac_address / match_vm_by_serial / match_vm_by_ip_address. Rebuilt on top of upstream 1.9.0, replacing 6ff1d2a and d54a320. Also fixes a regression in 6ff1d2a where the serial lookup ran regardless of match_host_by_serial and with a missing serial, matching the first device without a serial. Co-Authored-By: Claude Opus 5.5 --- module/sources/vmware/config.py | 11 +- module/sources/vmware/connection.py | 52 +++++---- settings-example.ini | 9 +- tests/test_vmware_host_device_type.py | 1 + tests/test_vmware_host_matching.py | 142 +++++++++++++++++++++++++ tests/test_vmware_vm_status_options.py | 1 + 6 files changed, 193 insertions(+), 23 deletions(-) create mode 100644 tests/test_vmware_host_matching.py diff --git a/module/sources/vmware/config.py b/module/sources/vmware/config.py index 72e32ac..c502bf1 100644 --- a/module/sources/vmware/config.py +++ b/module/sources/vmware/config.py @@ -236,9 +236,18 @@ def __init__(self): ConfigOption("match_host_by_serial", bool, description="""Try to find existing host based on serial number. This can cause issues - with blade centers if VMWare does not report the blades serial number properly.""", + with blade centers if VMWare does not report the blades serial number properly. + Hosts are matched by serial number and asset tag before MAC addresses.""", default_value=True), + ConfigOption("disable_host_mac_matching", + bool, + description="""Don't fall back to matching hosts by physical NIC MAC address if no + name+site, serial or asset tag match is found. Useful with MAC address pooling (e.g. + Cisco UCS) where the same MAC can be used by hosts in different sites. Best combined + with 'match_host_by_serial'. VM matching is controlled by 'match_vm_by_mac_address'.""", + default_value=False), + ConfigOption("match_vm_by_serial", bool, description="""Fall back to matching VMs by serial number (BIOS UUID) if no name+cluster diff --git a/module/sources/vmware/connection.py b/module/sources/vmware/connection.py index 27ea23c..744d6fe 100644 --- a/module/sources/vmware/connection.py +++ b/module/sources/vmware/connection.py @@ -1486,9 +1486,10 @@ def add_device_vm_to_inventory(self, object_type, object_data, pnic_data=None, v Try to find object first based on the object data, interface MAC addresses and primary IPs. 1. try to find by name and cluster/site - 2. try to find by mac addresses interfaces - 3. try to find by serial number (1st) or asset tag (2nd) (ESXi host) - 4. try to find by primary IP + 2. ESXi host: try to find by serial number (1st), asset tag (2nd) or mac addresses of + interfaces (3rd, unless 'disable_host_mac_matching' is set) + VM: try to find by mac addresses of interfaces (1st) or serial number (2nd) + 3. try to find by primary IP IP addresses for each interface are added here as well. First they will be checked and added if all checks pass. For each IP address a matching IP prefix will be searched for. First we @@ -1575,35 +1576,44 @@ def add_device_vm_to_inventory(self, object_type, object_data, pnic_data=None, v (object_type.name, device_vm_object.get_display_name(including_second_key=True))) # keep searching if no exact match was found - elif object_type == NBVM and self.settings.match_vm_by_mac_address is False: + # hosts are matched by serial and asset tag before MAC addresses, as pooled MACs + # (e.g. Cisco UCS) are not unique per blade and can match a host in another site + elif object_type == NBDevice: - log.debug2("Matching VMs by MAC address is disabled via 'match_vm_by_mac_address'. Skipping.") + if object_data.get("serial") is not None and self.settings.match_host_by_serial is True: + log.debug2(f"No exact match found. Trying to find {object_type.name} based on serial number") - else: + device_vm_object = self.inventory.get_by_data(object_type, data={"serial": object_data.get("serial")}) - log.debug2(f"No exact match found. Trying to find {object_type.name} based on MAC addresses") + if device_vm_object is None and object_data.get("asset_tag") is not None: + log.debug2(f"No match found. Trying to find {object_type.name} based on asset tag") - # on VMs vnic data is used, on physical devices pnic data is used - mac_source_data = vnic_data if object_type == NBVM else pnic_data + device_vm_object = self.inventory.get_by_data(object_type, + data={"asset_tag": object_data.get("asset_tag")}) - nic_macs = [x.get("mac_address") for x in mac_source_data.values()] + if device_vm_object is None and self.settings.disable_host_mac_matching is True: - device_vm_object = self.get_object_based_on_macs(object_type, nic_macs) + log.debug2("Matching hosts by MAC address is disabled via 'disable_host_mac_matching'. Skipping.") - # look for devices with same serial or asset tag - if object_type == NBDevice: + elif device_vm_object is None: - if device_vm_object is None and object_data.get("serial") is not None and \ - self.settings.match_host_by_serial is True: - log.debug2(f"No match found. Trying to find {object_type.name} based on serial number") + log.debug2(f"No match found. Trying to find {object_type.name} based on MAC addresses") - device_vm_object = self.inventory.get_by_data(object_type, data={"serial": object_data.get("serial")}) + nic_macs = [x.get("mac_address") for x in pnic_data.values()] - if device_vm_object is None and object_data.get("asset_tag") is not None: - log.debug2(f"No match found. Trying to find {object_type.name} based on asset tag") + device_vm_object = self.get_object_based_on_macs(object_type, nic_macs) - device_vm_object = self.inventory.get_by_data(object_type, - data={"asset_tag": object_data.get("asset_tag")}) + elif self.settings.match_vm_by_mac_address is False: + + log.debug2("Matching VMs by MAC address is disabled via 'match_vm_by_mac_address'. Skipping.") + + else: + + log.debug2(f"No exact match found. Trying to find {object_type.name} based on MAC addresses") + + nic_macs = [x.get("mac_address") for x in vnic_data.values()] + + device_vm_object = self.get_object_based_on_macs(object_type, nic_macs) # look for VMs with same serial if object_type == NBVM and device_vm_object is None and object_data.get("serial") is not None and \ diff --git a/settings-example.ini b/settings-example.ini index b1c71fa..5102991 100644 --- a/settings-example.ini +++ b/settings-example.ini @@ -280,9 +280,16 @@ password = super-secret ;vm_tag_relation = grafana.* = Infrastructure ; Try to find existing host based on serial number. This can cause issues with blade -; centers if VMWare does not report the blades serial number properly. +; centers if VMWare does not report the blades serial number properly. Hosts are matched +; by serial number and asset tag before MAC addresses. ;match_host_by_serial = True +; Don't fall back to matching hosts by physical NIC MAC address if no name+site, serial or +; asset tag match is found. Useful with MAC address pooling (e.g. Cisco UCS) where the +; same MAC can be used by hosts in different sites. Best combined with +; 'match_host_by_serial'. VM matching is controlled by 'match_vm_by_mac_address'. +;disable_host_mac_matching = False + ; Fall back to matching VMs by serial number (BIOS UUID) if no name+cluster match is ; found. Can misattribute a VM to an unrelated NetBox object if the same UUID is reported ; by multiple sources, e.g. a cloned/migrated VM whose stale copy overwrites the real VM's diff --git a/tests/test_vmware_host_device_type.py b/tests/test_vmware_host_device_type.py index 06f56dc..26672c9 100644 --- a/tests/test_vmware_host_device_type.py +++ b/tests/test_vmware_host_device_type.py @@ -33,6 +33,7 @@ def _make_source(inventory): src.object_cache = dict() src.settings = SimpleNamespace( match_host_by_serial=True, + disable_host_mac_matching=False, match_vm_by_serial=True, match_vm_by_mac_address=True, match_vm_by_ip_address=True, diff --git a/tests/test_vmware_host_matching.py b/tests/test_vmware_host_matching.py new file mode 100644 index 0000000..513e0b3 --- /dev/null +++ b/tests/test_vmware_host_matching.py @@ -0,0 +1,142 @@ +# -*- 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: . + +""" +ESXi hosts are matched by serial number and asset tag before MAC addresses. + +With MAC address pooling (e.g. Cisco UCS) the same MAC can be used by blades in +different sites, so matching by MAC first could attach a host to a device in the +wrong site. 'disable_host_mac_matching' turns the MAC fallback off for hosts. +""" +from types import SimpleNamespace + +from module.netbox.object_classes import NBSite, NBManufacturer, NBDeviceType, NBDevice, NBInterface +from module.sources.vmware.config import VMWareConfig +from module.sources.vmware.connection import VMWareHandler + +POOLED_MAC = "00:25:B5:00:00:01" + + +def _default_settings(): + # all VMware source options with their default values, the host NICs passed below + # run the interface sync which reads a lot more settings than the matching does + settings = dict() + for option in VMWareConfig().options: + for this_option in getattr(option, "options", [option]): + settings[this_option.key] = this_option.value + return settings + + +def _make_source(inventory, match_host_by_serial=True, disable_host_mac_matching=False): + # build the handler without its network-bound __init__ + src = object.__new__(VMWareHandler) + src.inventory = inventory + src.name = "test" + src.source_tag = "Source: test" + src.object_cache = dict() + src.settings = SimpleNamespace(**{ + **_default_settings(), + "match_host_by_serial": match_host_by_serial, + "disable_host_mac_matching": disable_host_mac_matching, + "host_role_relation": [], + "ip_tenant_inheritance_order": ["device", "prefix"], + }) + return src + + +def _existing_host(inventory, name, site_name, serial=None, mac=None): + # a device as it would be read from NetBox, optionally with a NIC using the given MAC + site = inventory.get_by_data(NBSite, data={"name": site_name}) or \ + inventory.add_object(NBSite, data={"name": site_name}, read_from_netbox=True) + vendor = inventory.add_object(NBManufacturer, data={"name": "Cisco"}, read_from_netbox=True) + device_type = inventory.add_object(NBDeviceType, data={"model": "UCSB-B200-M5", "manufacturer": vendor}, + read_from_netbox=True) + device = inventory.add_object(NBDevice, data={ + "name": name, "site": site, "device_type": device_type, "serial": serial, "status": "active", + }, read_from_netbox=True) + if mac is not None: + inventory.add_object(NBInterface, data={"name": "vmnic0", "device": device, "mac_address": mac}, + read_from_netbox=True) + return device + + +def _match_host(src, name, site_name, serial=None, mac=None): + # the host data add_host() hands over for an ESXi host; returns the device it was matched to + host_data = { + "name": name, + "device_type": {"model": "UCSB-B200-M5", "manufacturer": {"name": "Cisco"}}, + "site": {"name": site_name}, + "status": "active", + } + if serial is not None: + host_data["serial"] = serial + pnic_data = {"vmnic0": {"name": "vmnic0", "mac_address": mac}} if mac is not None else {} + before = set(src.inventory.get_all_items(NBDevice)) + src.add_device_vm_to_inventory(NBDevice, object_data=host_data, pnic_data=pnic_data, vnic_data={}, + nic_ips={}) + added = [x for x in src.inventory.get_all_items(NBDevice) if x not in before] + return added[0] if added else src.inventory.get_by_data(NBDevice, data={"serial": serial}) + + +def _setup_pooled_macs(inventory): + # two blades in different sites; only the one in site1 has the pooled MAC recorded + blade_a = _existing_host(inventory, "esxi-a", "site1", serial="SN-A", mac=POOLED_MAC) + blade_b = _existing_host(inventory, "esxi-b", "site2", serial="SN-B") + return blade_a, blade_b + + +def test_serial_wins_over_pooled_mac(inventory): + # a renamed blade in site2 shares a pooled MAC with a blade in site1, the serial must win + blade_a, blade_b = _setup_pooled_macs(inventory) + + matched = _match_host(_make_source(inventory), "esxi-b-new", "site2", serial="SN-B", mac=POOLED_MAC) + + assert matched is blade_b + assert blade_a.data.get("name") == "esxi-a" + + +def test_mac_fallback_without_serial_match(inventory): + # no serial match, MAC matching still works by default + blade_a, _ = _setup_pooled_macs(inventory) + + src = _make_source(inventory) + _match_host(src, "esxi-a-new", "site1", serial="SN-UNKNOWN", mac=POOLED_MAC) + + assert blade_a.data.get("name") == "esxi-a-new" + + +def test_disable_host_mac_matching(inventory): + # with MAC matching disabled for hosts an unknown serial creates a new device + blade_a, _ = _setup_pooled_macs(inventory) + + matched = _match_host(_make_source(inventory, disable_host_mac_matching=True), + "esxi-c", "site1", serial="SN-C", mac=POOLED_MAC) + + assert matched is not blade_a and matched.is_new + assert blade_a.data.get("name") == "esxi-a" + + +def test_match_host_by_serial_disabled(inventory): + # serial matching is skipped when disabled, even if the serial is known + _, blade_b = _setup_pooled_macs(inventory) + + src = _make_source(inventory, match_host_by_serial=False, disable_host_mac_matching=True) + _match_host(src, "esxi-b-new", "site2", serial="SN-B") + + assert blade_b.data.get("name") == "esxi-b" + + +def test_missing_serial_does_not_match_device_without_serial(inventory): + # a host reporting no serial must not be matched to an existing device that has no serial either + existing = _existing_host(inventory, "esxi-noserial", "site1") + + matched = _match_host(_make_source(inventory, disable_host_mac_matching=True), "esxi-other", "site1") + + assert matched is not existing and matched.is_new + assert existing.data.get("name") == "esxi-noserial" diff --git a/tests/test_vmware_vm_status_options.py b/tests/test_vmware_vm_status_options.py index 3736981..5befee5 100644 --- a/tests/test_vmware_vm_status_options.py +++ b/tests/test_vmware_vm_status_options.py @@ -40,6 +40,7 @@ def _make_source(inventory, status_on_create=None, status_preserve=None): src.object_cache = dict() src.settings = SimpleNamespace( match_host_by_serial=True, + disable_host_mac_matching=False, match_vm_by_serial=True, match_vm_by_mac_address=True, match_vm_by_ip_address=True,