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