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()