Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions docs/source_vmware.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,44 @@ Primary IPv4/6 will be determined by interface that provides the default route f

**Note:**<br>
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
```
13 changes: 13 additions & 0 deletions module/sources/vmware/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 22 additions & 0 deletions module/sources/vmware/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
11 changes: 11 additions & 0 deletions settings-example.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down
258 changes: 258 additions & 0 deletions tests/test_vmware_guest_hostname.py
Original file line number Diff line number Diff line change
@@ -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: <https://opensource.org/licenses/MIT>.

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