Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion module/sources/vmware/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 31 additions & 21 deletions module/sources/vmware/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 \
Expand Down
9 changes: 8 additions & 1 deletion settings-example.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions tests/test_vmware_host_device_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
142 changes: 142 additions & 0 deletions tests/test_vmware_host_matching.py
Original file line number Diff line number Diff line change
@@ -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: <https://opensource.org/licenses/MIT>.

"""
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"
1 change: 1 addition & 0 deletions tests/test_vmware_vm_status_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading