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 e4d8e2b..3c12f65 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. @@ -166,6 +178,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 +495,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() @@ -477,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 " @@ -650,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/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} 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 diff --git a/settings-example.ini b/settings-example.ini index ec2d5f1..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. @@ -217,6 +225,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