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