From a78defa533948b26847f99486de9265825517da6 Mon Sep 17 00:00:00 2001 From: ifrantic Date: Tue, 28 Jul 2026 12:42:58 +0200 Subject: [PATCH 1/2] vmware: add tag_name_include_category option Include vCenter tag category as name prefix (CategoryName:TagName). Also fix vm_exclude_by_tag_filter comparing strings to NBTag objects. --- module/sources/vmware/config.py | 11 +++++++++++ module/sources/vmware/connection.py | 24 ++++++++++++++++++------ settings-example.ini | 8 ++++++++ 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/module/sources/vmware/config.py b/module/sources/vmware/config.py index e4d8e2bc..2cadc301 100644 --- a/module/sources/vmware/config.py +++ b/module/sources/vmware/config.py @@ -284,6 +284,17 @@ def __init__(self): ConfigOption("host_tag_source", str), ConfigOption("vm_tag_source", str) ]), + ConfigOption("tag_name_include_category", + bool, + description="""\ + If enabled, vCenter tag names synced to NetBox will include the vCenter category as a + prefix in the format 'CategoryName:TagName'. Useful if TagName and CategoryName is used + as key/value pairs in vCenter. + When changed, existing synced tags are replaced on + the next run. Note: vm_exclude_by_tag_filter entries must use 'CategoryName:TagName' + format when this option is enabled. + """, + default_value=False), ConfigOption("sync_custom_attributes", bool, description="""sync custom attributes defined for hosts and VMs diff --git a/module/sources/vmware/connection.py b/module/sources/vmware/connection.py index e63763c9..8c3d9666 100644 --- a/module/sources/vmware/connection.py +++ b/module/sources/vmware/connection.py @@ -681,21 +681,32 @@ def get_vmware_object_tags(self, obj): # noinspection PyBroadException try: - tag_name = self.tag_session.tagging.Tag.get(tag_id).name - tag_description = self.tag_session.tagging.Tag.get(tag_id).description + tag = self.tag_session.tagging.Tag.get(tag_id) # store the object + tag_name = tag.name + tag_description = tag.description except Exception as e: log.error(f"Unable to retrieve vCenter tag '{tag_id}' for '{obj.name}': {e}") - continue + continue # skip tag entirely if basic fetch fails - if tag_name is not None: + try: + category_name = self.tag_session.tagging.Category.get(tag.category_id).name + except Exception: + category_name = None # gracefully degrade — tag still gets added + if tag_name is not None: if tag_description is not None and len(f"{tag_description}") > 0: tag_description = f"{primary_tag_name}: {tag_description}" else: tag_description = primary_tag_name + if category_name and not self.settings.tag_name_include_category: + tag_description = f"{category_name}: {tag_description}" if tag_description else category_name + + effective_tag_name = f"{category_name}:{tag_name}" \ + if category_name and self.settings.tag_name_include_category else tag_name + tag_list.append(self.inventory.add_update_object(NBTag, data={ - "name": tag_name, + "name": effective_tag_name, "description": tag_description })) @@ -2230,8 +2241,9 @@ def add_virtual_machine(self, obj): vcenter_tags = self.collect_object_tags(obj) # check if VM tag excludes VM from being synced to NetBox + vcenter_tag_names = [NetBoxObject.extract_tag_name(t) for t in vcenter_tags] for sync_exclude_tag in self.settings.vm_exclude_by_tag_filter or list(): - if sync_exclude_tag in vcenter_tags: + if sync_exclude_tag in vcenter_tag_names: log.debug(f"Virtual machine vCenter tag '{sync_exclude_tag}' in matches 'vm_exclude_by_tag_filter'. " f"Skipping") return diff --git a/settings-example.ini b/settings-example.ini index ec2d5f1f..cc2c9de2 100644 --- a/settings-example.ini +++ b/settings-example.ini @@ -308,6 +308,14 @@ password = super-secret ;host_tag_source = ;vm_tag_source = +; If enabled, vCenter tag names synced to NetBox will include the vCenter category as a +; prefix in the format 'CategoryName:TagName'. Useful if TagName and CategoryName is used +; as key/value pairs in vCenter. +; When changed, existing synced tags are replaced on +; the next run. Note: vm_exclude_by_tag_filter entries must use 'CategoryName:TagName' +; format when this option is enabled. +;tag_name_include_category = False + ; sync custom attributes defined for hosts and VMs in vCenter to NetBox as custom fields ;sync_custom_attributes = False From 295c3ca30dd8336dd9ddb79b498934bbccb2b29f Mon Sep 17 00:00:00 2001 From: Sergey Sannikov Date: Thu, 10 Sep 2026 03:16:08 +0400 Subject: [PATCH 2/2] vmware: keep the category lookup and tag name change under the option --- module/sources/vmware/connection.py | 20 +++--- tests/test_vmware_tag_category.py | 94 +++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 10 deletions(-) create mode 100644 tests/test_vmware_tag_category.py diff --git a/module/sources/vmware/connection.py b/module/sources/vmware/connection.py index 4dd91601..2c5d1e2a 100644 --- a/module/sources/vmware/connection.py +++ b/module/sources/vmware/connection.py @@ -813,10 +813,13 @@ def get_vmware_object_tags(self, obj): log.error(f"Unable to retrieve vCenter tag '{tag_id}' for '{obj.name}': {e}") continue # skip tag entirely if basic fetch fails - try: - category_name = self.tag_session.tagging.Category.get(tag.category_id).name - except Exception: - category_name = None # gracefully degrade — tag still gets added + category_name = None + if bool(self.settings.tag_name_include_category) is True: + # noinspection PyBroadException + try: + category_name = self.tag_session.tagging.Category.get(tag.category_id).name + except Exception as e: + log.debug(f"Unable to retrieve category of vCenter tag '{tag_name}': {e}") if tag_name is not None: if tag_description is not None and len(f"{tag_description}") > 0: @@ -824,14 +827,11 @@ def get_vmware_object_tags(self, obj): else: tag_description = primary_tag_name - if category_name and not self.settings.tag_name_include_category: - tag_description = f"{category_name}: {tag_description}" if tag_description else category_name - - effective_tag_name = f"{category_name}:{tag_name}" \ - if category_name and self.settings.tag_name_include_category else tag_name + if category_name is not None: + tag_name = f"{category_name}:{tag_name}" tag_list.append(self.inventory.add_update_object(NBTag, data={ - "name": effective_tag_name, + "name": tag_name, "description": tag_description })) diff --git a/tests/test_vmware_tag_category.py b/tests/test_vmware_tag_category.py new file mode 100644 index 00000000..805f6290 --- /dev/null +++ b/tests/test_vmware_tag_category.py @@ -0,0 +1,94 @@ +""" +vCenter tag handling: the exclude filter has to compare tag names, and putting the +category into the tag name is opt-in and must not change anything else (PR #518). + +vcsim serves no tag API, so the tag session is faked where the tags themselves matter. +""" +from types import SimpleNamespace + +import pytest + +from module.netbox.object_classes import NBTag, NBVM +from module.sources import instantiate_sources +from module.sources.vmware import connection as vmware_connection +from module.sources.vmware.connection import VMWareHandler + + +@pytest.fixture(autouse=True) +def dynamic_id(monkeypatch): + """The vSphere automation SDK is optional and absent here, the tag call needs the type.""" + monkeypatch.setattr(vmware_connection, "DynamicID", + lambda **kwargs: SimpleNamespace(**kwargs), raising=False) + + +class _FakeTagging: + """The parts of the vSphere tagging API get_vmware_object_tags() uses.""" + + def __init__(self, name="prod", description="", category="env"): + self.category_lookups = 0 + tag = SimpleNamespace(name=name, description=description, category_id="cat-1") + outer = self + + class _Tag: + @staticmethod + def get(_tag_id): + return tag + + class _Category: + @staticmethod + def get(_category_id): + outer.category_lookups += 1 + return SimpleNamespace(name=category) + + class _TagAssociation: + @staticmethod + def list_attached_tags(_dynamic_id): + return ["tag-1"] + + self.Tag, self.Category, self.TagAssociation = _Tag, _Category, _TagAssociation + + +def _handler_with_tags(inventory, tagging, include_category=False): + handler = object.__new__(VMWareHandler) + handler.inventory = inventory + handler.name = "test" + handler.source_tag = "Source: test" + handler.tag_session = SimpleNamespace(tagging=tagging) + handler.settings = SimpleNamespace(tag_name_include_category=include_category) + return handler + + +def _collect(handler): + obj = SimpleNamespace(name="vm1", _wsdlName="VirtualMachine", _moId="vm-1") + return handler.get_vmware_object_tags(obj) + + +def test_tag_name_and_description_are_untouched_by_default(inventory): + tagging = _FakeTagging(description="production") + tags = _collect(_handler_with_tags(inventory, tagging)) + + assert [tag.get_display_name() for tag in tags] == ["prod"] + assert tags[0].data.get("description") == "NetBox-synced: production" + assert tagging.category_lookups == 0, "the category was looked up although the option is off" + + +def test_tag_name_includes_the_category_when_enabled(inventory): + tagging = _FakeTagging(description="production") + tags = _collect(_handler_with_tags(inventory, tagging, include_category=True)) + + assert [tag.get_display_name() for tag in tags] == ["env:prod"] + assert tags[0].data.get("description") == "NetBox-synced: production" + + +def test_vm_exclude_by_tag_filter_skips_matching_vms(vcsim, inventory, load_config, vmware_settings, monkeypatch): + load_config(vmware_settings + "vm_exclude_by_tag_filter = no-sync\n") + source = instantiate_sources()[0] + assert source.init_successful + + excluded = inventory.add_update_object(NBTag, data={"name": "no-sync"}) + monkeypatch.setattr(source, "collect_object_tags", lambda _obj: [excluded]) + + inventory.resolve_relations() + source.apply() + + assert list(inventory.get_all_items(NBVM)) == [], "vm_exclude_by_tag_filter did not exclude anything"