From 2eb7ddfe1c267fcc5423e006678b7d392a74b4a4 Mon Sep 17 00:00:00 2001 From: Sergey Sannikov Date: Sat, 10 Oct 2026 04:31:02 +0400 Subject: [PATCH] vmware: resolve the VM tenant from resource pool, folder and cluster Adds vm_tenant_resource_pool_relation and vm_tenant_folder_relation, which map a VM's resource pool path or folder path to a tenant the way vm_tenant_relation maps its name; vm_tenant_inherit_from_cluster, which gives a VM without a tenant of its own the tenant of its cluster; and overwrite_vm_tenant, which when disabled keeps a tenant that was set by hand in NetBox. The order is vm_tenant_relation, resource pool, folder, cluster; the first match wins. IP addresses already take the VM's tenant through the device step of ip_tenant_inheritance_order. Also adds vm_exclude_by_resource_pool_filter and vm_include_by_resource_pool_filter next to the name filters. Resource pool and folder paths are built by walking the vCenter parent chain up to the cluster root pool and the datacenter's vm folder, both excluded, and are cached per managed object. The root pool is the pool whose parent is the cluster, not the pool named "Resources", so a child pool of that name is not mistaken for it. Fixes #531 Fixes #463 Fixes #454 Fixes #494 --- README.md | 5 +- docs/source_vmware.md | 29 ++ module/sources/vmware/config.py | 66 ++- module/sources/vmware/connection.py | 329 ++++++++++++++- settings-example.ini | 49 +++ tests/test_vmware_host_device_type.py | 1 + tests/test_vmware_vm_status_options.py | 1 + tests/test_vmware_vm_tenant_options.py | 548 +++++++++++++++++++++++++ tests/test_vmware_vm_tenant_vcsim.py | 215 ++++++++++ 9 files changed, 1238 insertions(+), 5 deletions(-) create mode 100644 tests/test_vmware_vm_tenant_options.py create mode 100644 tests/test_vmware_vm_tenant_vcsim.py diff --git a/README.md b/README.md index f395b1e..0f6d1be 100644 --- a/README.md +++ b/README.md @@ -296,8 +296,9 @@ Check out the documentations for the different sources netbox-sync provides various filtering capabilities to control what objects are synced from sources to NetBox: 1. **General VM filtering**: Use `vm_include_filter` and `vm_exclude_filter` to include or exclude VMs by name. -2. **Tag-based VM filtering**: Use `vm_exclude_by_tag_filter` to exclude VMs with specific vCenter tags. -3. **Partial information filtering**: +2. **Resource pool filtering**: Use `vm_include_by_resource_pool_filter` and `vm_exclude_by_resource_pool_filter` to include or exclude VMs by the resource pool they are organized in. +3. **Tag-based VM filtering**: Use `vm_exclude_by_tag_filter` to exclude VMs with specific vCenter tags. +4. **Partial information filtering**: - Use `vm_exclude_disk_sync` to exclude disk synchronization for VMs matching specific name patterns. - Use `vm_exclude_disk_sync_by_tag` to exclude disk synchronization for VMs with specific vCenter tags. diff --git a/docs/source_vmware.md b/docs/source_vmware.md index 247083b..a8334f4 100644 --- a/docs/source_vmware.md +++ b/docs/source_vmware.md @@ -99,15 +99,44 @@ First VM is filtered: * VM has a cluster and is it permitted * skip VMs with same name and cluster if already parsed * does the VM pass the `vm_include_filter` and `vm_exclude_filter` +* does the VM pass the `vm_include_by_resource_pool_filter` and `vm_exclude_by_resource_pool_filter` 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. +The resource pool filters are applied to the resource pool path (i.e. `Parent/Child`, the +cluster root pool `Resources` excluded) and to the plain resource pool name of the VM: the VM +is kept when either matches the include filter and skipped when either matches the exclude +filter. A VM placed directly in the cluster root pool has an empty pool path and is matched by +its plain pool name `Resources` only. A VM without a resource pool (a template) passes both +filters. + **Note:**
IP address information can only be extracted if guest tools are installed and running. +#### 5b. Resolve the tenant of a VM + +The tenant of a VM is resolved from different sources. The first match wins: + +1. `vm_tenant_relation` matched against the VM name +2. `vm_tenant_resource_pool_relation` matched against the resource pool path and then + against the plain resource pool name of the VM +3. `vm_tenant_folder_relation` matched against the VM folder path (relative to the + datacenter's VM folder) and then against the plain folder name of the VM + + An empty path (a VM in the cluster root pool, or directly in the datacenter's VM folder) + is not matched; such a VM is matched by its plain pool name `Resources` or folder name + `vm` only. +4. if `vm_tenant_inherit_from_cluster` is enabled: the tenant resolved via + `cluster_tenant_relation` for this cluster or, if that relation matched nothing, the + tenant assigned to the cluster in NetBox + +A tenant already set on an existing NetBox VM is kept if `overwrite_vm_tenant` is disabled. +If a tenant was resolved for the VM, its IP addresses inherit it through the `device` step +of `ip_tenant_inheritance_order`. + #### 6. Sync VMware Tools guest hostname to a custom field The vCenter VM name (used as the NetBox VM `name`) and the hostname reported from inside the diff --git a/module/sources/vmware/config.py b/module/sources/vmware/config.py index 72e32ac..ac0ac70 100644 --- a/module/sources/vmware/config.py +++ b/module/sources/vmware/config.py @@ -106,7 +106,20 @@ def __init__(self): ConfigOption("host_include_filter", str), ConfigOption("vm_exclude_filter", str, description="simply include/exclude VMs"), - ConfigOption("vm_include_filter", str) + ConfigOption("vm_include_filter", str), + ConfigOption("vm_exclude_by_resource_pool_filter", + str, + description="""This will only exclude VMs from sync based on the resource + pool they are organized in. A VM matching this filter is skipped entirely, + the same way a matching 'vm_exclude_filter' skips it. The regex is applied + to the resource pool path (i.e. "Parent/Child", the cluster root pool + "Resources" is excluded from the path) and to the plain resource pool name + of the VM; a VM is skipped when either matches. A VM placed directly in + the cluster root pool has an empty pool path and is matched by its plain + pool name "Resources" only. A VM without a resource pool (a template) + passes the filter. 'vm_include_by_resource_pool_filter' works the same + way, a VM is kept when its pool path or its plain pool name matches."""), + ConfigOption("vm_include_by_resource_pool_filter", str) ]), ConfigOption("vm_exclude_by_tag_filter", str, @@ -192,6 +205,50 @@ def __init__(self): config_example="Cluster_NYC.* = Customer A"), ConfigOption("host_tenant_relation", str, config_example="esxi300.* = Infrastructure"), ConfigOption("vm_tenant_relation", str, config_example="grafana.* = Infrastructure"), + ConfigOption("vm_tenant_resource_pool_relation", + str, + description="""This option defines which tenant is assigned to a VM based on the + vCenter resource pool it is organized in. This is done with a comma + separated key = value list. + key: defines the resource pool path as regex. The path is the resource + pool hierarchy up to (excluding) the cluster root pool, i.e. + "Parent/Child". The plain resource pool name of the VM is matched + as well. A VM placed directly in the cluster root pool has an + empty pool path, which is not matched, and the plain pool name + "Resources". + value: defines the NetBox tenant name (use quotes if name contains commas) + The tenant of a VM is resolved in this order: 'vm_tenant_relation', + 'vm_tenant_resource_pool_relation', 'vm_tenant_folder_relation' and + finally, if 'vm_tenant_inherit_from_cluster' is enabled, the tenant of + the cluster. The first match wins. + """, + config_example="Customers/.* = Customer A, Sandbox = Test"), + ConfigOption("vm_tenant_folder_relation", + str, + description="""This option defines which tenant is assigned to a VM based on the + vCenter VM folder it is organized in. This is done with a comma + separated key = value list. + key: defines the VM folder path as regex. The path is the folder + hierarchy relative to the datacenter's VM folder, i.e. + "Dept-A/Prod". The plain folder name of the VM is matched as + well. A VM placed directly in the datacenter's VM folder has an + empty folder path, which is not matched, and the plain folder + name "vm". + value: defines the NetBox tenant name (use quotes if name contains commas) + The tenant of a VM is resolved in this order: 'vm_tenant_relation', + 'vm_tenant_resource_pool_relation', 'vm_tenant_folder_relation' and + finally, if 'vm_tenant_inherit_from_cluster' is enabled, the tenant of + the cluster. The first match wins. + """, + config_example="Customers/.* = Customer A, Templates = Infrastructure"), + ConfigOption("vm_tenant_inherit_from_cluster", + bool, + description="""define if a VM inherits the tenant of its cluster if no tenant + could be resolved via 'vm_tenant_relation', 'vm_tenant_resource_pool_relation' + or 'vm_tenant_folder_relation'. The inherited tenant is the one resolved + through 'cluster_tenant_relation' for this cluster or, if that relation + matched nothing, the tenant assigned to the cluster in NetBox.""", + default_value=False), ConfigOption("host_platform_relation", str, description="""\ @@ -498,6 +555,13 @@ def __init__(self): description="""define if the platform of the VM discovered overwrites the VM platform in NetBox.""", default_value=True), + ConfigOption("overwrite_vm_tenant", + bool, + description="""define if the tenant resolved for a VM overwrites the tenant of the + VM in NetBox. If disabled, a tenant that is already set on an existing NetBox VM + is kept, even if a different tenant was resolved. A VM without a tenant in NetBox + still gets the resolved tenant.""", + default_value=True), ConfigOption("host_management_interface_match", str, description="""set a matching value for ESXi host management interface description diff --git a/module/sources/vmware/connection.py b/module/sources/vmware/connection.py index 60cf56d..6480522 100644 --- a/module/sources/vmware/connection.py +++ b/module/sources/vmware/connection.py @@ -98,6 +98,10 @@ class VMWareHandler(SourceBase): source_type = "vmware" + # how many levels of resource pools or folders are walked when building an object path, + # vCenter itself allows far fewer + object_path_max_depth = 20 + recursion_level = 0 # internal vars @@ -166,6 +170,7 @@ def __init__(self, name=None): self.processed_vm_names = dict() self.processed_vm_uuid = list() self.object_cache = dict() + self.object_path_cache = dict() self.parsing_vms_the_first_time = True self.objects_to_reevaluate = list() self.parsing_objects_to_reevaluate = False @@ -1641,6 +1646,19 @@ def add_device_vm_to_inventory(self, object_type, object_data, pnic_data=None, v object_data.get("platform") is not None: del object_data["platform"] + # keep the tenant which is already set on the existing NetBox VM. When it is the + # resolved one anyway the reference stays, so the tenant object keeps its source. + existing_tenant = grab(device_vm_object, "data.tenant") + if isinstance(existing_tenant, dict): + existing_tenant_name = existing_tenant.get("name") + else: + existing_tenant_name = grab(existing_tenant, "data.name") + + if object_type == NBVM and self.settings.overwrite_vm_tenant is False and \ + object_data.get("tenant") is not None and existing_tenant is not None and \ + existing_tenant_name != object_data["tenant"].get("name"): + del object_data["tenant"] + if object_type == NBDevice and self.settings.overwrite_device_platform is False and \ object_data.get("platform") is not None: del object_data["platform"] @@ -1906,6 +1924,309 @@ def get_object_from_cache(self, vm_object): return self.object_cache[vm_class_name].get(vm_object_id) + def add_object_paths_to_cache(self, vm_object, path_data): + """ + Store computed vCenter inventory paths for a managed object so repeated + lookups don't have to walk the inventory tree again. + + Parameters + ---------- + vm_object: vim.ManagedEntity + vCenter object the paths belong to + path_data: dict + path name as key and the path itself as value + """ + + if vm_object is None or not isinstance(path_data, dict): + return + + # noinspection PyBroadException + try: + vm_class_name = vm_object.__class__.__name__ + # noinspection PyProtectedMember + vm_object_id = vm_object._GetMoId() + except Exception: + return + + if self.object_path_cache.get(vm_class_name) is None: + self.object_path_cache[vm_class_name] = dict() + + if self.object_path_cache[vm_class_name].get(vm_object_id) is None: + self.object_path_cache[vm_class_name][vm_object_id] = dict() + + self.object_path_cache[vm_class_name][vm_object_id].update(path_data) + + def get_object_path_from_cache(self, vm_object, path_name): + """ + Return a cached vCenter inventory path of a managed object. + + Parameters + ---------- + vm_object: vim.ManagedEntity + vCenter object to look the path up for + path_name: str + name of the path to look up + + Returns + ------- + str, None: the cached path if present, otherwise None + """ + + if vm_object is None or path_name is None: + return + + # noinspection PyBroadException + try: + vm_class_name = vm_object.__class__.__name__ + # noinspection PyProtectedMember + vm_object_id = vm_object._GetMoId() + except Exception: + return + + if self.object_path_cache.get(vm_class_name) is None: + return + + object_paths = self.object_path_cache[vm_class_name].get(vm_object_id) + + if object_paths is None: + return + + return object_paths.get(path_name) + + def get_resource_pool_path(self, vm): + """ + Return the resource pool path of a VM as "Parent/Child". + + The pool hierarchy is walked up starting at the VM's resource pool until the + cluster root resource pool is reached, the pool whose parent is the cluster + itself (vCenter names it "Resources"). The root pool itself is excluded from + the path. A VM placed directly in the + cluster root pool therefore gets an empty path. + + Parameters + ---------- + vm: vim.VirtualMachine + virtual machine object to get the resource pool path for + + Returns + ------- + str, None: resource pool path if the VM has a resource pool assigned, + otherwise None (i.e. for template VMs) + """ + + if grab(vm, "resourcePool") is None: + return None + + cached_path = self.get_object_path_from_cache(vm, "resource_pool_path") + if cached_path is not None: + return cached_path + + path_parts = list() + pool = grab(vm, "resourcePool") + + # walk up the pool hierarchy, the cluster root pool ends the walk: it is the pool whose + # parent is the cluster itself rather than another pool (vCenter names it "Resources", + # but a child pool may carry that name too) + while pool is not None and len(path_parts) < self.object_path_max_depth: + if not isinstance(grab(pool, "parent"), vim.ResourcePool): + break + + pool_name = get_string_or_none(grab(pool, "name")) + if pool_name is None: + break + + path_parts.append(pool_name) + pool = grab(pool, "parent") + + if pool is not None and isinstance(grab(pool, "parent"), vim.ResourcePool): + log.warning(f"Resource pool hierarchy of VM '{get_string_or_none(grab(vm, 'name'))}' is deeper " + f"than {self.object_path_max_depth} levels, the pool path is cut off at the top") + + path = "/".join(reversed(path_parts)) + log.debug2(f"VM '{get_string_or_none(grab(vm, 'name'))}' has resource pool path '{path}'") + + self.add_object_paths_to_cache(vm, {"resource_pool_path": path}) + + return path + + def get_vm_folder_path(self, vm): + """ + Return the VM folder path of a VM as "Dept-A/Prod". + + The folder hierarchy is walked up starting at the VM's parent folder until the + datacenter's VM folder is reached, which is excluded from the path. A VM placed + directly in the datacenter's VM folder therefore gets an empty path. + + Parameters + ---------- + vm: vim.VirtualMachine + virtual machine object to get the VM folder path for + + Returns + ------- + str, None: VM folder path if the VM is organized in a folder, otherwise None + """ + + if grab(vm, "parent") is None or not isinstance(grab(vm, "parent"), vim.Folder): + return None + + cached_path = self.get_object_path_from_cache(vm, "vm_folder_path") + if cached_path is not None: + return cached_path + + path_parts = list() + folder = grab(vm, "parent") + + # walk up the folder hierarchy, the datacenter's VM folder ends the walk + while isinstance(folder, vim.Folder) and len(path_parts) < self.object_path_max_depth: + + # the folder directly below the datacenter is the datacenter's VM folder + if isinstance(grab(folder, "parent"), vim.Datacenter): + break + + folder_name = get_string_or_none(grab(folder, "name")) + if folder_name is None: + break + + path_parts.append(folder_name) + folder = grab(folder, "parent") + + if isinstance(folder, vim.Folder) and not isinstance(grab(folder, "parent"), vim.Datacenter): + log.warning(f"Folder hierarchy of VM '{get_string_or_none(grab(vm, 'name'))}' is deeper than " + f"{self.object_path_max_depth} levels, the folder path is cut off at the top") + + path = "/".join(reversed(path_parts)) + log.debug2(f"VM '{get_string_or_none(grab(vm, 'name'))}' has VM folder path '{path}'") + + self.add_object_paths_to_cache(vm, {"vm_folder_path": path}) + + return path + + def get_vm_tenant_name(self, vm, vm_name, nb_cluster_object, cluster_full_name): + """ + Resolve the NetBox tenant name for a VM based on the config options. + + The tenant is resolved in this order and the first match wins: + 1. 'vm_tenant_relation' matched against the VM name + 2. 'vm_tenant_resource_pool_relation' matched against the resource pool path + and then against the plain resource pool name + 3. 'vm_tenant_folder_relation' matched against the VM folder path and then + against the plain folder name + 4. if 'vm_tenant_inherit_from_cluster' is enabled: the tenant resolved via + 'cluster_tenant_relation' for this cluster, otherwise the tenant assigned + to the NetBox cluster object + + Parameters + ---------- + vm: vim.VirtualMachine + virtual machine object to resolve the tenant for + vm_name: str + name of the virtual machine + nb_cluster_object: NBCluster + NetBox cluster object this VM belongs to + cluster_full_name: str + cluster name as "Datacenter-name/Cluster-name" + + Returns + ------- + str, None: tenant name if one was resolved, otherwise None + """ + + tenant_name = self.get_object_relation(vm_name, "vm_tenant_relation") + + # resolve tenant by resource pool path + if tenant_name is None: + + resource_pool_path = self.get_resource_pool_path(vm) + if resource_pool_path: + tenant_name = self.get_object_relation(resource_pool_path, "vm_tenant_resource_pool_relation") + + if tenant_name is None: + resource_pool_name = get_string_or_none(grab(vm, "resourcePool.name")) + if resource_pool_name is not None: + tenant_name = self.get_object_relation(resource_pool_name, + "vm_tenant_resource_pool_relation") + + # resolve tenant by VM folder path + if tenant_name is None: + + vm_folder_path = self.get_vm_folder_path(vm) + if vm_folder_path: + tenant_name = self.get_object_relation(vm_folder_path, "vm_tenant_folder_relation") + + if tenant_name is None: + vm_folder_name = get_string_or_none(grab(vm, "parent.name")) + if vm_folder_name is not None: + tenant_name = self.get_object_relation(vm_folder_name, "vm_tenant_folder_relation") + + # inherit the tenant from the cluster if desired + if tenant_name is None and self.settings.vm_tenant_inherit_from_cluster is True: + + tenant_name = self.get_object_relation(cluster_full_name, "cluster_tenant_relation") + + if tenant_name is None: + cluster_tenant = grab(nb_cluster_object, "data.tenant") + if isinstance(cluster_tenant, dict): + tenant_name = cluster_tenant.get("name") + else: + tenant_name = grab(cluster_tenant, "data.name") + + return tenant_name + + def vm_passes_resource_pool_filter(self, vm): + """ + Check a VM's resource pool against the 'vm_include_by_resource_pool_filter' and + 'vm_exclude_by_resource_pool_filter' config options. + + Both filters are applied to the resource pool path and to the plain resource pool + name of the VM. The VM passes the include filter when either of the two matches + it, and is skipped when either of the two matches the exclude filter. An empty + pool path is not filtered against, so a VM placed directly in the cluster root + pool is judged by its plain pool name only. A VM without a resource pool (a + template) passes the filters. + + Parameters + ---------- + vm: vim.VirtualMachine + virtual machine object to check + + Returns + ------- + bool: True if the VM passes both filters, otherwise False + """ + + include_filter = self.settings.vm_include_by_resource_pool_filter + exclude_filter = self.settings.vm_exclude_by_resource_pool_filter + + if include_filter is None and exclude_filter is None: + return True + + resource_pool_path = self.get_resource_pool_path(vm) + resource_pool_name = get_string_or_none(grab(vm, "resourcePool.name")) + + pool_identifiers = [x for x in dict.fromkeys([resource_pool_path, resource_pool_name]) + if x not in [None, ""]] + + if len(pool_identifiers) == 0: + return True + + # first includes, the path or the plain name has to match + if include_filter is not None and \ + not any(include_filter.match(x) for x in pool_identifiers): + log.debug(f"Resource pool '{pool_identifiers[0]}' did not match include filter " + f"'{include_filter.pattern}'. Skipping") + return False + + # second excludes, neither may match + if exclude_filter is not None: + for pool_identifier in pool_identifiers: + if exclude_filter.match(pool_identifier): + log.debug(f"Resource pool '{pool_identifier}' matched exclude filter " + f"'{exclude_filter.pattern}'. Skipping") + return False + + return True + def add_datacenter(self, obj): """ Add a vCenter datacenter as a NBClusterGroup to NetBox @@ -2851,6 +3172,10 @@ def add_virtual_machine(self, obj): if self.passes_filter(name, self.settings.vm_include_filter, self.settings.vm_exclude_filter) is False: return + # filter VMs by resource pool path and resource pool name + if self.vm_passes_resource_pool_filter(obj) is False: + return + # # Collect data # @@ -2904,8 +3229,8 @@ def add_virtual_machine(self, obj): if self.settings.skip_vm_comments is False: annotation = get_string_or_none(grab(obj, "config.annotation")) - # assign vm_tenant_relation - tenant_name = self.get_object_relation(name, "vm_tenant_relation") + # resolve tenant for this VM + tenant_name = self.get_vm_tenant_name(obj, name, nb_cluster_object, cluster_full_name) # assign vm_tag_relation vm_tags = self.get_object_relation(name, "vm_tag_relation") diff --git a/settings-example.ini b/settings-example.ini index 75b725f..8f82357 100644 --- a/settings-example.ini +++ b/settings-example.ini @@ -189,6 +189,18 @@ password = super-secret ;vm_exclude_filter = ;vm_include_filter = +; This will only exclude VMs from sync based on the resource pool they are organized in. A +; VM matching this filter is skipped entirely, the same way a matching 'vm_exclude_filter' +; skips it. The regex is applied to the resource pool path (i.e. "Parent/Child", the +; cluster root pool "Resources" is excluded from the path) and to the plain resource pool +; name of the VM; a VM is skipped when either matches. A VM placed directly in the cluster +; root pool has an empty pool path and is matched by its plain pool name "Resources" only. +; A VM without a resource pool (a template) passes the filter. +; 'vm_include_by_resource_pool_filter' works the same way, a VM is kept when its pool path +; or its plain pool name matches. +;vm_exclude_by_resource_pool_filter = +;vm_include_by_resource_pool_filter = + ; defines a comma separated list of vCenter tags which (if assigned to a VM) will exclude ; this VM from being synced to NetBox. The config option 'vm_tag_source' determines which ; tags are collected for VMs. @@ -249,6 +261,37 @@ password = super-secret ;host_tenant_relation = esxi300.* = Infrastructure ;vm_tenant_relation = grafana.* = Infrastructure +; This option defines which tenant is assigned to a VM based on the vCenter resource pool +; it is organized in. This is done with a comma separated key = value list. key: defines +; the resource pool path as regex. The path is the resource pool hierarchy up to +; (excluding) the cluster root pool, i.e. "Parent/Child". The plain resource pool name of +; the VM is matched as well. A VM placed directly in the cluster root pool has an empty +; pool path, which is not matched, and the plain pool name "Resources". value: defines the +; NetBox tenant name (use quotes if name contains commas) The tenant of a VM is resolved +; in this order: 'vm_tenant_relation', 'vm_tenant_resource_pool_relation', +; 'vm_tenant_folder_relation' and finally, if 'vm_tenant_inherit_from_cluster' is enabled, +; the tenant of the cluster. The first match wins. +;vm_tenant_resource_pool_relation = Customers/.* = Customer A, Sandbox = Test + +; This option defines which tenant is assigned to a VM based on the vCenter VM folder it +; is organized in. This is done with a comma separated key = value list. key: defines the +; VM folder path as regex. The path is the folder hierarchy relative to the datacenter's +; VM folder, i.e. "Dept-A/Prod". The plain folder name of the VM is matched as well. A VM +; placed directly in the datacenter's VM folder has an empty folder path, which is not +; matched, and the plain folder name "vm". value: defines the NetBox tenant name (use +; quotes if name contains commas) The tenant of a VM is resolved in this order: +; 'vm_tenant_relation', 'vm_tenant_resource_pool_relation', 'vm_tenant_folder_relation' +; and finally, if 'vm_tenant_inherit_from_cluster' is enabled, the tenant of the cluster. +; The first match wins. +;vm_tenant_folder_relation = Customers/.* = Customer A, Templates = Infrastructure + +; define if a VM inherits the tenant of its cluster if no tenant could be resolved via +; 'vm_tenant_relation', 'vm_tenant_resource_pool_relation' or 'vm_tenant_folder_relation'. +; The inherited tenant is the one resolved through 'cluster_tenant_relation' for this +; cluster or, if that relation matched nothing, the tenant assigned to the cluster in +; NetBox. +;vm_tenant_inherit_from_cluster = False + ; This option defines custom platforms if the VMWare created platforms are not suitable. ; Pretty much a mapping of VMWare platform name to your own platform name. ; This is done with a comma separated key = value list. @@ -475,6 +518,12 @@ password = super-secret ; define if the platform of the VM discovered overwrites the VM platform in NetBox. ;overwrite_vm_platform = True +; define if the tenant resolved for a VM overwrites the tenant of the VM in NetBox. If +; disabled, a tenant that is already set on an existing NetBox VM is kept, even if a +; different tenant was resolved. A VM without a tenant in NetBox still gets the resolved +; tenant. +;overwrite_vm_tenant = True + ; set a matching value for ESXi host management interface description (case insensitive, ; comma separated). Used to figure out the ESXi primary IP address ;host_management_interface_match = management, mgmt diff --git a/tests/test_vmware_host_device_type.py b/tests/test_vmware_host_device_type.py index 06f56dc..9d0316e 100644 --- a/tests/test_vmware_host_device_type.py +++ b/tests/test_vmware_host_device_type.py @@ -38,6 +38,7 @@ def _make_source(inventory): match_vm_by_ip_address=True, overwrite_device_platform=False, overwrite_vm_platform=False, + overwrite_vm_tenant=True, host_role_relation=[], host_interface_exclude_filter=None, vm_interface_exclude_filter=None, diff --git a/tests/test_vmware_vm_status_options.py b/tests/test_vmware_vm_status_options.py index 3736981..0d5cf68 100644 --- a/tests/test_vmware_vm_status_options.py +++ b/tests/test_vmware_vm_status_options.py @@ -45,6 +45,7 @@ def _make_source(inventory, status_on_create=None, status_preserve=None): match_vm_by_ip_address=True, overwrite_device_platform=False, overwrite_vm_platform=False, + overwrite_vm_tenant=True, host_role_relation=[], vm_role_relation=[], host_interface_exclude_filter=None, diff --git a/tests/test_vmware_vm_tenant_options.py b/tests/test_vmware_vm_tenant_options.py new file mode 100644 index 0000000..c7058f0 --- /dev/null +++ b/tests/test_vmware_vm_tenant_options.py @@ -0,0 +1,548 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020 - 2026 netbox-sync team. All rights reserved. +# +# netbox-sync.py +# +# This work is licensed under the terms of the MIT license. +# For a copy, see file LICENSE.txt included in this +# repository or visit: . + +""" +VM tenant assignment (issues #531, #463 and #454): the tenant of a VM can be resolved +from the resource pool or the VM folder it is organized in, can be inherited from the +cluster and an existing tenant in NetBox can be protected from being overwritten. VMs +can also be filtered by their resource pool (issue #494). + +These file contains unit tests driving the real VMWareHandler against the in-memory +NetBoxInventory with pyVmomi-typed stand-ins, so neither a vCenter nor a NetBox is +needed. The vcsim-backed tests in test_vmware_vm_tenant_vcsim.py cover the same +behaviour against a captured inventory. +""" +import re +from types import SimpleNamespace + +from pyVmomi import vim + +from module.netbox.object_classes import NBCluster, NBClusterType, NBSite, NBTenant, NBVM +from module.sources.vmware.config import VMWareConfig +from module.sources.vmware.connection import VMWareHandler + +MINIMAL_CONFIG = """ +[netbox] +host_fqdn = netbox.example.com +api_token = xyz + +[source/vc] +type = vmware +host_fqdn = vcenter.example.com +username = u +password = p +""" + + +class _Folder(vim.Folder): + """vim.Folder with a plain name and parent, no vCenter connection behind it.""" + + def __init__(self, mo_id, name, parent=None): + super().__init__(mo_id) + object.__setattr__(self, "_name", name) + object.__setattr__(self, "_parent", parent) + + name = property(lambda self: self._name) + parent = property(lambda self: self._parent) + + +class _Datacenter(vim.Datacenter): + """vim.Datacenter with a plain name, no vCenter connection behind it.""" + + def __init__(self, mo_id, name): + super().__init__(mo_id) + object.__setattr__(self, "_name", name) + + name = property(lambda self: self._name) + + +class _ResourcePool(vim.ResourcePool): + """vim.ResourcePool with a plain name and parent, no vCenter connection behind it.""" + + def __init__(self, mo_id, name, parent=None): + super().__init__(mo_id) + object.__setattr__(self, "_name", name) + object.__setattr__(self, "_parent", parent) + + name = property(lambda self: self._name) + parent = property(lambda self: self._parent) + + +class _VirtualMachine(vim.VirtualMachine): + """vim.VirtualMachine with a plain name, folder and resource pool.""" + + def __init__(self, mo_id, name, parent=None, resource_pool=None): + super().__init__(mo_id) + object.__setattr__(self, "_name", name) + object.__setattr__(self, "_parent", parent) + object.__setattr__(self, "_resource_pool", resource_pool) + + name = property(lambda self: self._name) + parent = property(lambda self: self._parent) + resourcePool = property(lambda self: self._resource_pool) + + +# a small inventory tree: +# datacenter/vm folder/Dept-A/Prod +# cluster root pool "Resources"/Parent/Child +ROOT_POOL = _ResourcePool("resgroup-1", "Resources") +PARENT_POOL = _ResourcePool("resgroup-2", "Parent", parent=ROOT_POOL) +CHILD_POOL = _ResourcePool("resgroup-3", "Child", parent=PARENT_POOL) + +DC = _Datacenter("datacenter-1", "DC1") +VM_FOLDER = _Folder("folder-1", "vm", parent=DC) +DEPT_A_FOLDER = _Folder("folder-2", "Dept-A", parent=VM_FOLDER) +PROD_FOLDER = _Folder("folder-3", "Prod", parent=DEPT_A_FOLDER) + + +def _relation(*pairs): + """Relations in the shape the config parser hands them to the handler.""" + return [{"object_regex": re.compile(regex), "assigned_name": assigned} for regex, assigned in pairs] + + +def _make_source(inventory, **overrides): + # build the handler without its network-bound __init__ + src = object.__new__(VMWareHandler) + src.inventory = inventory + src.name = "test" + src.source_tag = "Source: test" + src.object_cache = dict() + src.object_path_cache = dict() + settings = { + "match_host_by_serial": True, + "match_vm_by_serial": True, + "match_vm_by_mac_address": True, + "match_vm_by_ip_address": True, + "overwrite_device_platform": True, + "overwrite_vm_platform": True, + "overwrite_vm_tenant": True, + "host_role_relation": [], + "vm_role_relation": [], + "host_interface_exclude_filter": None, + "vm_interface_exclude_filter": None, + "set_primary_ip": "when-undefined", + "vm_exclude_disk_sync": None, + "vm_exclude_disk_sync_by_tag": None, + "vm_status_on_create": None, + "vm_status_preserve": None, + "vm_tenant_relation": [], + "vm_tenant_resource_pool_relation": [], + "vm_tenant_folder_relation": [], + "cluster_tenant_relation": [], + "vm_tenant_inherit_from_cluster": False, + "vm_include_by_resource_pool_filter": None, + "vm_exclude_by_resource_pool_filter": None, + } + settings.update(overrides) + src.settings = SimpleNamespace(**settings) + return src + + +def _cluster(inventory, tenant_name=None): + site = inventory.add_object(NBSite, data={"name": "site1"}, read_from_netbox=True) + ctype = inventory.add_object(NBClusterType, data={"name": "vmware"}, read_from_netbox=True) + data = {"name": "c1", "type": ctype, "scope": site} + if tenant_name is not None: + data["tenant"] = {"name": tenant_name} + return inventory.add_object(NBCluster, data=data, read_from_netbox=True) + + +def _vm(mo_id, name, folder=None, pool=None): + return _VirtualMachine(mo_id, name, parent=folder, resource_pool=pool) + + +def _tenant_of(vm): + tenant = vm.data.get("tenant") + return None if tenant is None else tenant.get_display_name() + + +def _sync_vm(src, cluster, name="vm1", tenant=None): + # the handler stores the object in the inventory, it does not hand it back + object_data = {"name": name, "cluster": cluster, "status": "active"} + if tenant is not None: + object_data["tenant"] = {"name": tenant} + src.add_device_vm_to_inventory(NBVM, object_data=object_data, + pnic_data=dict(), vnic_data=dict()) + return src.inventory.get_by_data(NBVM, data={"name": name, "cluster": cluster}) + + +# +# config parsing +# + +def test_new_tenant_and_filter_options_are_unset_by_default(load_config): + """A config that does not mention them must leave today's behaviour in place.""" + load_config(MINIMAL_CONFIG) + handler = VMWareConfig() + handler.source_name = "vc" + settings = handler.parse(do_log=False) + + assert not settings.vm_tenant_resource_pool_relation + assert not settings.vm_tenant_folder_relation + assert settings.vm_tenant_inherit_from_cluster is False + assert settings.overwrite_vm_tenant is True + assert settings.vm_include_by_resource_pool_filter is None + assert settings.vm_exclude_by_resource_pool_filter is None + + +def test_new_tenant_and_filter_options_are_parsed(load_config): + load_config(MINIMAL_CONFIG + """ +vm_tenant_resource_pool_relation = Resources/Customers.* = Customer A +vm_tenant_folder_relation = Dept-A/Prod = Customer B +vm_tenant_inherit_from_cluster = True +overwrite_vm_tenant = False +vm_exclude_by_resource_pool_filter = POWERED OFF +vm_include_by_resource_pool_filter = ^Resources +""") + handler = VMWareConfig() + handler.source_name = "vc" + settings = handler.parse(do_log=False) + + assert settings.vm_tenant_resource_pool_relation == \ + [{"object_regex": re.compile("Resources/Customers.*"), "assigned_name": "Customer A"}] + assert settings.vm_tenant_folder_relation == \ + [{"object_regex": re.compile("Dept-A/Prod"), "assigned_name": "Customer B"}] + assert settings.vm_tenant_inherit_from_cluster is True + assert settings.overwrite_vm_tenant is False + assert settings.vm_exclude_by_resource_pool_filter.pattern == "POWERED OFF" + assert settings.vm_include_by_resource_pool_filter.pattern == "^Resources" + + +# +# resource pool and folder paths +# + +def test_resource_pool_path_excludes_the_cluster_root_pool(inventory): + src = _make_source(inventory) + + assert src.get_resource_pool_path(_vm("vm-1", "vm1", pool=CHILD_POOL)) == "Parent/Child" + assert src.get_resource_pool_path(_vm("vm-2", "vm2", pool=ROOT_POOL)) == "" + assert src.get_resource_pool_path(_vm("vm-3", "vm3", pool=None)) is None + + +def test_resource_pool_path_ends_at_the_pool_below_the_cluster_whatever_its_name(inventory): + # the root pool is the one whose parent is the cluster, not a pool; a child pool may well + # carry the name vCenter gives the root pool, and a root pool may carry another name + src = _make_source(inventory) + nested_resources = _ResourcePool("resgroup-7", "Resources", parent=PARENT_POOL) + renamed_root = _ResourcePool("resgroup-8", "Pool0") + + assert src.get_resource_pool_path(_vm("vm-7", "vm7", pool=nested_resources)) == "Parent/Resources" + assert src.get_resource_pool_path(_vm("vm-8", "vm8", pool=renamed_root)) == "" + + +def test_resource_pool_path_is_cached_per_managed_object(inventory): + src = _make_source(inventory) + vm = _vm("vm-1", "vm1", pool=CHILD_POOL) + + assert src.get_resource_pool_path(vm) == "Parent/Child" + + # a different pool must not change the answer, the path was cached for this VM + object.__setattr__(vm, "_resource_pool", _ResourcePool("resgroup-9", "Other", parent=ROOT_POOL)) + assert src.get_resource_pool_path(vm) == "Parent/Child" + assert len(src.object_path_cache) == 1 + + +def test_vm_folder_path_is_relative_to_the_datacenter_vm_folder(inventory): + src = _make_source(inventory) + + assert src.get_vm_folder_path(_vm("vm-1", "vm1", folder=PROD_FOLDER)) == "Dept-A/Prod" + assert src.get_vm_folder_path(_vm("vm-2", "vm2", folder=VM_FOLDER)) == "" + # a VM organized in a vApp (a resource pool) has no folder path + assert src.get_vm_folder_path(_vm("vm-3", "vm3", folder=CHILD_POOL)) is None + + +def test_vm_folder_path_is_cached_per_managed_object(inventory): + src = _make_source(inventory) + vm = _vm("vm-1", "vm1", folder=PROD_FOLDER) + + assert src.get_vm_folder_path(vm) == "Dept-A/Prod" + + # a different folder must not change the answer, the path was cached for this VM + object.__setattr__(vm, "_parent", DEPT_A_FOLDER) + assert src.get_vm_folder_path(vm) == "Dept-A/Prod" + assert len(src.object_path_cache) == 1 + + +# +# tenant resolution +# + +def test_vm_tenant_relation_takes_precedence_over_everything(inventory): + src = _make_source( + inventory, + vm_tenant_relation=_relation(("vm1", "Name Tenant")), + vm_tenant_resource_pool_relation=_relation((".*", "Pool Tenant")), + vm_tenant_folder_relation=_relation((".*", "Folder Tenant")), + cluster_tenant_relation=_relation((".*", "Cluster Tenant")), + vm_tenant_inherit_from_cluster=True, + ) + vm = _vm("vm-1", "vm1", folder=PROD_FOLDER, pool=CHILD_POOL) + + assert src.get_vm_tenant_name(vm, "vm1", _cluster(inventory), "DC1/c1") == "Name Tenant" + + +def test_resource_pool_relation_takes_precedence_over_the_folder_relation(inventory): + src = _make_source( + inventory, + vm_tenant_resource_pool_relation=_relation((".*", "Pool Tenant")), + vm_tenant_folder_relation=_relation((".*", "Folder Tenant")), + ) + vm = _vm("vm-1", "vm1", folder=PROD_FOLDER, pool=CHILD_POOL) + + assert src.get_vm_tenant_name(vm, "vm1", _cluster(inventory), "DC1/c1") == "Pool Tenant" + + +def test_tenant_is_resolved_by_resource_pool_path_and_plain_pool_name(inventory): + vm = _vm("vm-1", "vm1", pool=CHILD_POOL) + + by_path = _make_source(inventory, + vm_tenant_resource_pool_relation=_relation(("^Parent/Child$", "Path Tenant"))) + assert by_path.get_vm_tenant_name(vm, "vm1", _cluster(inventory), "DC1/c1") == "Path Tenant" + + by_name = _make_source(inventory, + vm_tenant_resource_pool_relation=_relation(("^Child$", "Name Tenant"))) + assert by_name.get_vm_tenant_name(vm, "vm1", _cluster(inventory), "DC1/c1") == "Name Tenant" + + +def test_tenant_is_resolved_by_folder_path_and_plain_folder_name(inventory): + vm = _vm("vm-1", "vm1", folder=PROD_FOLDER, pool=ROOT_POOL) + + by_path = _make_source(inventory, + vm_tenant_folder_relation=_relation(("^Dept-A/Prod$", "Path Tenant"))) + assert by_path.get_vm_tenant_name(vm, "vm1", _cluster(inventory), "DC1/c1") == "Path Tenant" + + by_name = _make_source(inventory, + vm_tenant_folder_relation=_relation(("^Prod$", "Name Tenant"))) + assert by_name.get_vm_tenant_name(vm, "vm1", _cluster(inventory), "DC1/c1") == "Name Tenant" + + +def test_no_tenant_is_resolved_without_relations(inventory): + src = _make_source(inventory) + vm = _vm("vm-1", "vm1", folder=PROD_FOLDER, pool=CHILD_POOL) + + assert src.get_vm_tenant_name(vm, "vm1", _cluster(inventory), "DC1/c1") is None + + +def test_tenant_is_inherited_from_the_cluster_relation(inventory): + src = _make_source( + inventory, + cluster_tenant_relation=_relation(("DC1/c1", "Cluster Tenant")), + vm_tenant_inherit_from_cluster=True, + ) + vm = _vm("vm-1", "vm1", folder=PROD_FOLDER, pool=CHILD_POOL) + + assert src.get_vm_tenant_name(vm, "vm1", _cluster(inventory), "DC1/c1") == "Cluster Tenant" + + +def test_cluster_tenant_object_and_unusable_shapes(inventory): + # the cluster tenant may be a resolved tenant object; an id alone or a dict without a + # name gives no tenant + src = _make_source(inventory, vm_tenant_inherit_from_cluster=True) + vm = _vm("vm-1", "vm1", folder=PROD_FOLDER, pool=CHILD_POOL) + cluster = _cluster(inventory) + + cluster.data["tenant"] = inventory.add_object(NBTenant, data={"name": "Object Tenant"}, + read_from_netbox=True) + assert src.get_vm_tenant_name(vm, "vm1", cluster, "DC1/c1") == "Object Tenant" + + cluster.data["tenant"] = 7 + assert src.get_vm_tenant_name(vm, "vm1", cluster, "DC1/c1") is None + + cluster.data["tenant"] = {"id": 7} + assert src.get_vm_tenant_name(vm, "vm1", cluster, "DC1/c1") is None + + +def test_empty_paths_are_not_matched_against_the_relations(inventory): + # a VM in the cluster root pool or directly in the datacenter's VM folder has empty + # paths; like the filters, the relations only see its plain pool and folder names + src = _make_source(inventory, + vm_tenant_resource_pool_relation=_relation(("^$", "Empty Pool Path")), + vm_tenant_folder_relation=_relation(("^$", "Empty Folder Path"))) + cluster = _cluster(inventory) + vm = _vm("vm-1", "vm1", folder=VM_FOLDER, pool=ROOT_POOL) + + assert src.get_vm_tenant_name(vm, "vm1", cluster, "DC1/c1") is None + + by_name = _make_source(inventory, + vm_tenant_resource_pool_relation=_relation(("^Resources$", "Root Pool")), + vm_tenant_folder_relation=_relation(("^vm$", "Root Folder"))) + assert by_name.get_vm_tenant_name(vm, "vm1", cluster, "DC1/c1") == "Root Pool" + assert by_name.get_vm_tenant_name(_vm("vm-2", "vm2", folder=VM_FOLDER), "vm2", cluster, + "DC1/c1") == "Root Folder" + + +def test_a_path_deeper_than_the_walk_limit_is_reported(inventory, caplog): + src = _make_source(inventory) + pool = ROOT_POOL + for level in range(25): + pool = _ResourcePool(f"resgroup-{100 + level}", f"L{level + 1}", parent=pool) + folder = VM_FOLDER + for level in range(25): + folder = _Folder(f"folder-{100 + level}", f"F{level + 1}", parent=folder) + vm = _vm("vm-1", "vm1", folder=folder, pool=pool) + + with caplog.at_level("WARNING"): + pool_path = src.get_resource_pool_path(vm) + folder_path = src.get_vm_folder_path(vm) + + assert pool_path.endswith("/L25") and folder_path.endswith("/F25") + assert sum("deeper than 20 levels" in record.message for record in caplog.records) == 2 + + +def test_tenant_is_inherited_from_the_netbox_cluster_object(inventory): + # the tenant set on the NetBox cluster is used when the cluster relation matched nothing + src = _make_source(inventory, vm_tenant_inherit_from_cluster=True) + vm = _vm("vm-1", "vm1", folder=PROD_FOLDER, pool=CHILD_POOL) + + cluster = _cluster(inventory, tenant_name="Cluster Object Tenant") + assert src.get_vm_tenant_name(vm, "vm1", cluster, "DC1/c1") == "Cluster Object Tenant" + + # covers a cluster tenant which was not resolved to a tenant object (yet) + cluster.data["tenant"] = {"id": 5, "name": "Unresolved Tenant"} + assert src.get_vm_tenant_name(vm, "vm1", cluster, "DC1/c1") == "Unresolved Tenant" + + +def test_cluster_tenant_is_not_inherited_when_disabled(inventory): + src = _make_source( + inventory, + cluster_tenant_relation=_relation((".*", "Cluster Tenant")), + vm_tenant_inherit_from_cluster=False, + ) + vm = _vm("vm-1", "vm1", folder=PROD_FOLDER, pool=CHILD_POOL) + + assert src.get_vm_tenant_name(vm, "vm1", _cluster(inventory), "DC1/c1") is None + + +# +# overwrite_vm_tenant +# + +def test_existing_vm_tenant_is_kept_when_overwrite_is_disabled(inventory): + cluster = _cluster(inventory) + tenant = inventory.add_object(NBTenant, data={"name": "Existing Tenant"}, read_from_netbox=True) + inventory.add_object(NBVM, data={"name": "vm1", "cluster": cluster, "status": "active", + "tenant": tenant}, read_from_netbox=True) + + src = _make_source(inventory, overwrite_vm_tenant=False) + vm = _sync_vm(src, cluster, tenant="Resolved Tenant") + + assert _tenant_of(vm) == "Existing Tenant" + + +def test_equal_tenant_stays_referenced_when_overwrite_is_disabled(inventory): + # the resolved tenant is the one already set: the VM keeps it and the tenant object stays + # referenced by the source, so it keeps the source tag instead of losing it every run + cluster = _cluster(inventory) + tenant = inventory.add_object(NBTenant, data={"name": "Existing Tenant"}, read_from_netbox=True) + inventory.add_object(NBVM, data={"name": "vm1", "cluster": cluster, "status": "active", + "tenant": tenant}, read_from_netbox=True) + + src = _make_source(inventory, overwrite_vm_tenant=False) + vm = _sync_vm(src, cluster, tenant="Existing Tenant") + + assert _tenant_of(vm) == "Existing Tenant" + assert tenant.source is src + + +def test_equal_tenant_in_api_shape_stays_referenced_when_overwrite_is_disabled(inventory): + # same as above, with the existing tenant still in the shape the NetBox API returns it + cluster = _cluster(inventory) + tenant = inventory.add_object(NBTenant, data={"name": "Existing Tenant"}, read_from_netbox=True) + inventory.add_object(NBVM, data={"name": "vm1", "cluster": cluster, "status": "active", + "tenant": {"id": tenant.get_nb_reference(), "name": "Existing Tenant"}}, + read_from_netbox=True) + + src = _make_source(inventory, overwrite_vm_tenant=False) + vm = _sync_vm(src, cluster, tenant="Existing Tenant") + + assert _tenant_of(vm) == "Existing Tenant" + assert tenant.source is src + + +def test_existing_vm_tenant_is_overwritten_by_default(inventory): + cluster = _cluster(inventory) + tenant = inventory.add_object(NBTenant, data={"name": "Existing Tenant"}, read_from_netbox=True) + inventory.add_object(NBVM, data={"name": "vm1", "cluster": cluster, "status": "active", + "tenant": tenant}, read_from_netbox=True) + + src = _make_source(inventory) + vm = _sync_vm(src, cluster, tenant="Resolved Tenant") + + assert _tenant_of(vm) == "Resolved Tenant" + + +def test_vm_without_tenant_gets_the_resolved_tenant_even_when_overwrite_is_disabled(inventory): + cluster = _cluster(inventory) + inventory.add_object(NBVM, data={"name": "vm1", "cluster": cluster, "status": "active"}, + read_from_netbox=True) + + src = _make_source(inventory, overwrite_vm_tenant=False) + vm = _sync_vm(src, cluster, tenant="Resolved Tenant") + + assert _tenant_of(vm) == "Resolved Tenant" + + +# +# resource pool filters +# + +def test_vm_passes_when_no_resource_pool_filter_is_set(inventory): + src = _make_source(inventory) + + assert src.vm_passes_resource_pool_filter(_vm("vm-1", "vm1", pool=CHILD_POOL)) is True + assert src.vm_passes_resource_pool_filter(_vm("vm-2", "vm2", pool=None)) is True + + +def test_exclude_filter_matches_the_resource_pool_path(inventory): + src = _make_source(inventory, vm_exclude_by_resource_pool_filter=re.compile("^Parent")) + + assert src.vm_passes_resource_pool_filter(_vm("vm-1", "vm1", pool=CHILD_POOL)) is False + assert src.vm_passes_resource_pool_filter(_vm("vm-2", "vm2", pool=ROOT_POOL)) is True + + +def test_exclude_filter_matches_the_plain_pool_name(inventory): + src = _make_source(inventory, vm_exclude_by_resource_pool_filter=re.compile("^Child$")) + + assert src.vm_passes_resource_pool_filter(_vm("vm-1", "vm1", pool=CHILD_POOL)) is False + + +def test_root_pool_vm_is_judged_by_its_plain_pool_name(inventory): + vm = _vm("vm-1", "vm1", pool=ROOT_POOL) + + excluded = _make_source(inventory, vm_exclude_by_resource_pool_filter=re.compile("^Resources$")) + assert excluded.vm_passes_resource_pool_filter(vm) is False + + included = _make_source(inventory, vm_include_by_resource_pool_filter=re.compile("^Resources")) + assert included.vm_passes_resource_pool_filter(vm) is True + + +def test_include_filter_accepts_the_pool_path_or_the_plain_pool_name(inventory): + # one of the two identifiers has to match the include filter, not both: the plain name + # of a VM in a nested pool is the leaf pool only and can never match a path pattern + vm = _vm("vm-1", "vm1", pool=CHILD_POOL) + + by_path = _make_source(inventory, vm_include_by_resource_pool_filter=re.compile("^Parent/")) + assert by_path.vm_passes_resource_pool_filter(vm) is True + + by_name = _make_source(inventory, vm_include_by_resource_pool_filter=re.compile("^Child$")) + assert by_name.vm_passes_resource_pool_filter(vm) is True + + +def test_exclude_filter_wins_over_a_matching_include_filter(inventory): + src = _make_source(inventory, vm_include_by_resource_pool_filter=re.compile("^Parent/"), + vm_exclude_by_resource_pool_filter=re.compile("^Child$")) + + assert src.vm_passes_resource_pool_filter(_vm("vm-1", "vm1", pool=CHILD_POOL)) is False + + +def test_include_filter_excludes_vms_in_other_pools(inventory): + src = _make_source(inventory, vm_include_by_resource_pool_filter=re.compile("^Sandbox$")) + + assert src.vm_passes_resource_pool_filter(_vm("vm-1", "vm1", pool=CHILD_POOL)) is False diff --git a/tests/test_vmware_vm_tenant_vcsim.py b/tests/test_vmware_vm_tenant_vcsim.py new file mode 100644 index 0000000..1e91f9a --- /dev/null +++ b/tests/test_vmware_vm_tenant_vcsim.py @@ -0,0 +1,215 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020 - 2026 netbox-sync team. All rights reserved. +# +# netbox-sync.py +# +# This work is licensed under the terms of the MIT license. +# For a copy, see file LICENSE.txt included in this +# repository or visit: . + +""" +vcsim-backed tests for VM tenant assignment (issues #531, #463 and #454) and resource +pool based VM filtering (issue #494). + +The vc001 inventory organizes its 34 VMs in VM folders below the datacenter's "vm" +folder ("CBK", "CBK/Templates", "CBK/Backup", "CBK/vms-kg-160er", "CBK/vms-kg-160er/VX", +"CBK/vms-cha-170er", "CBK/vms-iaa-180er", "Customers/Xyz", "Customers/Abc", "vCLS") +while all of them sit directly in the single cluster root resource pool "Resources", +which gives every VM an empty pool path and the plain pool name "Resources". +""" +import pytest + +from module.netbox.object_classes import ( + NBCluster, NBDevice, NBIPAddress, NBTenant, NBVM, NBVMInterface, +) +from module.sources import instantiate_sources + + +def _sync(inventory, load_config, vmware_settings, extra_options=""): + """Run the real VMware source against the running vcsim with the given options.""" + load_config(vmware_settings + extra_options) + sources = instantiate_sources() + assert len(sources) == 1 and sources[0].init_successful, "VMware source failed to initialise" + inventory.resolve_relations() + sources[0].apply() + return sources[0] + + +def _empty(inventory): + """Start over with an empty inventory, the same way the fixture does.""" + inventory.base_structure = dict() + inventory.source_list = list() + inventory.init() + + +def _vms_by_name(inventory): + return {vm.get_display_name(): vm for vm in inventory.get_all_items(NBVM)} + + +def _tenant_of(nb_object): + tenant = nb_object.data.get("tenant") + return None if tenant is None else tenant.get_display_name() + + +def _mark_objects_as_saved(inventory): + """Mark the synced objects the way they look after a NetBox round trip.""" + next_id = 1 + for cls in (NBCluster, NBDevice, NBVM, NBVMInterface, NBIPAddress, NBTenant): + for item in inventory.get_all_items(cls): + item.is_new = False + item.nb_id = next_id + next_id += 1 + + +def test_folder_relation_assigns_tenants(vcsim, inventory, load_config, vmware_settings): + """A folder relation using real folder paths of the vc001 inventory (issues #463, #454).""" + if vcsim.name != "vc001": + pytest.skip("uses the VM folder layout of the vc001 inventory") + + _sync(inventory, load_config, vmware_settings, + "vm_tenant_folder_relation = Customers/.* = Customer Tenants\n") + + vms = _vms_by_name(inventory) + # matched via the folder paths "Customers/Xyz" and "Customers/Abc" + assert _tenant_of(vms["vm002_xyz-veeam"]) == "Customer Tenants" + assert _tenant_of(vms["vm169_CRFE-baraClient01"]) == "Customer Tenants" + # VMs in unrelated folders stay without a tenant + assert _tenant_of(vms["z_vc001"]) is None + assert _tenant_of(vms["tmp01_W2k22"]) is None + + +def test_resource_pool_relation_assigns_tenants(vcsim, inventory, load_config, vmware_settings): + """A resource pool relation using the real pool name of the vc001 inventory (issue #463).""" + if vcsim.name != "vc001": + pytest.skip("uses the resource pool layout of the vc001 inventory") + + _sync(inventory, load_config, vmware_settings, + "vm_tenant_resource_pool_relation = ^Resources$ = Pool Tenant\n") + + vms = _vms_by_name(inventory) + assert vms, "no VM was synced" + # every vc001 VM sits in the cluster root pool, matched by its plain pool name + for vm in vms.values(): + assert _tenant_of(vm) == "Pool Tenant", vm.get_display_name() + + +def test_tenant_relation_precedence(vcsim, inventory, load_config, vmware_settings): + """The name relation beats the pool relation, the pool relation beats the folder one.""" + if vcsim.name != "vc001": + pytest.skip("uses the VM folder layout of the vc001 inventory") + + _sync(inventory, load_config, vmware_settings, + "vm_tenant_relation = ^vm150-.* = Name Tenant\n" + "vm_tenant_resource_pool_relation = ^Resources$ = Pool Tenant\n" + "vm_tenant_folder_relation = Customers/.* = Folder Tenant\n") + + vms = _vms_by_name(inventory) + assert _tenant_of(vms["vm150-w2k22"]) == "Name Tenant" + # both sit in the "Customers" folders, but the resource pool relation wins + assert _tenant_of(vms["vm002_xyz-veeam"]) == "Pool Tenant" + assert _tenant_of(vms["vm169_CRFE-baraClient01"]) == "Pool Tenant" + + +def test_folder_relation_wins_when_no_pool_relation_matches(vcsim, inventory, load_config, vmware_settings): + if vcsim.name != "vc001": + pytest.skip("uses the VM folder layout of the vc001 inventory") + + _sync(inventory, load_config, vmware_settings, + "vm_tenant_resource_pool_relation = ^Sandbox$ = Pool Tenant\n" + "vm_tenant_folder_relation = Customers/.* = Folder Tenant\n") + + vms = _vms_by_name(inventory) + assert _tenant_of(vms["vm002_xyz-veeam"]) == "Folder Tenant" + assert _tenant_of(vms["vm169_CRFE-baraClient01"]) == "Folder Tenant" + # no pool matched and no folder matched: no tenant + assert _tenant_of(vms["z_vc001"]) is None + + +def test_vm_tenants_are_inherited_from_the_cluster(vcsim, inventory, load_config, vmware_settings): + """The tenant inheritance asked for in issue #531.""" + _sync(inventory, load_config, vmware_settings, + "cluster_tenant_relation = .* = Cluster Tenant\n" + "vm_tenant_inherit_from_cluster = True\n") + + vms = _vms_by_name(inventory) + assert vms, "no VM was synced" + for vm in vms.values(): + assert _tenant_of(vm) == "Cluster Tenant", vm.get_display_name() + + +def test_vm_ip_addresses_inherit_the_vm_tenant(vcsim, inventory, load_config, vmware_settings): + """The 'device' step of 'ip_tenant_inheritance_order' hands the VM tenant to its IPs (issue #531).""" + _sync(inventory, load_config, vmware_settings, + "vm_tenant_resource_pool_relation = ^Resources$ = Pool Tenant\n" + "ip_tenant_inheritance_order = device\n") + + vm_ip_addresses = 0 + for ip in inventory.get_all_items(NBIPAddress): + if not isinstance(ip.get_interface(), NBVMInterface): + continue + vm_ip_addresses += 1 + tenant = ip.data.get("tenant") + assert tenant is not None, \ + f"IP '{ip.data.get('address')}' of a tenanted VM has no tenant" + assert tenant.get_display_name() == "Pool Tenant" + + if vm_ip_addresses == 0: + pytest.skip("no guest IP addresses in this inventory") + + +def test_exclude_filter_skips_vms_in_the_matching_pool(vcsim, inventory, load_config, vmware_settings): + """The resource pool exclude filter asked for in issue #494.""" + if vcsim.name != "vc001": + pytest.skip("asserts the resource pool layout of the vc001 inventory") + + _sync(inventory, load_config, vmware_settings, + "vm_exclude_by_resource_pool_filter = ^Resources$\n") + + # every vc001 VM sits in the cluster root pool "Resources" and is skipped by it + assert len(list(inventory.get_all_items(NBVM))) == 0 + # hosts and clusters are not affected by a VM filter + assert len(list(inventory.get_all_items(NBDevice))) > 0 + assert len(list(inventory.get_all_items(NBCluster))) > 0 + + +def test_include_filter_keeps_vms_of_the_matching_pool(vcsim, inventory, load_config, vmware_settings): + """An include filter matching the plain pool name keeps root pool VMs (issue #494).""" + if vcsim.name != "vc001": + pytest.skip("asserts the resource pool layout of the vc001 inventory") + + _sync(inventory, load_config, vmware_settings) + unfiltered = len(_vms_by_name(inventory)) + assert unfiltered > 0 + + _empty(inventory) + _sync(inventory, load_config, vmware_settings, + "vm_include_by_resource_pool_filter = ^Resources\n") + assert len(_vms_by_name(inventory)) == unfiltered, \ + "the empty pool path must not filter out root pool VMs" + + _empty(inventory) + _sync(inventory, load_config, vmware_settings, + "vm_include_by_resource_pool_filter = ^Nowhere$\n") + assert len(_vms_by_name(inventory)) == 0 + + +def test_existing_vm_tenant_is_kept_when_overwrite_is_disabled(vcsim, inventory, load_config, + vmware_settings): + _sync(inventory, load_config, vmware_settings, + "vm_tenant_resource_pool_relation = ^Resources$ = Tenant A\n") + + vms = _vms_by_name(inventory) + assert vms + for vm in vms.values(): + assert _tenant_of(vm) == "Tenant A" + + _mark_objects_as_saved(inventory) + + # a second run resolves a different tenant but must keep the one already set + _sync(inventory, load_config, vmware_settings, + "vm_tenant_resource_pool_relation = ^Resources$ = Tenant B\n" + "overwrite_vm_tenant = False\n") + + vms = _vms_by_name(inventory) + for vm in vms.values(): + assert _tenant_of(vm) == "Tenant A", vm.get_display_name()