From 75ad16f494962abc10d060789ec461ca3eae4a84 Mon Sep 17 00:00:00 2001 From: Lab Admin Date: Tue, 28 Jul 2026 16:22:52 -0500 Subject: [PATCH 1/2] fix: don't treat guest-tools-running-but-empty guest.net as authoritative IP removal Old TMOS builds (e.g. BIG-IP 11.5.0, 12.1.4.1) report VMware Tools as running but never populate per-NIC guest.net data. That caused every interface's NetBox IP assignment on those VMs to look "removed" on each sync cycle and get deleted, even though the guest is up and the IP is still live. Skip IP handling for a VM when guest.net comes back completely empty while tools report running; a real per-NIC removal still reports the NIC (just without an IP), so that case is unaffected. (cherry picked from commit 261e338ee1c7cb00d3249126338a7cba0ed8f9f6) --- module/sources/common/source_base.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/module/sources/common/source_base.py b/module/sources/common/source_base.py index b81b1b8..b93a341 100644 --- a/module/sources/common/source_base.py +++ b/module/sources/common/source_base.py @@ -429,6 +429,15 @@ def add_update_interface(self, interface_object, device_object, interface_data, if type(device_object) == NBVM and grab(vmware_object,'guest.toolsRunningStatus') != "guestToolsRunning": log.debug(f"VM '{device_object.name}' guest tool status is 'NotRunning', skipping IP handling") skip_ip_handling = True + elif type(device_object) == NBVM and len(grab(vmware_object, "guest.net", fallback=list())) == 0: + # guest tools report "running" but returned zero NICs for the whole VM -- this is a stale/ + # incompatible guest tools install (seen on old TMOS releases), not a real "all interfaces lost + # their IP" event. Trusting it would tear down otherwise-valid NetBox IP-to-interface assignments + # every sync cycle. A real per-NIC IP removal still reports the NIC (with no IP), so that case is + # unaffected by this guard. + log.debug(f"VM '{device_object.name}' guest tools running but reported zero network interfaces; " + f"skipping IP handling (stale/incompatible VMware Tools?)") + skip_ip_handling = True ip_address_objects = list() matching_ip_prefixes = list() From fbc387fa4dcb61ff4639956b7074ee5c105c0107 Mon Sep 17 00:00:00 2001 From: Sergey Sannikov Date: Thu, 10 Sep 2026 13:34:32 +0400 Subject: [PATCH 2/2] vmware: keep IPs when guest tools report no interface at all --- module/sources/common/source_base.py | 20 ++++--- tests/test_vmware_empty_guest_net.py | 89 ++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 9 deletions(-) create mode 100644 tests/test_vmware_empty_guest_net.py diff --git a/module/sources/common/source_base.py b/module/sources/common/source_base.py index b93a341..ee3ee97 100644 --- a/module/sources/common/source_base.py +++ b/module/sources/common/source_base.py @@ -429,15 +429,6 @@ def add_update_interface(self, interface_object, device_object, interface_data, if type(device_object) == NBVM and grab(vmware_object,'guest.toolsRunningStatus') != "guestToolsRunning": log.debug(f"VM '{device_object.name}' guest tool status is 'NotRunning', skipping IP handling") skip_ip_handling = True - elif type(device_object) == NBVM and len(grab(vmware_object, "guest.net", fallback=list())) == 0: - # guest tools report "running" but returned zero NICs for the whole VM -- this is a stale/ - # incompatible guest tools install (seen on old TMOS releases), not a real "all interfaces lost - # their IP" event. Trusting it would tear down otherwise-valid NetBox IP-to-interface assignments - # every sync cycle. A real per-NIC IP removal still reports the NIC (with no IP), so that case is - # unaffected by this guard. - log.debug(f"VM '{device_object.name}' guest tools running but reported zero network interfaces; " - f"skipping IP handling (stale/incompatible VMware Tools?)") - skip_ip_handling = True ip_address_objects = list() matching_ip_prefixes = list() @@ -690,6 +681,17 @@ def add_update_interface(self, interface_object, device_object, interface_data, # is still a statement that the interface was seen skip_ip_removal = keep_undiscovered_ips is True and len(interface_ips or list()) == 0 + # guest tools which report as running but hand back no interface at all are broken + # (seen on old TMOS releases), not a statement that every address is gone. Keep what is + # in NetBox instead of tearing it off on every run. A real removal still reports the + # interface, just without an address, so that case is unaffected + reported_interfaces = grab(vmware_object, "guest.net") + if type(device_object) == NBVM and isinstance(reported_interfaces, list) and \ + len(reported_interfaces) == 0: + log.debug(f"VM '{device_object.name}' guest tools report no network interface at all, " + f"keeping the addresses currently assigned in NetBox") + skip_ip_removal = True + for current_ip in interface_object.get_ip_addresses(): if skip_ip_handling is True or skip_ip_removal is True: diff --git a/tests/test_vmware_empty_guest_net.py b/tests/test_vmware_empty_guest_net.py new file mode 100644 index 0000000..6e49c35 --- /dev/null +++ b/tests/test_vmware_empty_guest_net.py @@ -0,0 +1,89 @@ +""" +VMware Tools that report as running but hand back no interfaces at all are broken, +not a statement that every IP is gone. Trusting them tears the IP assignments off a +VM on every run (from #509 by @dirtycache). +""" +from types import SimpleNamespace + +import pytest + +from module.netbox.inventory import NetBoxInventory +from module.netbox.object_classes import ( + NBCluster, NBClusterType, NBIPAddress, NBSite, NBVM, NBVMInterface, +) +from module.sources.common.source_base import SourceBase + +TOOLS_RUNNING_NO_NICS = SimpleNamespace( + guest=SimpleNamespace(toolsRunningStatus="guestToolsRunning", net=[])) +TOOLS_RUNNING_WITH_NIC = SimpleNamespace( + guest=SimpleNamespace(toolsRunningStatus="guestToolsRunning", + net=[SimpleNamespace(ipAddress=[])])) + + +@pytest.fixture +def inventory(): + def _reset(): + inv = NetBoxInventory() + inv.base_structure = {} + inv.source_list = [] + inv.init() + inv.netbox_api_version = "4.0.0" + return inv + inv = _reset() + yield inv + _reset() + + +def _source(inventory): + src = SourceBase() + src.inventory = inventory + src.name = "test" + src.source_tag = "Source: test" + src.settings = SimpleNamespace( + skip_fhrp_group_ips=False, preserve_primary_ips=False, + ip_tenant_inheritance_order=["disabled"], disable_vlan_sync=True, + vlan_group_relation_by_id=None, vlan_group_relation_by_name=None, + vlan_sync_exclude_by_id=None, vlan_sync_exclude_by_name=None, + ) + src.return_longest_matching_prefix_for_ip = lambda *a, **k: None + return src + + +def _vm_with_ip(inv): + site = inv.add_object(NBSite, data={"name": "site1"}, read_from_netbox=True) + ctype = inv.add_object(NBClusterType, data={"name": "vmware"}, read_from_netbox=True) + cluster = inv.add_object(NBCluster, data={"name": "c1", "type": ctype, "scope": site}, + read_from_netbox=True) + vm = inv.add_object(NBVM, data={"name": "vm1", "cluster": cluster, "status": "active"}, + read_from_netbox=True) + nic = inv.add_object(NBVMInterface, data={"name": "eth0", "virtual_machine": vm, "enabled": True}, + read_from_netbox=True) + ip = inv.add_object(NBIPAddress, data={ + "address": "10.0.0.5/24", + "assigned_object_type": "virtualization.vminterface", + "assigned_object_id": nic, + }, read_from_netbox=True) + return vm, nic, ip + + +def test_broken_tools_reporting_no_interfaces_keep_the_ips(inventory): + vm, nic, ip = _vm_with_ip(inventory) + + _source(inventory).add_update_interface( + interface_object=nic, device_object=vm, interface_data={"name": "eth0"}, + interface_ips=[], vmware_object=TOOLS_RUNNING_NO_NICS) + + assert "assigned_object_id" not in ip.unset_items, \ + "the VM lost its IP because the guest tools returned nothing" + + +def test_a_reported_interface_without_ips_still_removes_them(inventory): + # control: the guard must not stop a real removal, where the NIC is reported but has no IP + vm, nic, ip = _vm_with_ip(inventory) + + _source(inventory).add_update_interface( + interface_object=nic, device_object=vm, interface_data={"name": "eth0"}, + interface_ips=[], vmware_object=TOOLS_RUNNING_WITH_NIC) + + assert "assigned_object_id" in ip.unset_items, \ + "a genuinely empty interface must still drop its IP"