diff --git a/docs/source_vmware.md b/docs/source_vmware.md index 11f1d03e..5fb2b31f 100644 --- a/docs/source_vmware.md +++ b/docs/source_vmware.md @@ -107,3 +107,44 @@ Primary IPv4/6 will be determined by interface that provides the default route f **Note:**
IP address information can only be extracted if guest tools are installed and running. + +#### 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 +guest OS by VMware Tools are not always identical. This can happen if: +* a VM was renamed in vCenter but the OS hostname was not changed (or vice versa) +* an OS administrator changed the hostname without informing the virtualization team +* a VM was cloned and the guest hostname was not adjusted afterwards + +To make this drift visible in NetBox, the option `vm_guest_hostname_custom_field` can be set to +the name of a NetBox custom field. If set, the guest hostname reported by VMware Tools (vCenter +property `guest.hostName`) is synced into that custom field on every synced VM, while the NetBox +VM `name` keeps reflecting the vCenter inventory name unchanged. This allows administrators to +compare the NetBox VM name against the custom field value on the same VM record to spot naming +drift. + +```ini +vm_guest_hostname_custom_field = vmware_guest_hostname +``` + +If the custom field does not exist in NetBox yet it will be created automatically (type `Text`, +assigned to the `Virtual Machine` object type), the same way other custom fields managed by +netbox-sync (e.g. `vcsa_*` custom attributes) are created. An administrator can also pre-create +the field beforehand; netbox-sync will then just use the existing field. + +This option is unset by default, so the feature is disabled and no additional custom field is +created unless explicitly configured. + +VMware Tools may not always report a hostname, for example if Tools are not installed, not +running, outdated, or simply have not reported guest information yet. In that case +netbox-sync leaves the custom field untouched and keeps the last known value, instead of +clearing it. A message is logged at debug level (`-l DEBUG2`) whenever this happens. + +Example result in NetBox for a VM named `APPPRD01` in vCenter whose guest OS reports +`appprd01.corp.example.com` as its hostname: + +```text +Virtual Machine Name: APPPRD01 +Custom Fields: + VMware Guest Hostname: appprd01.corp.example.com +``` diff --git a/module/sources/vmware/config.py b/module/sources/vmware/config.py index e4d8e2bc..6971f97b 100644 --- a/module/sources/vmware/config.py +++ b/module/sources/vmware/config.py @@ -305,6 +305,19 @@ def __init__(self): str, config_example="config.uuid") ]), + ConfigOption("vm_guest_hostname_custom_field", + str, + description="""defines the name of a NetBox custom field which is used to store the + hostname reported by VMware Tools from inside the guest OS (vCenter property + 'guest.hostName'). This is independent of the vCenter VM inventory name and can be + used to detect naming drift between the vCenter VM name and the actual OS hostname. + The custom field must be of type "Text" and assigned to the "Virtual Machine" object + type. If it does not exist yet, it will be created automatically, the same way other + netbox-sync managed custom fields are created. + If this option is unset (default) the guest hostname is not synced. + If VMware Tools does not report a hostname (not installed, not running or no data yet) + the custom field is left untouched so any previously synced value is preserved.""", + config_example="vmware_guest_hostname"), ConfigOption("set_source_name_as_cluster_group", bool, description="""this will set the sources name as cluster group name instead of the datacenter. diff --git a/module/sources/vmware/connection.py b/module/sources/vmware/connection.py index e63763c9..120c8c64 100644 --- a/module/sources/vmware/connection.py +++ b/module/sources/vmware/connection.py @@ -825,6 +825,28 @@ def get_object_custom_fields(self, obj): return_custom_fields[grab(custom_field, "data.name")] = f"{memory_size} {memory_unit}" + # add VMware Tools guest hostname to VM + if object_type == "virtualization.virtualmachine" and self.settings.vm_guest_hostname_custom_field: + + guest_hostname_field_name = self.settings.vm_guest_hostname_custom_field + guest_hostname = get_string_or_none(grab(obj, "guest.hostName")) + + if guest_hostname is not None: + custom_field = self.add_update_custom_field({ + "name": guest_hostname_field_name, + "label": "VMware Guest Hostname", + "object_types": [object_type], + "type": "text", + "description": "Hostname reported by VMware Tools from inside the guest OS" + }) + + return_custom_fields[grab(custom_field, "data.name")] = guest_hostname + + else: + log.debug2(f"VM '{grab(obj, 'name')}' guest hostname not reported by VMware Tools " + "(not installed, not running or no data available yet). Keeping current " + f"value of custom field '{guest_hostname_field_name}' untouched.") + field_definition = {grab(k, "key"): grab(k, "name") for k in grab(obj, "availableField", fallback=list())} for obj_custom_field in custom_value: diff --git a/settings-example.ini b/settings-example.ini index ec2d5f1f..c547ca61 100644 --- a/settings-example.ini +++ b/settings-example.ini @@ -321,6 +321,17 @@ password = super-secret ;host_custom_object_attributes = summary.runtime.bootTime ;vm_custom_object_attributes = config.uuid +; defines the name of a NetBox custom field which is used to store the hostname reported +; by VMware Tools from inside the guest OS (vCenter property 'guest.hostName'). This is +; independent of the vCenter VM inventory name and can be used to detect naming drift +; between the vCenter VM name and the actual OS hostname. The custom field must be of type +; "Text" and assigned to the "Virtual Machine" object type. If it does not exist yet, it +; will be created automatically, the same way other netbox-sync managed custom fields are +; created. If this option is unset (default) the guest hostname is not synced. If VMware +; Tools does not report a hostname (not installed, not running or no data yet) the custom +; field is left untouched so any previously synced value is preserved. +;vm_guest_hostname_custom_field = vmware_guest_hostname + ; this will set the sources name as cluster group name instead of the datacenter. This ; works if the vCenter has ONLY ONE datacenter configured. Otherwise it will rename all ; datacenters to the source name! diff --git a/tests/test_vmware_guest_hostname.py b/tests/test_vmware_guest_hostname.py new file mode 100644 index 00000000..0ee17937 --- /dev/null +++ b/tests/test_vmware_guest_hostname.py @@ -0,0 +1,258 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020 - 2026 Ricardo Bartels. 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: . + +""" +Unit tests for the "sync VMware Tools guest hostname to a NetBox custom field" feature. + +Covers module.sources.vmware.connection.VMWareHandler.get_object_custom_fields() as well as the +generic custom field handling in module.netbox.object_classes.NetBoxObject that this feature relies on. + +Run with: python -m unittest discover -s tests +""" + +import os +import sys +import types +import unittest + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from module.netbox.inventory import NetBoxInventory # noqa: E402 +from module.netbox.object_classes import NBCustomField, NBVM # noqa: E402 +from module.sources.vmware.connection import VMWareHandler # noqa: E402 +from module.sources.vmware.config import VMWareConfig # noqa: E402 + + +def make_vm_obj(hostname=None, name="APPPRD01", guest=True): + """ + Build a minimal fake pyVmomi VirtualMachine object, providing just the + attributes accessed by get_object_custom_fields()/add_virtual_machine(). + """ + + guest_info = None + if guest: + guest_info = types.SimpleNamespace(hostName=hostname) + + return types.SimpleNamespace( + _wsdlName="VirtualMachine", + name=name, + guest=guest_info, + customValue=[], + availableField=[], + ) + + +class GuestHostnameCustomFieldTestCase(unittest.TestCase): + """ + Tests the guest hostname handling in VMWareHandler.get_object_custom_fields() directly, + without requiring a live vCenter connection. + """ + + def setUp(self): + self.inventory = NetBoxInventory() + # isolate tests from each other, inventory is a process wide singleton + self.inventory.base_structure[NBCustomField.name] = [] + self.inventory.base_structure[NBVM.name] = [] + self.inventory.netbox_api_version = "4.1.0" + + # build a VMWareHandler instance without running its network-bound __init__ + self.handler = VMWareHandler.__new__(VMWareHandler) + self.handler.inventory = self.inventory + self.handler.name = "test-vcenter" + self.handler.settings = types.SimpleNamespace( + sync_custom_attributes=False, + vm_custom_object_attributes=[], + host_custom_object_attributes=[], + custom_attribute_exclude=None, + vm_guest_hostname_custom_field=None, + ) + + # 8. feature not configured -> unchanged/backward compatible behavior + def test_feature_disabled_by_default(self): + obj = make_vm_obj(hostname="appprd01.corp.example.com") + + result = self.handler.get_object_custom_fields(obj) + + self.assertEqual(result, {}) + self.assertEqual(len(self.inventory.get_all_items(NBCustomField)), 0) + + # 1. VMware Tools reports a valid hostname + def test_valid_hostname_is_synced_and_field_is_created(self): + self.handler.settings.vm_guest_hostname_custom_field = "vmware_guest_hostname" + obj = make_vm_obj(hostname="appprd01") + + result = self.handler.get_object_custom_fields(obj) + + self.assertEqual(result, {"vmware_guest_hostname": "appprd01"}) + + field = self.inventory.get_by_data(NBCustomField, data={"name": "vmware_guest_hostname"}) + self.assertIsNotNone(field) + self.assertEqual(field.data.get("type"), "text") + self.assertIn("virtualization.virtualmachine", field.data.get("object_types")) + + # 2. VMware Tools hostname is an FQDN -> preserved exactly, no domain stripping/case changes + def test_fqdn_hostname_preserved_exactly(self): + self.handler.settings.vm_guest_hostname_custom_field = "vmware_guest_hostname" + obj = make_vm_obj(hostname="srv01.Example.LOCAL") + + result = self.handler.get_object_custom_fields(obj) + + self.assertEqual(result["vmware_guest_hostname"], "srv01.Example.LOCAL") + + # 3. VMware Tools returns no hostname (empty string) + def test_empty_hostname_is_not_synced(self): + self.handler.settings.vm_guest_hostname_custom_field = "vmware_guest_hostname" + obj = make_vm_obj(hostname="") + + result = self.handler.get_object_custom_fields(obj) + + self.assertNotIn("vmware_guest_hostname", result) + + # 4. VMware Tools unavailable entirely (no guest info at all) + def test_vmware_tools_unavailable_does_not_raise(self): + self.handler.settings.vm_guest_hostname_custom_field = "vmware_guest_hostname" + obj = make_vm_obj(guest=False) + + result = self.handler.get_object_custom_fields(obj) + + self.assertNotIn("vmware_guest_hostname", result) + + # 5. configured custom field already exists (e.g. pre-created by an administrator) + def test_pre_existing_field_label_is_preserved(self): + self.inventory.add_object(NBCustomField, data={ + "name": "vmware_guest_hostname", + "label": "Guest OS Hostname (Admin Managed)", + "object_types": ["virtualization.virtualmachine"], + "type": "text", + }, read_from_netbox=True) + + self.handler.settings.vm_guest_hostname_custom_field = "vmware_guest_hostname" + obj = make_vm_obj(hostname="appprd01") + + result = self.handler.get_object_custom_fields(obj) + + self.assertEqual(result["vmware_guest_hostname"], "appprd01") + field = self.inventory.get_by_data(NBCustomField, data={"name": "vmware_guest_hostname"}) + self.assertEqual(field.data.get("label"), "Guest OS Hostname (Admin Managed)") + + # 6. configured custom field does not exist yet -> created automatically, sync does not fail + def test_missing_field_is_created_automatically(self): + self.handler.settings.vm_guest_hostname_custom_field = "vmware_guest_hostname" + self.assertIsNone(self.inventory.get_by_data(NBCustomField, data={"name": "vmware_guest_hostname"})) + + obj = make_vm_obj(hostname="appprd01") + result = self.handler.get_object_custom_fields(obj) + + self.assertEqual(result["vmware_guest_hostname"], "appprd01") + self.assertIsNotNone(self.inventory.get_by_data(NBCustomField, data={"name": "vmware_guest_hostname"})) + + # 7. guest hostname changes between synchronization runs + def test_hostname_change_between_runs_updates_vm(self): + self.handler.settings.vm_guest_hostname_custom_field = "vmware_guest_hostname" + + obj = make_vm_obj(hostname="appprd01") + vm = self.inventory.add_update_object(NBVM, data={ + "name": "APPPRD01", + "custom_fields": self.handler.get_object_custom_fields(obj), + }) + self.assertEqual(vm.data["custom_fields"]["vmware_guest_hostname"], "appprd01") + + obj_renamed = make_vm_obj(hostname="appprd02") + vm.update(data={"custom_fields": self.handler.get_object_custom_fields(obj_renamed)}) + + self.assertEqual(vm.data["custom_fields"]["vmware_guest_hostname"], "appprd02") + + # unavailable data must preserve previously synced value instead of clearing it + def test_unavailable_hostname_preserves_previous_value(self): + self.handler.settings.vm_guest_hostname_custom_field = "vmware_guest_hostname" + + obj = make_vm_obj(hostname="appprd01") + vm = self.inventory.add_update_object(NBVM, data={ + "name": "APPPRD01", + "custom_fields": self.handler.get_object_custom_fields(obj), + }) + self.assertEqual(vm.data["custom_fields"]["vmware_guest_hostname"], "appprd01") + + # VMware Tools stops reporting a hostname on a later run + obj_no_tools = make_vm_obj(hostname="") + custom_fields_no_tools = self.handler.get_object_custom_fields(obj_no_tools) + self.assertNotIn("vmware_guest_hostname", custom_fields_no_tools) + + vm.update(data={"custom_fields": custom_fields_no_tools}) + + self.assertEqual(vm.data["custom_fields"]["vmware_guest_hostname"], "appprd01") + + # 9. existing (unrelated) custom field VM sync behavior remains unchanged + def test_unrelated_custom_fields_unaffected_when_feature_disabled(self): + obj = make_vm_obj(hostname="appprd01") + obj.customValue = [types.SimpleNamespace(key=1, value="42")] + obj.availableField = [types.SimpleNamespace(key=1, name="Ticket")] + self.handler.settings.sync_custom_attributes = True + + result = self.handler.get_object_custom_fields(obj) + + self.assertIn("vcsa_ticket", result) + self.assertNotIn("vmware_guest_hostname", result) + + +class CustomFieldNotFoundBehaviorTestCase(unittest.TestCase): + """ + Verifies the generic safety net (module.netbox.object_classes.NetBoxObject) this feature + relies on when a configured custom field name is unknown to the inventory: the whole VM + sync must not crash, only the custom_fields update for that pass is skipped. + """ + + def setUp(self): + self.inventory = NetBoxInventory() + self.inventory.base_structure[NBCustomField.name] = [] + self.inventory.base_structure[NBVM.name] = [] + self.inventory.netbox_api_version = "4.1.0" + + def test_unknown_custom_field_is_skipped_not_fatal(self): + vm = self.inventory.add_object(NBVM, data={"name": "APPPRD01"}) + + # should not raise, even though "does_not_exist_field" was never registered + vm.update(data={"custom_fields": {"does_not_exist_field": "value"}}) + + self.assertNotIn("does_not_exist_field", (vm.data.get("custom_fields") or {})) + self.assertEqual(vm.data.get("name"), "APPPRD01") + + +class VMWareConfigGuestHostnameOptionTestCase(unittest.TestCase): + """ + Verifies the 'vm_guest_hostname_custom_field' config option definition itself. + """ + + def _parse(self, source_settings): + config = VMWareConfig() + config.source_name = "my-vcenter-example" + config.config_content = { + "source": { + "my-vcenter-example": { + "type": "vmware", + "host_fqdn": "vcenter.example.com", + "username": "readonly", + "password": "secret", + **source_settings, + } + } + } + return config.parse(do_log=False) + + def test_option_defaults_to_disabled(self): + settings = self._parse({}) + self.assertIsNone(settings.vm_guest_hostname_custom_field) + + def test_option_value_is_passed_through_unmodified(self): + settings = self._parse({"vm_guest_hostname_custom_field": "vmware_guest_hostname"}) + self.assertEqual(settings.vm_guest_hostname_custom_field, "vmware_guest_hostname") + + +if __name__ == "__main__": + unittest.main()