From f2a856a407a0b41396a30a4771a8b2799208fa00 Mon Sep 17 00:00:00 2001 From: Lab Admin Date: Wed, 17 Jun 2026 16:57:45 -0500 Subject: [PATCH 1/5] 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 e4d8e2b..7e17838 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 e63763c..0230589 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 2/5] 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 ec2d5f1..cb7cd66 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 3/5] 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 4d20e9d..0fc4c06 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 7e17838..65a89c2 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 cb7cd66..5c4f9df 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 4/5] 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 c85de91..1168f1b 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 5a530cd2231c6a72fa814e151345a2541c85ae5f Mon Sep 17 00:00:00 2001 From: Lab Admin Date: Fri, 19 Jun 2026 15:17:20 -0500 Subject: [PATCH 5/5] fix: strip all whitespace (incl. newlines) from relation key/value parsing 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. --- 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 65a89c2..3c12f65 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 "