From f2a856a407a0b41396a30a4771a8b2799208fa00 Mon Sep 17 00:00:00 2001 From: Lab Admin Date: Wed, 17 Jun 2026 16:57:45 -0500 Subject: [PATCH 01/19] adds vm_platform_from_annotation_relation config option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allows overriding a VM's platform in NetBox based on a regex match against the vCenter annotation (notes) field. Useful when vSphere misreports the guest OS — F5 BIG-IP/BIG-IQ VE VMs identify as CentOS but carry identifying text in their annotation. Patterns are compiled with re.DOTALL and matched via re.search so they span newlines and match anywhere in the annotation without anchoring. Takes priority over vm_platform_relation when both would match. The annotation is now always read from vCenter regardless of the skip_vm_comments setting, so platform detection works even when comment syncing is disabled. --- module/sources/vmware/config.py | 45 +++++++++++++++++++++++++++++ module/sources/vmware/connection.py | 16 +++++++--- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/module/sources/vmware/config.py b/module/sources/vmware/config.py index e4d8e2bc..7e178381 100644 --- a/module/sources/vmware/config.py +++ b/module/sources/vmware/config.py @@ -166,6 +166,20 @@ def __init__(self): value: defines the desired NetBox platform name""", config_example="VMware ESXi 7.0.3 = VMware ESXi 7.0 Update 3o"), ConfigOption("vm_platform_relation", str, config_example="centos-7.* = centos7, microsoft-windows-server-2016.* = Windows2016"), + ConfigOption("vm_platform_from_annotation_relation", + str, + description="""\ + Override the platform of a VM based on the content of its vCenter + annotation (the Notes field, synced to the NetBox comments field). + Useful when vSphere misidentifies the guest OS — for example, F5 + BIG-IP/BIG-IQ Virtual Edition VMs report as CentOS but their + annotation contains product-identifying text. + This is done with a comma separated key = value list. + key: regex matched anywhere in the annotation text (re.search, + re.DOTALL — patterns span newlines automatically) + value: defines the desired NetBox platform name + Takes priority over vm_platform_relation when both match.""", + config_example="Virtual Edition.*F5 = TMOS"), ConfigOption("host_role_relation", str, description="""\ @@ -469,6 +483,37 @@ def validate_options(self): continue + if option.key == "vm_platform_from_annotation_relation": + + relation_data = list() + + for relation in quoted_split(option.value): + + object_name = relation.split("=")[0].strip(' "') + relation_name = relation.split("=")[1].strip(' "') + + if len(object_name) == 0 or len(relation_name) == 0: + log.error(f"Config option '{relation}' malformed got '{object_name}' for " + f"object name and '{relation_name}' for annotation platform name.") + self.set_validation_failed() + continue + + try: + re_compiled = re.compile(object_name, re.DOTALL) + except Exception as e: + log.error(f"Problem parsing regular expression '{object_name}' for '{relation}': {e}") + self.set_validation_failed() + continue + + relation_data.append({ + "object_regex": re_compiled, + "assigned_name": relation_name + }) + + option.set_value(relation_data) + + continue + if "relation" in option.key and "vlan_group_relation" not in option.key: relation_data = list() diff --git a/module/sources/vmware/connection.py b/module/sources/vmware/connection.py index e63763c9..02305896 100644 --- a/module/sources/vmware/connection.py +++ b/module/sources/vmware/connection.py @@ -2216,9 +2216,17 @@ def add_virtual_machine(self, obj): hardware_devices = grab(obj, "config.hardware.device", fallback=list()) - annotation = None - if self.settings.skip_vm_comments is False: - annotation = get_string_or_none(grab(obj, "config.annotation")) + # always read annotation — needed for platform detection even when skip_vm_comments is True + annotation = get_string_or_none(grab(obj, "config.annotation")) + + # override platform based on annotation content; takes priority over vm_platform_relation + if annotation is not None: + for relation in grab(self.settings, "vm_platform_from_annotation_relation", fallback=list()): + if relation.get("object_regex").search(annotation): + platform = relation.get("assigned_name") + log.debug2(f"Overriding VM platform to '{platform}' based on annotation content " + f"(pattern: '{relation.get('object_regex').pattern}')") + break # assign vm_tenant_relation tenant_name = self.get_object_relation(name, "vm_tenant_relation") @@ -2272,7 +2280,7 @@ def add_virtual_machine(self, obj): if platform is not None: vm_data["platform"] = {"name": platform} - if annotation is not None: + if annotation is not None and self.settings.skip_vm_comments is False: vm_data["comments"] = annotation if tenant_name is not None: vm_data["tenant"] = {"name": tenant_name} From 7a27cb44188a8bec88fc08700313b661f21ec8f0 Mon Sep 17 00:00:00 2001 From: Lab Admin Date: Wed, 17 Jun 2026 17:16:16 -0500 Subject: [PATCH 02/19] docs: add vm_platform_from_annotation_relation to settings-example.ini --- settings-example.ini | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/settings-example.ini b/settings-example.ini index ec2d5f1f..cb7cd66b 100644 --- a/settings-example.ini +++ b/settings-example.ini @@ -217,6 +217,19 @@ password = super-secret ;host_platform_relation = VMware ESXi 7.0.3 = VMware ESXi 7.0 Update 3o ;vm_platform_relation = centos-7.* = centos7, microsoft-windows-server-2016.* = Windows2016 +; Override the platform of a VM based on the content of its vCenter annotation (the Notes +; field, synced to the NetBox comments field). Useful when vSphere misidentifies the guest +; OS for appliance-style VMs — for example, network or security virtual appliances that run +; on a modified Linux base are reported by vSphere as the base distro (CentOS, etc.) but +; carry product-identifying text in their annotation. +; Patterns are matched anywhere in the annotation text (re.search) and span newlines +; automatically (re.DOTALL), so multi-line annotations work without special flags. +; Takes priority over vm_platform_relation when both would match. +; This is done with a comma separated key = value list. +; key: defines a regex matched against the full VM annotation content +; value: defines the desired NetBox platform name +;vm_platform_from_annotation_relation = BIG-IP Local Traffic Manager Virtual Edition.*F5 = TMOS + ; Define the NetBox device role used for hosts. The default is ; set to "Server". This is done with a comma separated key = value list. ; key: defines host(s) name as regex From 8553fd60ce9f6c50a16a32e26fd608c3bf4f5861 Mon Sep 17 00:00:00 2001 From: Lab Admin Date: Wed, 17 Jun 2026 23:16:16 -0500 Subject: [PATCH 03/19] adds vm_ip_permitted_overlapping_subnets config option Introduces a new config option that allows the same IP address to appear on multiple VM interfaces simultaneously without triggering duplicate-assignment warnings or being skipped. A common real-world scenario is isolated HA peer-to-peer VLANs where the same /30 addressing scheme is reused across many VM pairs. The IPs are unique within each link VLAN but overlap globally, causing netbox-sync's in-memory duplicate check to warn and skip the second (and subsequent) interface assignments. When an IP falls within a configured overlapping subnet, netbox-sync creates a separate NetBox IP address object per interface rather than sharing a single object. The existing duplicate-check logic for all other IPs is unchanged. --- module/sources/common/source_base.py | 18 ++++++++++++++++++ module/sources/vmware/config.py | 27 ++++++++++++++++++++++++++- settings-example.ini | 8 ++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/module/sources/common/source_base.py b/module/sources/common/source_base.py index 4d20e9d2..0fc4c062 100644 --- a/module/sources/common/source_base.py +++ b/module/sources/common/source_base.py @@ -442,8 +442,26 @@ def add_update_interface(self, interface_object, device_object, interface_data, # try to find matching IP address object this_ip_object = None skip_this_ip = False + + ip_is_overlapping = any( + ip_object.ip in subnet + for subnet in grab(self.settings, "vm_ip_permitted_overlapping_subnets", fallback=list()) + ) + + if ip_is_overlapping: + for ip in self.inventory.get_all_items(NBIPAddress): + ip_address_string = grab(ip, "data.address", fallback="") + if not ip_address_string.startswith(f"{ip_object.ip.compressed}/"): + continue + if ip.get_interface() == interface_object: + this_ip_object = ip + break + for ip in self.inventory.get_all_items(NBIPAddress): + if ip_is_overlapping: + continue + # check if address matches (without prefix length) ip_address_string = grab(ip, "data.address", fallback="") diff --git a/module/sources/vmware/config.py b/module/sources/vmware/config.py index 7e178381..65a89c24 100644 --- a/module/sources/vmware/config.py +++ b/module/sources/vmware/config.py @@ -8,7 +8,7 @@ # repository or visit: . import re -from ipaddress import ip_address +from ipaddress import ip_address, ip_network from module.common.misc import quoted_split from module.config import source_config_section_name @@ -82,6 +82,18 @@ def __init__(self): ConfigOption(**config_option_permitted_subnets_definition), + ConfigOption("vm_ip_permitted_overlapping_subnets", + str, + description="""\ + Define subnets where the same IP address may legitimately appear on + multiple VM interfaces simultaneously — for example, isolated HA + peer-to-peer links where the same /30 addressing is reused across + many VM pairs. Supply a comma-separated list of prefixes in CIDR + notation. When an IP falls within one of these subnets, netbox-sync + creates a separate NetBox IP address object per interface rather than + sharing a single object across VMs.""", + config_example="10.99.99.0/24, 192.168.200.0/24"), + ConfigOptionGroup(title="filter", description="""filters can be used to include/exclude certain objects from importing into NetBox. Include filters are checked first and exclude filters after. @@ -695,3 +707,16 @@ def validate_options(self): self.set_validation_failed() permitted_subnets_option.set_value(permitted_subnets) + + overlapping_subnets_option = self.get_option_by_name("vm_ip_permitted_overlapping_subnets") + + if overlapping_subnets_option is not None and overlapping_subnets_option.value is not None: + subnet_list = [x.strip() for x in overlapping_subnets_option.value.split(",") if x.strip() != ""] + parsed_subnets = [] + for subnet in subnet_list: + try: + parsed_subnets.append(ip_network(subnet, strict=False)) + except Exception as e: + log.error(f"Problem parsing vm_ip_permitted_overlapping_subnets entry '{subnet}': {e}") + self.set_validation_failed() + overlapping_subnets_option.set_value(parsed_subnets) diff --git a/settings-example.ini b/settings-example.ini index cb7cd66b..5c4f9df1 100644 --- a/settings-example.ini +++ b/settings-example.ini @@ -146,6 +146,14 @@ password = super-secret ; blocks a leading '!' has to be added ;permitted_subnets = 172.16.0.0/12, 10.0.0.0/8, 192.168.0.0/16, fd00::/8, !10.23.42.0/24 +; Subnets where the same IP address may legitimately appear on multiple VM interfaces +; simultaneously. A common use case is isolated HA peer-to-peer links where the same /30 +; addressing is reused across many VM pairs (the IPs are unique within each link VLAN but +; overlap globally). Supply a comma-separated list of prefixes in CIDR notation. When an +; IP falls within one of these subnets, netbox-sync creates a separate NetBox IP address +; object per interface rather than sharing a single object and emitting a duplicate warning. +;vm_ip_permitted_overlapping_subnets = 10.99.99.0/24 + ; filter options ; filters can be used to include/exclude certain objects from importing into NetBox. From da8f2ae81a23d5074ce73d0d92594d48337e6d2a Mon Sep 17 00:00:00 2001 From: Lab Admin Date: Thu, 18 Jun 2026 12:15:57 -0500 Subject: [PATCH 04/19] suppress pkg_resources DeprecationWarning from vmware-vapi-runtime vmware-vapi-runtime 2.52.0 imports pkg_resources at runtime in vmware/vapi/l10n/bundle.py. setuptools >= 81 added a DeprecationWarning to pkg_resources, causing a UserWarning to be emitted on every run when the vSphere source is configured. Suppress the specific warning at startup until the vapi stack is upgraded to 9.x, where the import is replaced with importlib.resources. --- netbox-sync.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/netbox-sync.py b/netbox-sync.py index c85de915..1168f1bf 100755 --- a/netbox-sync.py +++ b/netbox-sync.py @@ -12,6 +12,11 @@ Sync objects from various sources to NetBox """ +import warnings +# vmware-vapi-runtime 2.52.0 imports pkg_resources at runtime, which emits a +# DeprecationWarning on setuptools >= 81. Suppress it until the vapi stack is +# upgraded to 9.x where the import is replaced with importlib.resources. +warnings.filterwarnings("ignore", message="pkg_resources is deprecated", category=UserWarning) from datetime import datetime From e9fad7e19d3c3adff6a2760e91f546aeb606aed3 Mon Sep 17 00:00:00 2001 From: Adam Korab Date: Fri, 19 Jun 2026 15:19:36 -0500 Subject: [PATCH 05/19] fix: strip all whitespace (incl. newlines) from relation key/value parsing (#1) configparser joins multi-line values with \n, so relation entries on continuation lines start with a leading newline. The previous strip(' "') left that newline in the regex pattern, causing matches to fail unless the annotation itself started with a newline. Using strip() fixes multi-line *_relation config values in settings.ini. Co-authored-by: Lab Admin --- module/sources/vmware/config.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/module/sources/vmware/config.py b/module/sources/vmware/config.py index 65a89c24..3c12f65f 100644 --- a/module/sources/vmware/config.py +++ b/module/sources/vmware/config.py @@ -501,8 +501,8 @@ def validate_options(self): for relation in quoted_split(option.value): - object_name = relation.split("=")[0].strip(' "') - relation_name = relation.split("=")[1].strip(' "') + object_name = relation.split("=")[0].strip() + relation_name = relation.split("=")[1].strip() if len(object_name) == 0 or len(relation_name) == 0: log.error(f"Config option '{relation}' malformed got '{object_name}' for " @@ -534,8 +534,8 @@ def validate_options(self): for relation in quoted_split(option.value): - object_name = relation.split("=")[0].strip(' "') - relation_name = relation.split("=")[1].strip(' "') + object_name = relation.split("=")[0].strip() + relation_name = relation.split("=")[1].strip() if len(object_name) == 0 or len(relation_name) == 0: log.error(f"Config option '{relation}' malformed got '{object_name}' for " From c48b63d026d21e83f02fac1b493d72ffad407c57 Mon Sep 17 00:00:00 2001 From: Adam Korab Date: Fri, 19 Jun 2026 15:55:17 -0500 Subject: [PATCH 06/19] docs: update vm_platform_from_annotation_relation example to show BIG-IQ mapping (#2) Co-authored-by: Lab Admin --- settings-example.ini | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/settings-example.ini b/settings-example.ini index 5c4f9df1..9815a442 100644 --- a/settings-example.ini +++ b/settings-example.ini @@ -236,7 +236,9 @@ password = super-secret ; This is done with a comma separated key = value list. ; key: defines a regex matched against the full VM annotation content ; value: defines the desired NetBox platform name -;vm_platform_from_annotation_relation = BIG-IP Local Traffic Manager Virtual Edition.*F5 = TMOS +;vm_platform_from_annotation_relation = +; BIG-IP Local Traffic Manager Virtual Edition.*F5 = TMOS, +; BIG-IQ Virtual Edition.*F5 = BIG-IQ ; Define the NetBox device role used for hosts. The default is ; set to "Server". This is done with a comma separated key = value list. From 261e338ee1c7cb00d3249126338a7cba0ed8f9f6 Mon Sep 17 00:00:00 2001 From: Lab Admin Date: Tue, 28 Jul 2026 16:22:52 -0500 Subject: [PATCH 07/19] 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. --- 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 0fc4c062..b82afeaa 100644 --- a/module/sources/common/source_base.py +++ b/module/sources/common/source_base.py @@ -366,6 +366,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 2e927518b72d78a7bf282d5133968343049248a3 Mon Sep 17 00:00:00 2001 From: Lab Admin Date: Wed, 29 Jul 2026 00:48:33 -0500 Subject: [PATCH 08/19] vmware: name mgmt/data-plane interfaces natively for ACOS/TMOS VMs netbox-device-onboard.py's ACOS/TMOS collectors write interface data using each platform's own native names (e.g. 'ethernet1', '1.1', 'mgmt') and move the vNIC's MAC address onto that interface. netbox-sync's interface matching falls back to MAC address when names don't match, so on its next run it would find that MAC sitting on the differently-named interface, decide that WAS the vSphere-reported vNIC, and rename it back to 'vNIC N (...)' - clobbering the onboarder's data every 5 minutes. Fixes this at the root: for VMs whose already-resolved platform is ACOS or TMOS, name each interface using the vNIC's slot order (slot 1 = mgmt/ management, slot N = the platform's own data-plane naming) instead of the generic 'vNIC N' vSphere label, and skip appending the portgroup/VLAN name suffix for these platforms so the name matches exactly what the other tool looks up. Confirmed via direct MAC cross-check (aXAPI interface/management + interface/ethernet; iControl REST mgmt + 1.N) that vNIC slot order is stable and platform-standard across every A10 vThunder and F5 BIG-IP VE instance in this environment. Verified with a dry-run against production data: zero errors, and every 'attribute name changed' transition is confined to the acos/tmos-platform VMs (thn01a/thn01b already correctly named from prior onboarder testing show no changes at all; bigip-ve-001..007/f50xa/f50xb/ltm01a/ltm01b/ltm02a/ltm02b get a one-time rename to native names). No other VM type is affected. --- module/sources/vmware/connection.py | 55 +++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/module/sources/vmware/connection.py b/module/sources/vmware/connection.py index 02305896..2146f5b0 100644 --- a/module/sources/vmware/connection.py +++ b/module/sources/vmware/connection.py @@ -2059,6 +2059,52 @@ def add_host(self, obj): return + # NIC slot 1 is always the management interface on these platforms' standard OVA/vmx + # deployment layout; subsequent slots are sequential data-plane interfaces. Confirmed by + # direct MAC-address cross-check between vCenter's reported vNIC order and each platform's + # own interface API/CLI (aXAPI 'interface/management'+'interface/ethernet', iControl REST + # 'mgmt'+'1.N') across multiple A10 vThunder and F5 BIG-IP VE instances. + _NATIVE_VNIC_NAMES_BY_PLATFORM = { + "acos": ("management", "ethernet{}"), + "tmos": ("mgmt", "1.{}"), + } + + def _uses_native_vnic_names(self, platform): + return str(platform or "").strip().lower() in self._NATIVE_VNIC_NAMES_BY_PLATFORM + + def get_vnic_name(self, platform, int_label): + """ + Return the NetBox interface name for a vNIC: the platform's own native interface + name (e.g. 'mgmt', '1.1') for platforms in _NATIVE_VNIC_NAMES_BY_PLATFORM, otherwise + the default 'vNIC N' vSphere-generic name. + + Naming interfaces to match what the guest OS itself calls them lets other tools that + manage these VMs by their own device APIs (e.g. netbox-device-onboard.py) write to the + SAME interface object instead of creating a second, differently-named one and fighting + over which interface owns the shared MAC address on every sync cycle. + + Parameters + ---------- + platform: str, None + VM platform name as already resolved for vm_data["platform"] (e.g. "ACOS", "TMOS") + int_label: str + vSphere deviceInfo.label for this NIC (e.g. "Network adapter 3") + + Returns + ------- + str: interface name to use + """ + + slot_str = int_label.split(" ")[-1] + native_names = self._NATIVE_VNIC_NAMES_BY_PLATFORM.get(str(platform or "").strip().lower()) + + if native_names is not None and slot_str.isdigit(): + mgmt_name, data_plane_format = native_names + slot_num = int(slot_str) + return mgmt_name if slot_num == 1 else data_plane_format.format(slot_num - 1) + + return "vNIC {}".format(slot_str) + def add_virtual_machine(self, obj): """ Parse a vCenter VM add to NetBox once all data is gathered. @@ -2435,10 +2481,15 @@ def add_virtual_machine(self, obj): int_connected = grab(vm_device, "connectable.connected", fallback=False) int_label = grab(vm_device, "deviceInfo.label", fallback="") - int_name = "vNIC {}".format(int_label.split(" ")[-1]) + int_name = self.get_vnic_name(platform, int_label) int_full_name = int_name - if int_network_name is not None: + # Native platform names (e.g. "mgmt", "1.1", "ethernet1") must stay verbatim so + # other tools that manage these VMs via the guest's own API/CLI (e.g. + # netbox-device-onboard.py) look up the exact same interface name — appending the + # VLAN/portgroup name here would make the two tools create two different interfaces + # for the same physical NIC. + if int_network_name is not None and not self._uses_native_vnic_names(platform): int_full_name = f"{int_full_name} ({int_network_name})" int_description = f"{int_label} ({device_class})" From 3cd8dd0bf2f14d9f2e53c1955cac2c4b99187798 Mon Sep 17 00:00:00 2001 From: Lab Admin Date: Wed, 29 Jul 2026 01:05:32 -0500 Subject: [PATCH 09/19] vmware: don't sync vSwitch MTU onto acos/tmos native-named interfaces Confirmed live in production immediately after the previous fix (wip/platform-interface-names, PR #4) landed: with interface identity now converged between netbox-sync and netbox-device-onboard.py's ACOS/TMOS collectors, both tools write to the same interface object - and both always send an mtu value, so the two disagreed forever (vSwitch/portgroup MTU 9000 vs. the device's own reported interface MTU 1500), flipping back and forth every 5-minute sync cycle. The vSwitch's jumbo-frame capability and the guest's own configured interface MTU are different facts, not the same fact from two sources - for these two platforms the device's own API is the authoritative source netbox-device-onboard.py already treats it as, matching the reasoning behind the interface-naming fix itself. Verified via dry-run: zero mtu-related lines anywhere for acos/tmos VMs (nothing left to write), no change for any other VM type. --- module/sources/vmware/connection.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/module/sources/vmware/connection.py b/module/sources/vmware/connection.py index 2146f5b0..db61a962 100644 --- a/module/sources/vmware/connection.py +++ b/module/sources/vmware/connection.py @@ -2555,7 +2555,13 @@ def add_virtual_machine(self, obj): "enabled": int_connected, } - if int_mtu is not None and self.settings.sync_vm_interface_mtu is True: + # For acos/tmos-platform interfaces, the vSwitch/portgroup MTU (jumbo-frame + # capability of the underlying network) isn't the same fact as the guest's own + # configured interface MTU that netbox-device-onboard.py's device-API collectors + # write to the same interface object — sending both here would fight forever. + # The device's own API stays authoritative for these platforms' interfaces. + if int_mtu is not None and self.settings.sync_vm_interface_mtu is True and \ + not self._uses_native_vnic_names(platform): vm_nic_data["mtu"] = int_mtu if int_mode is not None: vm_nic_data["mode"] = int_mode From 5fe4bcd7946bfc0fb33c55b06cc19a47832015c6 Mon Sep 17 00:00:00 2001 From: Lab Admin Date: Wed, 29 Jul 2026 01:16:14 -0500 Subject: [PATCH 10/19] vmware: don't sync description or primary IP selection for acos/tmos VMs Same conflict class as the mtu fix (PR #5), found on the very next cron cycle after it deployed: interface 'description' is generated from vSphere adapter/portgroup info and gets fought over the same way mtu was (the onboarder writes the device's own interface label, e.g. 'HA_503', and netbox-sync overwrote it back to 'Network adapter N (...)' every cycle). VM-level primary IP selection has the identical problem one level up: set_primary_ip=always makes netbox-sync unconditionally overwrite primary_ip4/6 with its own default-gateway-subnet guess every run, which just relocated a BIG-IP/vThunder's primary IP away from what netbox-device-onboard.py had deliberately set as the real management address. Both are now skipped for acos/tmos VMs/interfaces, consistent with the policy from #4/#5: for these platforms, device-API-sourced data (netbox-device-onboard.py) is authoritative for any field the device's own API can report, and netbox-sync keeps only structural sync (interface existence/MAC discovery, VM lifecycle, cluster/site/tags, and any field that isn't also written by the device-side collector). Verified via dry-run: zero errors, no further description/mtu/primary_ip changes needed for the acos/tmos VMs already onboarded (thn01a, thn01b, ltm01a, bigip-ve-001), no regression to any other VM type (conditional is scoped to _uses_native_vnic_names(platform) only). --- module/sources/vmware/connection.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/module/sources/vmware/connection.py b/module/sources/vmware/connection.py index db61a962..a6e10a62 100644 --- a/module/sources/vmware/connection.py +++ b/module/sources/vmware/connection.py @@ -2551,10 +2551,15 @@ def add_virtual_machine(self, obj): "name": unquote(int_full_name), "virtual_machine": None, "mac_address": int_mac, - "description": unquote(int_description), "enabled": int_connected, } + # Same reasoning as mtu below: for acos/tmos-platform interfaces, the vSphere- + # generated description (adapter type + portgroup/VLAN) isn't the same fact as the + # device's own interface label that netbox-device-onboard.py writes here instead. + if not self._uses_native_vnic_names(platform): + vm_nic_data["description"] = unquote(int_description) + # For acos/tmos-platform interfaces, the vSwitch/portgroup MTU (jumbo-frame # capability of the underlying network) isn't the same fact as the guest's own # configured interface MTU that netbox-device-onboard.py's device-API collectors @@ -2658,6 +2663,14 @@ def add_virtual_machine(self, obj): f"VM '{name}', using it as primary IPv6.") vm_primary_ip6 = potential_primary_ipv6_list[0] + # For acos/tmos VMs, primary IP selection is netbox-device-onboard.py's job (it knows + # which interface is actually reachable/managed) - passing our own gateway-subnet guess + # here would fight it every cycle under set_primary_ip=always, the same conflict class + # as the interface-level mtu/description fields above. + if self._uses_native_vnic_names(platform): + vm_primary_ip4 = None + vm_primary_ip6 = None + # add VM to inventory self.add_device_vm_to_inventory(NBVM, object_data=vm_data, vnic_data=nic_data, nic_ips=nic_ips, p_ipv4=vm_primary_ip4, p_ipv6=vm_primary_ip6, From 5bc26914ad4e363e1afae298d74cdb2ad2ae5d7f Mon Sep 17 00:00:00 2001 From: Lab Admin Date: Wed, 29 Jul 2026 01:51:57 -0500 Subject: [PATCH 11/19] vmware: don't sync mode/tagged_vlans/untagged_vlan for acos/tmos VMs Same conflict class as mtu/description/primary_ip (PRs #5, #6), confirmed live: bigip-ve-001's 1.2 interface (a real tagged VLAN member) had its mode flipped from 'tagged' back to 'access' by netbox-sync's vSwitch/portgroup- derived write on the very next cron cycle after netbox-device-onboard.py's TMOS collector set it to 'tagged' based on the device's own VLAN membership. The vSwitch/portgroup's VLAN tag is the hypervisor's L2 view of the port, not necessarily the same fact as the device's own VLAN/trunk membership - same reasoning as the earlier fixes. Skipped for acos/tmos native-named interfaces; device-API-sourced data stays authoritative for these platforms across mode/untagged_vlan/tagged_vlans, matching mtu/description/ primary_ip. Verified via dry-run: zero errors, zero mode/vlan changes for any acos/tmos VM, no regression to any other VM type. --- module/sources/vmware/connection.py | 56 ++++++++++++++++------------- 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/module/sources/vmware/connection.py b/module/sources/vmware/connection.py index a6e10a62..a61b5406 100644 --- a/module/sources/vmware/connection.py +++ b/module/sources/vmware/connection.py @@ -2568,37 +2568,45 @@ def add_virtual_machine(self, obj): if int_mtu is not None and self.settings.sync_vm_interface_mtu is True and \ not self._uses_native_vnic_names(platform): vm_nic_data["mtu"] = int_mtu - if int_mode is not None: - vm_nic_data["mode"] = int_mode - - if int_network_vlan_ids is not None and int_mode != "tagged-all": + # Same reasoning as mtu/description above: for acos/tmos-platform interfaces, + # the vSwitch/portgroup's VLAN tag is the hypervisor's L2 view of the port, not + # necessarily the same thing as the device's own VLAN/trunk membership that + # netbox-device-onboard.py's collectors write to the same interface object. + # Confirmed live: bigip-ve-001's 1.2 (a real tagged VLAN member) had its 'mode' + # flipped tagged -> access by this vSwitch-derived write on the very next sync + # cycle after the device-side collector set it to 'tagged'. + if not self._uses_native_vnic_names(platform): + if int_mode is not None: + vm_nic_data["mode"] = int_mode - if len(int_network_vlan_ids) == 1 and int_network_vlan_ids[0] != 0: + if int_network_vlan_ids is not None and int_mode != "tagged-all": - vm_nic_data["untagged_vlan"] = { - "name": unquote(int_network_name), - "vid": int_network_vlan_ids[0], - "site": { - "name": site_name - } - } - else: - tagged_vlan_list = list() - for int_network_vlan_id in int_network_vlan_ids: + if len(int_network_vlan_ids) == 1 and int_network_vlan_ids[0] != 0: - if int_network_vlan_id == 0: - continue - - tagged_vlan_list.append({ - "name": unquote(f"{int_network_name}-{int_network_vlan_id}"), - "vid": int_network_vlan_id, + vm_nic_data["untagged_vlan"] = { + "name": unquote(int_network_name), + "vid": int_network_vlan_ids[0], "site": { "name": site_name } - }) + } + else: + tagged_vlan_list = list() + for int_network_vlan_id in int_network_vlan_ids: + + if int_network_vlan_id == 0: + continue + + tagged_vlan_list.append({ + "name": unquote(f"{int_network_name}-{int_network_vlan_id}"), + "vid": int_network_vlan_id, + "site": { + "name": site_name + } + }) - if len(tagged_vlan_list) > 0: - vm_nic_data["tagged_vlans"] = tagged_vlan_list + if len(tagged_vlan_list) > 0: + vm_nic_data["tagged_vlans"] = tagged_vlan_list nic_data[int_full_name] = vm_nic_data From ffeb7460fc2ac3b21d74229a55a02b790573c353 Mon Sep 17 00:00:00 2001 From: Lab Admin Date: Wed, 29 Jul 2026 02:35:48 -0500 Subject: [PATCH 12/19] vmware: sync identity only for acos/tmos VM interfaces, not attributes Consolidates PRs #5/#6/#7 into one policy instead of continuing to chase individual fields as they surface (enabled was the fourth one found live, after mtu/description/primary_ip and mode/tagged_vlans/untagged_vlan - netbox-sync flipped bigip-ve-001's 1.1/1.3 back to enabled=False on its very next cron cycle after the TMOS collector correctly set them enabled=True). For acos/tmos native-named interfaces, netbox-sync now sends only identity (name, mac_address - needed to create the interface and seed MAC-object/IP matching) and leaves every other attribute (enabled, description, mtu, mode, tagged_vlans, untagged_vlan) to netbox-device-onboard.py's device-API collectors entirely, including on first creation - NetBox's own field defaults apply until the device-side onboarder's first run sets the real values. This is the root fix for the whack-a-mole pattern from #5/#6/#7: device-API-sourced data is authoritative for anything the device's own API can report, for the full lifetime of these interfaces, not just for the specific fields that happened to conflict during testing. Verified via dry-run: zero errors, zero attribute changes of any kind for any acos/tmos VM (fully converged from the prior fixes), no regression to any other VM type (the gate is the same _uses_native_vnic_names(platform) check already used throughout). --- module/sources/vmware/connection.py | 35 +++++++++++++---------------- 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/module/sources/vmware/connection.py b/module/sources/vmware/connection.py index a61b5406..5a388326 100644 --- a/module/sources/vmware/connection.py +++ b/module/sources/vmware/connection.py @@ -2547,35 +2547,30 @@ def add_virtual_machine(self, obj): vm_primary_ip6 = int_ip_address + # For acos/tmos-platform interfaces, netbox-device-onboard.py's device-API + # collectors (aXAPI/iControl) manage this same interface object and are + # authoritative for it - every attribute below (enabled, description, mtu, + # mode, tagged_vlans, untagged_vlan) was, in turn, confirmed live to fight + # with whatever the device-side collector had just set, each on its own next + # 5-minute cron cycle (see PRs #5, #6, #7 for the individual field-by-field + # history). Rather than keep chasing one field at a time, netbox-sync writes + # ONLY identity for these platforms - name, mac_address (needed to create the + # interface and seed MAC-object/IP matching) - and leaves every other + # attribute to the device-side collector entirely, including on first + # creation (NetBox's own defaults apply until the device-side onboarder runs). vm_nic_data = { "name": unquote(int_full_name), "virtual_machine": None, "mac_address": int_mac, - "enabled": int_connected, } - # Same reasoning as mtu below: for acos/tmos-platform interfaces, the vSphere- - # generated description (adapter type + portgroup/VLAN) isn't the same fact as the - # device's own interface label that netbox-device-onboard.py writes here instead. if not self._uses_native_vnic_names(platform): + vm_nic_data["enabled"] = int_connected vm_nic_data["description"] = unquote(int_description) - # For acos/tmos-platform interfaces, the vSwitch/portgroup MTU (jumbo-frame - # capability of the underlying network) isn't the same fact as the guest's own - # configured interface MTU that netbox-device-onboard.py's device-API collectors - # write to the same interface object — sending both here would fight forever. - # The device's own API stays authoritative for these platforms' interfaces. - if int_mtu is not None and self.settings.sync_vm_interface_mtu is True and \ - not self._uses_native_vnic_names(platform): - vm_nic_data["mtu"] = int_mtu - # Same reasoning as mtu/description above: for acos/tmos-platform interfaces, - # the vSwitch/portgroup's VLAN tag is the hypervisor's L2 view of the port, not - # necessarily the same thing as the device's own VLAN/trunk membership that - # netbox-device-onboard.py's collectors write to the same interface object. - # Confirmed live: bigip-ve-001's 1.2 (a real tagged VLAN member) had its 'mode' - # flipped tagged -> access by this vSwitch-derived write on the very next sync - # cycle after the device-side collector set it to 'tagged'. - if not self._uses_native_vnic_names(platform): + if int_mtu is not None and self.settings.sync_vm_interface_mtu is True: + vm_nic_data["mtu"] = int_mtu + if int_mode is not None: vm_nic_data["mode"] = int_mode From ebd1c51e15d17ae8815976d43ee5d9335b1b34f0 Mon Sep 17 00:00:00 2001 From: Lab Admin Date: Wed, 29 Jul 2026 08:13:56 -0500 Subject: [PATCH 13/19] vmware: add Alteon ADC to identity-only interface sync policy Extends the acos/tmos policy from PRs #4-#8 to a third platform ahead of the upcoming netbox-device-onboard.py Alteon collector, instead of discovering the same MAC-ownership/attribute-oscillation cascade again. Native naming: 'mgmt' for the management interface, bare port number ('1'/'2'/'3') for data ports - confirmed via MAC cross-check (hwMACAddress matches vNIC 1 on both alt01a/alt01b) and VLAN-bitmap cross-check against Alteon's own PortInfoTable port numbering, same slot-1-is-mgmt convention as ACOS/TMOS. Verified via dry-run: zero errors, alt01a/alt01b get a one-time rename from vNIC N to native names with no other attribute changes (comprehensive identity-only policy from PR #8 applies automatically), no change to any acos/tmos/other VM. --- module/sources/vmware/connection.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/module/sources/vmware/connection.py b/module/sources/vmware/connection.py index 5a388326..7ba8724d 100644 --- a/module/sources/vmware/connection.py +++ b/module/sources/vmware/connection.py @@ -2063,10 +2063,12 @@ def add_host(self, obj): # deployment layout; subsequent slots are sequential data-plane interfaces. Confirmed by # direct MAC-address cross-check between vCenter's reported vNIC order and each platform's # own interface API/CLI (aXAPI 'interface/management'+'interface/ethernet', iControl REST - # 'mgmt'+'1.N') across multiple A10 vThunder and F5 BIG-IP VE instances. + # 'mgmt'+'1.N', AlteonOS REST 'hwMACAddress'+'PortInfoTable' Indx) across multiple A10 + # vThunder, F5 BIG-IP VE, and Radware Alteon VA instances. _NATIVE_VNIC_NAMES_BY_PLATFORM = { "acos": ("management", "ethernet{}"), "tmos": ("mgmt", "1.{}"), + "alteon adc": ("mgmt", "{}"), } def _uses_native_vnic_names(self, platform): From aad00701a822d23fe3c13ea1a823ab98b42f71f1 Mon Sep 17 00:00:00 2001 From: Lab Admin Date: Wed, 29 Jul 2026 10:37:58 -0500 Subject: [PATCH 14/19] source_base: guard IP teardown per-interface, not just whole-VM Extends the guest.net-empty guard from PR #3 (dirtycache/netbox-sync): that guard only catches a completely empty guest.net for the whole VM, but an old/flaky TMOS install can report SOME interfaces in guest.net on a given cycle while omitting one specific NIC's MAC entirely - confirmed live on bigip-ve-001, where the mgmt interface's IP was torn down (and its primary_ip4 cleared) even though guest.net wasn't totally empty that cycle, exactly the same false-teardown failure mode as PR #3 but at per-NIC granularity instead of whole-VM. A real per-NIC IP removal still reports that NIC's MAC (with an empty IP list) - total absence of the MAC itself from guest.net just means guest tools didn't report on this NIC this cycle, not a genuine removal. Verified via dry-run: zero errors, zero "no longer assigned" teardown events, normal VMs' primary IP assignment unaffected. Could not force- reproduce the exact intermittent condition live (bigip-ve-001's guest.net currently reports 0 entries total, which the existing whole-VM guard already covers) - this fix is verified by code review and regression- safety, not by reproducing the original failure on demand. --- module/sources/common/source_base.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/module/sources/common/source_base.py b/module/sources/common/source_base.py index b82afeaa..2b5600f0 100644 --- a/module/sources/common/source_base.py +++ b/module/sources/common/source_base.py @@ -16,6 +16,7 @@ from module.netbox import * from module.common.logging import get_logger from module.common.misc import grab +from module.common.support import normalize_mac_address log = get_logger() @@ -375,6 +376,25 @@ def add_update_interface(self, interface_object, device_object, interface_data, 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 + elif type(device_object) == NBVM and interface_mac_address is not None: + # Same reasoning as the whole-VM guard above, but per-interface: on some guest-tools + # cycles an old/flaky TMOS install reports SOME interfaces in guest.net but omits one + # specific NIC's MAC entirely (confirmed live: bigip-ve-001's mgmt interface lost its + # IP even though guest.net wasn't totally empty that cycle) - the whole-VM guard above + # only catches a fully-empty guest.net, not this narrower case. A real per-NIC IP + # removal still reports the NIC's MAC (with an empty IP list); total absence of the MAC + # itself means guest tools simply didn't report on this NIC this cycle, not a genuine + # removal. + reported_macs = { + normalize_mac_address(grab(g, "macAddress")) + for g in grab(vmware_object, "guest.net", fallback=list()) + if grab(g, "macAddress") is not None + } + if normalize_mac_address(interface_mac_address) not in reported_macs: + log.debug(f"VM '{device_object.name}' interface with MAC '{interface_mac_address}' not present " + "in this cycle's guest.net at all; skipping IP handling for this interface " + "(stale/incomplete VMware Tools report?)") + skip_ip_handling = True ip_address_objects = list() matching_ip_prefixes = list() From e189653652438ed76f9814c7bfe13fe2839c87b4 Mon Sep 17 00:00:00 2001 From: Lab Admin Date: Wed, 29 Jul 2026 12:18:45 -0500 Subject: [PATCH 15/19] vmware source: stop reconciling guest.net IPs on acos/tmos/alteon data interfaces Confirmed live: netbox-sync's own guest.net-derived IP handling was fighting netbox-device-onboard.py's self-IP writes on these platforms' data-plane interfaces every 5-minute cron cycle - either tearing the address off a real vNIC guest.net never reports it on (alt01a), or reparenting it back onto the real vNIC from the device-side collector's synthetic VLAN interface (ltm01a). The existing identity-only-sync policy only covered interface attributes, not IP-to-interface ownership. Extends the same per-platform policy: a new _skip_ip_reconciliation flag (smuggled through interface_data the same way untagged_vlan/tagged_vlans already are) tells add_update_interface() to leave a data interface's IP assignment alone entirely, for both additions and removals. mgmt keeps syncing from guest.net as before - that path has never conflicted. Also touch (re-source, no data change) any IP left alone this way, so prune_data()'s 30-day orphan-delete doesn't reclaim an address a device-side collector is actively managing just because this source stopped claiming it. --- module/sources/common/source_base.py | 19 ++++++++++++++++++- module/sources/vmware/connection.py | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/module/sources/common/source_base.py b/module/sources/common/source_base.py index 2b5600f0..8da1c794 100644 --- a/module/sources/common/source_base.py +++ b/module/sources/common/source_base.py @@ -311,6 +311,11 @@ def add_update_interface(self, interface_object, device_object, interface_data, if len(tagged_vlans) > 0: del interface_data["tagged_vlans"] + # a source (currently only vmware/connection.py, for acos/tmos/alteon data-plane + # interfaces) can mark an interface as fully owned elsewhere for IP-address purposes - + # skip both adding and removing IP assignments on it entirely + force_skip_ip_handling = interface_data.pop("_skip_ip_reconciliation", False) is True + # get device tenant device_tenant = grab(device_object, "data.tenant") @@ -364,7 +369,11 @@ def add_update_interface(self, interface_object, device_object, interface_data, # skip handling of IPs for VMs with not installed/running guest tools skip_ip_handling = False - if type(device_object) == NBVM and grab(vmware_object,'guest.toolsRunningStatus') != "guestToolsRunning": + if force_skip_ip_handling is True: + log.debug(f"Interface '{interface_object.get_display_name()}' is marked as fully owned elsewhere for " + "IP-address purposes by its source; skipping IP handling for this interface") + skip_ip_handling = True + elif 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: @@ -643,6 +652,14 @@ def add_update_interface(self, interface_object, device_object, interface_data, for current_ip in interface_object.get_ip_addresses(): if skip_ip_handling is True: + if force_skip_ip_handling is True: + # We're intentionally leaving this IP's assignment alone (a device-side + # collector owns it), but still need to mark it as "seen" this cycle - + # otherwise it stops being claimed by any source and netbox-sync's own + # orphan-pruning (module/netbox/connection.py prune_data(), 30-day + # default delay here) will tag it Orphaned and eventually delete it out + # from under the collector that's actively managing it. + current_ip.update(data={}, source=self) continue if grab(current_ip, "data.role.value") == "anycast": diff --git a/module/sources/vmware/connection.py b/module/sources/vmware/connection.py index 7ba8724d..a540ad6b 100644 --- a/module/sources/vmware/connection.py +++ b/module/sources/vmware/connection.py @@ -2566,6 +2566,24 @@ def add_virtual_machine(self, obj): "mac_address": int_mac, } + # Same identity-only reasoning as above, extended to IP-address ownership: for + # these platforms' DATA-plane interfaces (not mgmt - its IP has always come from + # guest.net without conflict), netbox-device-onboard.py's device-API collectors + # are authoritative for self-IP-to-interface assignment too. guest.net reports + # these addresses against the real vNIC's MAC regardless of whether the device- + # side collector binds the address to that same physical interface (acos/alteon) + # or to a separate synthetic VLAN-interface object (tmos) - confirmed live to + # fight over ownership of the address every 5-minute cron cycle either way + # (alt01a: netbox-sync tore the address back off a real vNIC guest.net never + # reports it on; ltm01a: netbox-sync kept reparenting it from the synthetic VLAN + # interface back onto the real vNIC). _skip_ip_reconciliation is a private, + # popped-before-write flag (same smuggling pattern as untagged_vlan/tagged_vlans + # above) telling add_update_interface() to leave this interface's IP assignment + # (both additions and removals) alone entirely. + native_names = self._NATIVE_VNIC_NAMES_BY_PLATFORM.get(str(platform or "").strip().lower()) + if native_names is not None and int_full_name != native_names[0]: + vm_nic_data["_skip_ip_reconciliation"] = True + if not self._uses_native_vnic_names(platform): vm_nic_data["enabled"] = int_connected vm_nic_data["description"] = unquote(int_description) From 17488fda27c04fc031bfa7f5ffbd2a17970962bf Mon Sep 17 00:00:00 2001 From: Lab Admin Date: Thu, 30 Jul 2026 15:12:48 -0500 Subject: [PATCH 16/19] vmware source: keep managing mode/vlan for acos vNICs, unlike tmos/alteon ACOS in this environment runs in routed mode with zero device-side VLAN awareness (confirmed live: aXAPI's network/vlan table is empty on every ACOS target onboarded so far) - the vSphere portgroup is the only available source of truth for which VLAN an ethernet port sits on. tmos/alteon are different: they have real device-side VLAN data that netbox-device-onboard.py already collects and manages, which is why the existing identity-only policy correctly leaves mode/untagged_vlan/ tagged_vlans alone for those two. Applying the same blanket policy to acos left its VLAN data permanently frozen at whatever it was before the policy existed, with no source updating it going forward. acos's other attributes (enabled/description/mtu) are still device-collected and stay identity-only/hands-off, same as before - only mode/untagged_vlan/ tagged_vlans get the carve-out. --- module/sources/vmware/connection.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/module/sources/vmware/connection.py b/module/sources/vmware/connection.py index a540ad6b..d6c3bcfd 100644 --- a/module/sources/vmware/connection.py +++ b/module/sources/vmware/connection.py @@ -2591,6 +2591,17 @@ def add_virtual_machine(self, obj): if int_mtu is not None and self.settings.sync_vm_interface_mtu is True: vm_nic_data["mtu"] = int_mtu + # ACOS in this environment runs in routed mode with zero device-side VLAN + # awareness (confirmed live: aXAPI's network/vlan table is empty on every + # ACOS target onboarded so far) - the vSphere portgroup is the only + # available source of truth for which VLAN an ethernet port sits on, unlike + # tmos/alteon which do have real device-side VLAN data that + # netbox-device-onboard.py collects and manages instead. So mode/ + # untagged_vlan/tagged_vlans stay netbox-sync-managed for acos specifically, + # even though its other attributes (enabled/description/mtu, handled above) + # remain identity-only/device-authoritative like the other two platforms. + if not self._uses_native_vnic_names(platform) or str(platform or "").strip().lower() == "acos": + if int_mode is not None: vm_nic_data["mode"] = int_mode From 15c514797340c64c92373439f511583ff5c9b220 Mon Sep 17 00:00:00 2001 From: Adam Korab Date: Mon, 31 Aug 2026 15:57:22 -0500 Subject: [PATCH 17/19] Sync derived VCSA metadata to NetBox --- module/sources/vmware/connection.py | 70 +++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/module/sources/vmware/connection.py b/module/sources/vmware/connection.py index d6c3bcfd..bd8de9cb 100644 --- a/module/sources/vmware/connection.py +++ b/module/sources/vmware/connection.py @@ -117,6 +117,13 @@ def __init__(self, name=None): self.create_api_session() + # Derive VCSA metadata from the connected vCenter source. The source + # FQDN is the deterministic correlation key; these values must not be + # duplicated in settings.ini. + self.vcsa_source_fqdn = self._normalise_fqdn(self.settings.host_fqdn) + self.vcsa_version = get_string_or_none(grab(self.session, "about.version")) + self.vpxd_cert_mode = self._get_vpxd_cert_mode() + self.init_successful = True # instantiate source specific vars @@ -135,6 +142,66 @@ def __init__(self, name=None): self.objects_to_reevaluate = list() self.parsing_objects_to_reevaluate = False + @staticmethod + def _normalise_fqdn(value): + if value is None: + return None + return str(value).strip().rstrip(".").lower() + + def _get_vpxd_cert_mode(self): + """Read the live, vCenter-wide certificate-management mode.""" + try: + for option in self.session.setting.QueryOptions() or []: + if grab(option, "key") == "vpxd.certmgmt.mode": + return get_string_or_none(grab(option, "value")) + except Exception as e: + log.warning(f"Unable to read vpxd.certmgmt.mode from vCenter '{self.name}': {e}") + return None + + def _is_vcsa_vm(self, vm_name): + """Match only the VCSA VM for this source; do not infer by platform.""" + vm_fqdn = self._normalise_fqdn(vm_name) + if vm_fqdn is None or self.vcsa_source_fqdn is None: + return False + return vm_fqdn in { + self.vcsa_source_fqdn, + self.vcsa_source_fqdn.split(".", 1)[0] + } + + def _get_vcsa_custom_fields(self, vm_name): + object_type = "virtualization.virtualmachine" + fields = {} + + field = self.add_update_custom_field({ + "name": "is_vcsa", "label": "IS_VCSA", + "object_types": [object_type], "type": "boolean", + "description": f"Whether this VM is the VCSA for source '{self.name}'" + }) + is_vcsa = self._is_vcsa_vm(vm_name) + fields[grab(field, "data.name")] = is_vcsa + + if not is_vcsa: + return fields + + if self.vcsa_version is not None: + field = self.add_update_custom_field({ + "name": "vcsa_version", "label": "VCSA_VERSION", + "object_types": [object_type], "type": "text", + "description": f"VCSA version reported by source '{self.name}'" + }) + fields[grab(field, "data.name")] = self.vcsa_version + + if self.vpxd_cert_mode is not None: + field = self.add_update_custom_field({ + "name": "vpxd_cert_mode", "label": "VPXD_CERT_MODE", + "object_types": [object_type], "type": "select", + "choices": ["vmca", "custom", "thumbprint"], + "description": f"vpxd.certmgmt.mode reported by source '{self.name}'" + }) + fields[grab(field, "data.name")] = self.vpxd_cert_mode + + return fields + def create_sdk_session(self): """ Initialize SDK session with vCenter @@ -2337,6 +2404,9 @@ def add_virtual_machine(self, obj): # add custom fields if present and configured vm_custom_fields = self.get_object_custom_fields(obj) + # Source-derived VCSA metadata is authoritative for the matching + # appliance VM and is merged after generic vCenter attributes. + vm_custom_fields.update(self._get_vcsa_custom_fields(name)) if len(vm_custom_fields) > 0: vm_data["custom_fields"] = vm_custom_fields From e29662d837e7ba3ef283446c2d17d26dad43c769 Mon Sep 17 00:00:00 2001 From: Lab Admin Date: Wed, 2 Sep 2026 14:34:56 -0500 Subject: [PATCH 18/19] vmware: DNS-name and fallback-VLAN based primary IPv4 selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor VM primary-IPv4 determination into a 3-tier get_vm_primary_ip4(): 1. vm_primary_ip4_by_dns_name (new, opt-in, default off): forward-resolve the VM's synced name and prefer an interface IP that matches its A record. Helps appliances that default-route out a data-plane interface instead of the management interface. 2. default-gateway subnet match — existing behavior, unchanged when the new options are unset. 3. vm_primary_ip4_fallback_vlans (new): ordered last-resort mgmt-VLAN list used only when neither DNS name nor default gateway yields a primary. Adds perform_forward_lookups()/forward_lookup() to common/support.py (async aiodns A-record resolution mirroring reverse_lookup) and registers both new VMware source options in config.py with parsing/validation. No behavior change unless the new options are configured. --- docs/source_vmware.md | 9 +- module/common/support.py | 90 +++++++++ module/sources/vmware/config.py | 41 +++- module/sources/vmware/connection.py | 291 +++++++++++++++++++++++++++- settings-example.ini | 15 ++ 5 files changed, 435 insertions(+), 11 deletions(-) diff --git a/docs/source_vmware.md b/docs/source_vmware.md index 11f1d03e..d1039604 100644 --- a/docs/source_vmware.md +++ b/docs/source_vmware.md @@ -103,7 +103,14 @@ First VM is filtered: Then all necessary VM data will be collected:
platform, virtual interfaces, virtual cpu/disk/memory interface VLANs, IP addresses -Primary IPv4/6 will be determined by interface that provides the default route for this VM. +Primary IPv6 will be determined by the interface that provides the default route for this VM. + +Primary IPv4 is determined by a tiered fallback, first match wins: +1. an interface IP matches the DNS (A record) name of the VM (`vm_primary_ip4_by_dns_name`) +2. interface with this IP provides the default route for this VM +3. an interface IP sits on a VLAN listed in `vm_primary_ip4_fallback_vlans` (tried in order) + +An IP that falls within `vm_ip_permitted_overlapping_subnets` is never eligible for any tier. **Note:**
IP address information can only be extracted if guest tools are installed and running. diff --git a/module/common/support.py b/module/common/support.py index 6fea6464..8d161ead 100644 --- a/module/common/support.py +++ b/module/common/support.py @@ -8,6 +8,8 @@ # repository or visit: . import asyncio +import socket +from ipaddress import ip_address import aiodns @@ -118,4 +120,92 @@ async def reverse_lookup(resolver, ip): return {ip: resolved_name} + +def perform_forward_lookups(names, dns_servers=None): + """ + Perform DNS forward (A record) lookups for host names + + Parameters + ---------- + names: list + a list of host names to look up + dns_servers: list + a list of DNS servers to use to look up list of host names + + Returns + ------- + dict: of {"name": ["ip", ...]} for requested names, list will be empty if nothing was resolved + """ + + loop = asyncio.get_event_loop() + + resolver = aiodns.DNSResolver(loop=loop) + + if dns_servers is not None: + if isinstance(dns_servers, list): + log.debug2("using provided DNS servers to perform lookup: %s" % ", ".join(dns_servers)) + resolver.nameservers = dns_servers + else: + log.error(f"List of provided DNS servers invalid: {dns_servers}") + + queue = asyncio.gather(*(forward_lookup(resolver, name) for name in names)) + results = loop.run_until_complete(queue) + + # return dictionary instead of a list of dictionaries + return {k: v for x in results for k, v in x.items()} + + +async def forward_lookup(resolver, name): + """ + Perform actual forward lookup + + Parameters + ---------- + resolver: aiodns.DNSResolver + handler to DNS resolver + name: str + host name to look up + + Returns + ------- + dict: of {"name": ["ip", ...]} for requested name, list will be empty if nothing was resolved + """ + + valid_hostname_characters = "abcdefghijklmnopqrstuvwxyz0123456789-." + + resolved_ips = list() + response = None + + if name is None or len(f"{name}") == 0: + return dict() + + # validate name to check if this is a valid host name before querying it + if not all([bool(str(c).lower() in valid_hostname_characters) for c in name]): + log.warning(f"Host name contains invalid characters, skipping A record lookup: {name}") + return {name: resolved_ips} + + log.debug2(f"Requesting A record: {name}") + + try: + # getaddrinfo (instead of the deprecated query()/gethostbyname()) also honors + # the resolver's search list and /etc/hosts, which helps when VM names are + # synced without their domain suffix (see 'strip_vm_domain_name') + response = await resolver.getaddrinfo(name, socket.AF_INET) + except aiodns.error.DNSError as err: + log.debug("Unable to find an A record for %s: %s", name, err.args[1]) + + for node in getattr(response, "nodes", None) or list(): + node_address = (getattr(node, "addr", None) or (None,))[0] + if isinstance(node_address, bytes): + node_address = node_address.decode() + try: + resolved_ips.append(str(ip_address(node_address))) + except ValueError: + log.warning(f"A record for '{name}' returned an invalid IP address: {node_address}") + + if len(resolved_ips) > 0: + log.debug2("A record(s) for %s: %s" % (name, ", ".join(resolved_ips))) + + return {name: resolved_ips} + # EOF diff --git a/module/sources/vmware/config.py b/module/sources/vmware/config.py index 3c12f65f..80ebcf57 100644 --- a/module/sources/vmware/config.py +++ b/module/sources/vmware/config.py @@ -264,6 +264,27 @@ def __init__(self): as "when-undefined" """, default_value="when-undefined"), + ConfigOption("vm_primary_ip4_by_dns_name", + bool, + description="""\ + Resolve the VM's name (as it will be synced to NetBox) via DNS and, if + the A record matches one of the IP addresses discovered on the VM's + interfaces, prefer it as the primary IPv4 address. This is tried before + the built-in default-gateway based detection and helps with appliances + that route their default traffic out a data-plane interface instead of + the management interface. + """, + default_value=False), + ConfigOption("vm_primary_ip4_fallback_vlans", + str, + description="""\ + Comma separated, ordered list of VLAN IDs used as a last resort to + determine a VM's primary IPv4 address if neither its DNS name (see + 'vm_primary_ip4_by_dns_name' above) nor its default gateway could be + used. The first VLAN in the list that has an IPv4 address on one of the + VM's interfaces wins. Usually points at a management VLAN. + """, + config_example="1370"), ConfigOption("skip_vm_comments", bool, description="Do not sync notes from a VM in vCenter to the comments field on a VM in netbox", @@ -584,8 +605,14 @@ def validate_options(self): if option.key == "custom_dns_servers": dns_name_lookup = self.get_option_by_name("dns_name_lookup") + vm_primary_ip4_by_dns_name = self.get_option_by_name("vm_primary_ip4_by_dns_name") + + dns_lookup_needed = any([ + isinstance(dns_name_lookup, ConfigOption) and dns_name_lookup.value is True, + isinstance(vm_primary_ip4_by_dns_name, ConfigOption) and vm_primary_ip4_by_dns_name.value is True + ]) - if not isinstance(dns_name_lookup, ConfigOption) or dns_name_lookup.value is False: + if dns_lookup_needed is False: continue custom_dns_servers = quoted_split(option.value) @@ -720,3 +747,15 @@ def validate_options(self): log.error(f"Problem parsing vm_ip_permitted_overlapping_subnets entry '{subnet}': {e}") self.set_validation_failed() overlapping_subnets_option.set_value(parsed_subnets) + + fallback_vlans_option = self.get_option_by_name("vm_primary_ip4_fallback_vlans") + + if fallback_vlans_option is not None and fallback_vlans_option.value is not None: + parsed_vlan_ids = list() + for vlan_id in quoted_split(fallback_vlans_option.value) or list(): + try: + parsed_vlan_ids.append(int(vlan_id)) + except ValueError: + log.error(f"Problem parsing vm_primary_ip4_fallback_vlans entry '{vlan_id}', must be an integer") + self.set_validation_failed() + fallback_vlans_option.set_value(parsed_vlan_ids) diff --git a/module/sources/vmware/connection.py b/module/sources/vmware/connection.py index bd8de9cb..d6cfaef8 100644 --- a/module/sources/vmware/connection.py +++ b/module/sources/vmware/connection.py @@ -30,7 +30,7 @@ from module.sources.vmware.config import VMWareConfig from module.common.logging import get_logger, DEBUG3 from module.common.misc import grab, dump, get_string_or_none, plural, quoted_split -from module.common.support import normalize_mac_address +from module.common.support import normalize_mac_address, perform_forward_lookups from module.netbox.inventory import NetBoxInventory from module.netbox import * @@ -141,6 +141,8 @@ def __init__(self, name=None): self.parsing_vms_the_first_time = True self.objects_to_reevaluate = list() self.parsing_objects_to_reevaluate = False + # cache of resolved VM name -> [ip, ...] A records, populated once per sync run + self.vm_dns_lookup_cache = dict() @staticmethod def _normalise_fqdn(value): @@ -454,6 +456,9 @@ def apply(self): self.parsing_vms_the_first_time = False log.debug("Iterating over all virtual machines a second time ") + if view_details.get("view_type") == vim.VirtualMachine: + self.resolve_vm_dns_names(view_objects) + for obj in view_objects: if log.level == DEBUG3: @@ -481,6 +486,63 @@ def apply(self): self.update_basic_data() + def resolve_vm_dns_names(self, vm_objects): + """ + Bulk-resolve A records for a list of VMs and populate 'vm_dns_lookup_cache' with the + results. Called once per vCenter view so 'get_vm_dns_ips' below never has to perform a + blocking per-VM lookup while VMs are being processed. + + Only does anything if 'vm_primary_ip4_by_dns_name' is enabled. + + Parameters + ---------- + vm_objects: list of vim.VirtualMachine + VMs to resolve names for + """ + + if self.settings.vm_primary_ip4_by_dns_name is not True: + return + + vm_names = list() + for vm_object in vm_objects or list(): + + vm_name = get_string_or_none(grab(vm_object, "name")) + + if vm_name is not None and self.settings.strip_vm_domain_name is True: + vm_name = vm_name.split(".")[0] + + if vm_name is not None and vm_name not in vm_names and vm_name not in self.vm_dns_lookup_cache: + vm_names.append(vm_name) + + if len(vm_names) == 0: + return + + log.debug(f"Resolving DNS A record{plural(len(vm_names))} for {len(vm_names)} VM name{plural(len(vm_names))}") + + self.vm_dns_lookup_cache.update(perform_forward_lookups(vm_names, self.settings.custom_dns_servers)) + + def get_vm_dns_ips(self, vm_name): + """ + Return the resolved IPv4 addresses for a VM name from 'vm_dns_lookup_cache', performing + the lookup on demand if it wasn't resolved by 'resolve_vm_dns_names' already (e.g. VMs + picked up via 'objects_to_reevaluate'). + + Parameters + ---------- + vm_name: str + VM name to look up + + Returns + ------- + list: of resolved IPv4 addresses as strings, empty list if none were found + """ + + if vm_name not in self.vm_dns_lookup_cache: + self.vm_dns_lookup_cache.update( + perform_forward_lookups([vm_name], self.settings.custom_dns_servers)) + + return self.vm_dns_lookup_cache.get(vm_name) or list() + @staticmethod def passes_filter(name, include_filter, exclude_filter): """ @@ -1302,6 +1364,50 @@ def add_device_vm_to_inventory(self, object_type, object_data, pnic_data=None, v f"'{device_vm_object.get_display_name()}'") device_vm_object.update(data={f"primary_ip{ip_version}": ip_object}) + # some interfaces are not touched this run (e.g. a VM whose guest tools are running but + # not reporting any guest.net data, see 'skip_ip_handling' in source_base.py) so their + # IP address objects never end up in 'ip_address_objects' above and the assignment loop + # never sees them. Fall back to an existing NetBox IP address already assigned to one of + # this object's OWN interfaces that matches the requested primary IP, applying the same + # 'set_primary_ip' semantics as the loop above. + for ip_version, wanted_ip_interface in ((4, primary_ipv4_object), (6, primary_ipv6_object)): + + if wanted_ip_interface is None: + continue + + current_primary_ip_object = grab(device_vm_object, f"data.primary_ip{ip_version}") + + existing_ip_object = None + for ip_object in self.inventory.get_all_items(NBIPAddress): + + if ip_object.get_device_vm() is not device_vm_object: + continue + + # noinspection PyBroadException + try: + if ip_interface(grab(ip_object, "data.address")) != wanted_ip_interface: + continue + except Exception: + continue + + existing_ip_object = ip_object + break + + # nothing found, or already set to the desired IP + if existing_ip_object is None or existing_ip_object is current_primary_ip_object: + continue + + set_this_primary_ip = False + if self.settings.set_primary_ip == "always": + set_this_primary_ip = True + elif self.settings.set_primary_ip != "never" and current_primary_ip_object is None: + set_this_primary_ip = True + + if set_this_primary_ip is True: + log.debug(f"Setting IP '{grab(existing_ip_object, 'data.address')}' as primary IPv{ip_version} " + f"for '{device_vm_object.get_display_name()}' (existing assignment untouched this run)") + device_vm_object.update(data={f"primary_ip{ip_version}": existing_ip_object}) + return def get_parent_object_by_class(self, obj, object_class_to_find): @@ -2190,7 +2296,10 @@ def add_virtual_machine(self, obj): Then all necessary VM data will be collected. platform, virtual interfaces, virtual cpu/disk/memory interface VLANs, IP addresses - Primary IPv4/6 will be determined by interface that provides the default route for this VM + Primary IPv6 will be determined by interface that provides the default route for this VM. + + Primary IPv4 is determined by 'get_vm_primary_ip4' using a tiered fallback (DNS name, + then default route, then a configured fallback VLAN). Note: IP address information can only be extracted if guest tools are installed and running. @@ -2605,14 +2714,11 @@ def add_virtual_machine(self, obj): nic_ips[int_full_name].append(int_ip_address) - # check if primary gateways are in the subnet of this IP address - # if it matches IP gets chosen as primary IP - if vm_default_gateway_ip4 is not None and \ - vm_default_gateway_ip4 in ip_interface(int_ip_address).network and \ - vm_primary_ip4 is None: - - vm_primary_ip4 = int_ip_address + # IPv4 primary IP selection happens after this loop, in 'get_vm_primary_ip4', + # once all interfaces and their VLANs are known (see tiered fallback there) + # check if the default IPv6 gateway is in the subnet of this address + # if it matches, IP gets chosen as primary IPv6 if vm_default_gateway_ip6 is not None and \ vm_default_gateway_ip6 in ip_interface(int_ip_address).network and \ vm_primary_ip6 is None: @@ -2747,6 +2853,10 @@ def add_virtual_machine(self, obj): nic_data[int_full_name] = vm_nic_data + # determine primary IPv4 address using the tiered fallback (DNS name, default route, + # configured fallback VLAN); see 'get_vm_primary_ip4' for details + vm_primary_ip4 = self.get_vm_primary_ip4(name, nic_data, nic_ips, vm_default_gateway_ip4) + # if VM has only one IPv6 on all interfaces, use it as primary IPv6 address if vm_primary_ip6 is None or True: all_ips = [y for xs in nic_ips.values() for y in xs] @@ -2782,6 +2892,169 @@ def add_virtual_machine(self, obj): return + def get_existing_vm_ipv4_candidates(self, vm_name, overlapping_subnets): + """ + Return eligible IPv4 addresses NetBox already has assigned to this VM's interfaces + from a previous sync. Used only as tier-1 (DNS) candidates when vCenter's live data + for this run yields none at all (e.g. guest tools reporting but not populating + guest.net) -- see 'get_vm_primary_ip4'. + + Parameters + ---------- + vm_name: str + name of the VM as it will be synced to NetBox + overlapping_subnets: list + parsed 'vm_ip_permitted_overlapping_subnets' networks to exclude + + Returns + ------- + list: of (interface name, "ip/prefixlen", ip_interface) tuples + """ + + existing_vm_object = None + for vm_object in self.inventory.get_all_items(NBVM): + if grab(vm_object, "data.name") == vm_name: + existing_vm_object = vm_object + break + + if existing_vm_object is None: + return list() + + candidates = list() + for ip_object in self.inventory.get_all_items(NBIPAddress): + + if ip_object.get_device_vm() is not existing_vm_object: + continue + + # noinspection PyBroadException + try: + ip_interface_object = ip_interface(grab(ip_object, "data.address")) + except Exception: + continue + + if ip_interface_object.version != 4: + continue + + if any(ip_interface_object.ip in subnet for subnet in overlapping_subnets): + continue + + interface_object = ip_object.get_interface() + int_name = grab(interface_object, "data.name", fallback="unknown interface") + + candidates.append((int_name, str(ip_interface_object), ip_interface_object)) + + return candidates + + def get_vm_primary_ip4(self, vm_name, nic_data, nic_ips, default_gateway_ip4): + """ + Determine the primary IPv4 address for a VM using a tiered fallback. The first tier + that produces a match wins: + + 1. an interface IP exactly matches an A record for 'vm_name' + (only tried if 'vm_primary_ip4_by_dns_name' is enabled). If vCenter reported no + interface IPs at all this run (e.g. guest tools running but not populating + guest.net), IPs NetBox already has assigned to this VM's interfaces from a + previous sync are used as candidates instead -- DNS resolving to one of them + each run is the independent, current confirmation that it's still valid. This + fallback is intentionally DNS-only: tiers 2/3 below have no such confirmation + for stale data and are not extended this way. + 2. an interface IP is in the same network as the VM's default gateway + (the pre-existing behavior, derived from guest.ipStack) + 3. an interface IP sits on a VLAN listed in 'vm_primary_ip4_fallback_vlans', + tried in the configured order (only tried if the option is set) + + An IP that falls within 'vm_ip_permitted_overlapping_subnets' (deliberately shared + HA/heartbeat addresses, e.g. VRRP peer links) is never eligible for any tier, since such + an address is intentionally assigned to more than one VM and would otherwise get + reassigned as primary IP back and forth between those VMs on every sync run. + + Parameters + ---------- + vm_name: str + name of the VM as it will be synced to NetBox + nic_data: dict + interface data keyed by full interface name, as collected in 'add_virtual_machine' + nic_ips: dict + list of "ip/prefixlen" strings keyed by full interface name + default_gateway_ip4: IPv4Address + default IPv4 gateway reported by the VM, or None if none was found + + Returns + ------- + str: primary IPv4 address including prefix length, None if no candidate was found + """ + + overlapping_subnets = grab(self.settings, "vm_ip_permitted_overlapping_subnets", fallback=list()) + + # ordered list of (interface name, "ip/prefixlen", ip_interface) for eligible IPv4 addresses + ipv4_candidates = list() + for int_name, int_ip_list in nic_ips.items(): + for int_ip in int_ip_list: + # noinspection PyBroadException + try: + ip_interface_object = ip_interface(int_ip) + except Exception: + continue + + if ip_interface_object.version != 4: + continue + + if any(ip_interface_object.ip in subnet for subnet in overlapping_subnets): + log.debug2(f"IP '{int_ip}' on interface '{int_name}' of VM '{vm_name}' is part of a " + "permitted overlapping subnet, excluding it from primary IPv4 selection") + continue + + ipv4_candidates.append((int_name, int_ip, ip_interface_object)) + + # tier 1: VM name resolves to one of the discovered IPs, or to an existing NetBox + # assignment if vCenter reported no interface IPs at all this run + if self.settings.vm_primary_ip4_by_dns_name is True: + + dns_candidates = ipv4_candidates + using_existing_assignments = False + + if len(ipv4_candidates) == 0: + dns_candidates = self.get_existing_vm_ipv4_candidates(vm_name, overlapping_subnets) + using_existing_assignments = True + + resolved_ips = self.get_vm_dns_ips(vm_name) + for int_name, int_ip, ip_interface_object in dns_candidates: + if str(ip_interface_object.ip) in resolved_ips: + match_source = "matches DNS A record, no live IPs reported by vCenter this run" \ + if using_existing_assignments else "matches DNS A record" + log.debug(f"Using '{int_ip}' on interface '{int_name}' as primary IPv4 for VM " + f"'{vm_name}' ({match_source})") + return int_ip + + if len(resolved_ips) > 0 and len(dns_candidates) > 0: + log.debug2(f"DNS A record(s) for '{vm_name}' ({', '.join(resolved_ips)}) don't match any " + "IPv4 address discovered on this VM") + + if len(ipv4_candidates) == 0: + log.debug2(f"VM '{vm_name}' has no eligible IPv4 addresses, unable to determine " + "a primary IPv4 address") + return None + + # tier 2: default gateway is in the same network as this IP + if default_gateway_ip4 is not None: + for int_name, int_ip, ip_interface_object in ipv4_candidates: + if default_gateway_ip4 in ip_interface_object.network: + log.debug(f"Using '{int_ip}' on interface '{int_name}' as primary IPv4 for VM " + f"'{vm_name}' (default gateway {default_gateway_ip4})") + return int_ip + + # tier 3: static fallback to a configured VLAN, tried in the configured order + for fallback_vlan in self.settings.vm_primary_ip4_fallback_vlans or list(): + for int_name, int_ip, ip_interface_object in ipv4_candidates: + if grab(nic_data, f"{int_name}|untagged_vlan|vid", separator="|") == fallback_vlan: + log.debug(f"Using '{int_ip}' on interface '{int_name}' as primary IPv4 for VM " + f"'{vm_name}' (fallback VLAN {fallback_vlan})") + return int_ip + + log.debug2(f"Unable to determine a primary IPv4 address for VM '{vm_name}'") + + return None + def update_basic_data(self): """ diff --git a/settings-example.ini b/settings-example.ini index 9815a442..2d682805 100644 --- a/settings-example.ini +++ b/settings-example.ini @@ -294,6 +294,21 @@ password = super-secret ; as "when-undefined" ;set_primary_ip = when-undefined +; Resolve the VM's name (as it will be synced to NetBox) via DNS and, if +; the A record matches one of the IP addresses discovered on the VM's +; interfaces, prefer it as the primary IPv4 address. This is tried before +; the built-in default-gateway based detection and helps with appliances +; that route their default traffic out a data-plane interface instead of +; the management interface. +;vm_primary_ip4_by_dns_name = False + +; Comma separated, ordered list of VLAN IDs used as a last resort to +; determine a VM's primary IPv4 address if neither its DNS name (see +; 'vm_primary_ip4_by_dns_name' above) nor its default gateway could be +; used. The first VLAN in the list that has an IPv4 address on one of the +; VM's interfaces wins. Usually points at a management VLAN. +;vm_primary_ip4_fallback_vlans = 1370 + ; Do not sync notes from a VM in vCenter to the comments field on a VM in netbox ;skip_vm_comments = False From b587a709645474bfb1d38b6a6d134622e795889e Mon Sep 17 00:00:00 2001 From: Adam Korab Date: Thu, 10 Sep 2026 15:42:12 -0500 Subject: [PATCH 19/19] vmware: stop syncing vpxd certificate mode --- module/sources/vmware/connection.py | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/module/sources/vmware/connection.py b/module/sources/vmware/connection.py index d6cfaef8..56fb325a 100644 --- a/module/sources/vmware/connection.py +++ b/module/sources/vmware/connection.py @@ -122,7 +122,6 @@ def __init__(self, name=None): # duplicated in settings.ini. self.vcsa_source_fqdn = self._normalise_fqdn(self.settings.host_fqdn) self.vcsa_version = get_string_or_none(grab(self.session, "about.version")) - self.vpxd_cert_mode = self._get_vpxd_cert_mode() self.init_successful = True @@ -150,16 +149,6 @@ def _normalise_fqdn(value): return None return str(value).strip().rstrip(".").lower() - def _get_vpxd_cert_mode(self): - """Read the live, vCenter-wide certificate-management mode.""" - try: - for option in self.session.setting.QueryOptions() or []: - if grab(option, "key") == "vpxd.certmgmt.mode": - return get_string_or_none(grab(option, "value")) - except Exception as e: - log.warning(f"Unable to read vpxd.certmgmt.mode from vCenter '{self.name}': {e}") - return None - def _is_vcsa_vm(self, vm_name): """Match only the VCSA VM for this source; do not infer by platform.""" vm_fqdn = self._normalise_fqdn(vm_name) @@ -193,15 +182,6 @@ def _get_vcsa_custom_fields(self, vm_name): }) fields[grab(field, "data.name")] = self.vcsa_version - if self.vpxd_cert_mode is not None: - field = self.add_update_custom_field({ - "name": "vpxd_cert_mode", "label": "VPXD_CERT_MODE", - "object_types": [object_type], "type": "select", - "choices": ["vmca", "custom", "thumbprint"], - "description": f"vpxd.certmgmt.mode reported by source '{self.name}'" - }) - fields[grab(field, "data.name")] = self.vpxd_cert_mode - return fields def create_sdk_session(self):