diff --git a/module/sources/vmware/config.py b/module/sources/vmware/config.py index b63f499..a7e39e2 100644 --- a/module/sources/vmware/config.py +++ b/module/sources/vmware/config.py @@ -333,6 +333,25 @@ def __init__(self): description="""If an IP address is assigned to a FHRP group (like HSRP, VRRP, GLBP) then this IP address will be skipped and not synced to NetBox to prevent incorrect syncing.""", default_value=False), + ConfigOption("vm_status_on_create", + str, + description="""defines the status a VM gets assigned in NetBox when netbox-sync + creates it as a new NetBox VM. Updates of already existing NetBox VMs are not + affected by this option. This way new VMs can start their lifecycle in NetBox + as i.e. 'planned' until changed manually in NetBox. + possible values: offline, active, planned, staged, failed, decommissioning + """, + config_example="planned"), + ConfigOption("vm_status_preserve", + str, + description="""defines a comma separated list of NetBox VM statuses which will be + preserved on updates. If the current status of an existing NetBox VM matches one of + these values then netbox-sync will not change the status of this VM. This way VMs + can be kept in i.e. 'planned' or 'staged' until changed manually in NetBox. + Set to an empty value to always update the VM status. + possible values: offline, active, planned, staged, failed, decommissioning + """, + config_example="planned, staged, decommissioning"), ConfigOption("strip_host_domain_name", bool, description="strip domain part from host name before syncing device to NetBox", @@ -658,6 +677,28 @@ def validate_options(self): log.error(f"Primary IP option '{option.key}' value '{option.value}' invalid.") self.set_validation_failed() + # keep in sync with NBVM data_model status values in module/netbox/object_classes.py + valid_vm_statuses = ["offline", "active", "planned", "staged", "failed", "decommissioning"] + + if option.key == "vm_status_on_create": + option.set_value(option.value.lower()) + if option.value not in valid_vm_statuses: + log.error(f"Config option '{option.key}' value '{option.value}' invalid. " + f"Possible values: {', '.join(valid_vm_statuses)}") + self.set_validation_failed() + + continue + + if option.key == "vm_status_preserve": + option.set_value([x.lower() for x in quoted_split(option.value) or list()]) + for status_value in option.value: + if status_value not in valid_vm_statuses: + log.error(f"Config option '{option.key}' value '{status_value}' invalid. " + f"Possible values: {', '.join(valid_vm_statuses)}") + self.set_validation_failed() + + continue + if option.key == "custom_dns_servers": dns_name_lookup = self.get_option_by_name("dns_name_lookup") diff --git a/module/sources/vmware/connection.py b/module/sources/vmware/connection.py index aaa4972..97002ff 100644 --- a/module/sources/vmware/connection.py +++ b/module/sources/vmware/connection.py @@ -1240,6 +1240,11 @@ def add_device_vm_to_inventory(self, object_type, object_data, pnic_data=None, v if device_vm_object is None: object_name = object_data.get(object_type.primary_key) log.debug(f"No existing {object_type.name} object for {object_name}. Creating a new {object_type.name}.") + + if object_type == NBVM and self.settings.vm_status_on_create is not None and \ + object_data.get("status") is not None: + object_data["status"] = self.settings.vm_status_on_create + device_vm_object = self.inventory.add_object(object_type, data=object_data, source=self) else: @@ -1257,6 +1262,16 @@ def add_device_vm_to_inventory(self, object_type, object_data, pnic_data=None, v self.hardware_identifier_is_unknown(grab(object_data, "device_type.model")): del object_data["device_type"] + if object_type == NBVM and object_data.get("status") is not None: + current_status = grab(device_vm_object, "data.status") + if isinstance(current_status, dict): + current_status = current_status.get("value") + if current_status in (self.settings.vm_status_preserve or list()): + log.debug2(f"Current status '{current_status}' of " + f"'{device_vm_object.get_display_name()}' is in 'vm_status_preserve' list. " + f"Not updating VM status.") + del object_data["status"] + device_vm_object.update(data=object_data, source=self) # add object to cache diff --git a/settings-example.ini b/settings-example.ini index 7326325..a06467b 100644 --- a/settings-example.ini +++ b/settings-example.ini @@ -334,6 +334,21 @@ password = super-secret ; address will be skipped and not synced to NetBox to prevent incorrect syncing. ;skip_fhrp_group_ips = False +; defines the status a VM gets assigned in NetBox when netbox-sync creates it as a new +; NetBox VM. Updates of already existing NetBox VMs are not affected by this option. This +; way new VMs can start their lifecycle in NetBox as i.e. 'planned' until changed manually +; in NetBox. Unset by default, the status then follows the power state as before. +; possible values: offline, active, planned, staged, failed, decommissioning +;vm_status_on_create = planned + +; defines a comma separated list of NetBox VM statuses which will be preserved on updates. +; If the current status of an existing NetBox VM matches one of these values then netbox- +; sync will not change the status of this VM. This way VMs can be kept in i.e. 'planned' +; or 'staged' until changed manually in NetBox. Set to an empty value to always update the +; VM status. Unset by default, so the status always follows the power state as before. +; possible values: offline, active, planned, staged, failed, decommissioning +;vm_status_preserve = planned, staged, decommissioning + ; strip domain part from host name before syncing device to NetBox ;strip_host_domain_name = False diff --git a/tests/test_vmware_vm_status_options.py b/tests/test_vmware_vm_status_options.py new file mode 100644 index 0000000..7a475d6 --- /dev/null +++ b/tests/test_vmware_vm_status_options.py @@ -0,0 +1,119 @@ +""" +vm_status_on_create and vm_status_preserve are opt-in: unset, a VM's status keeps +following its power state exactly as before (PR #528). +""" +from types import SimpleNamespace + +import pytest + +from module.netbox.object_classes import NBCluster, NBClusterType, NBSite, 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 +""" + + +def _make_source(inventory, status_on_create=None, status_preserve=None): + src = object.__new__(VMWareHandler) + src.inventory = inventory + src.name = "test" + src.source_tag = "Source: test" + src.object_cache = dict() + src.settings = SimpleNamespace( + 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=False, + overwrite_vm_platform=False, + 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=status_on_create, + vm_status_preserve=status_preserve, + ) + return src + + +def _cluster(inventory): + site = inventory.add_object(NBSite, data={"name": "site1"}, read_from_netbox=True) + ctype = inventory.add_object(NBClusterType, data={"name": "vmware"}, read_from_netbox=True) + return inventory.add_object(NBCluster, data={"name": "c1", "type": ctype, "scope": site}, + read_from_netbox=True) + + +def _sync_vm(src, cluster, name="vm1", status="active"): + # the handler stores the object in the inventory, it does not hand it back + src.add_device_vm_to_inventory( + NBVM, object_data={"name": name, "cluster": cluster, "status": status}, + pnic_data=dict(), vnic_data=dict()) + return src.inventory.get_by_data(NBVM, data={"name": name, "cluster": cluster}) + + +def _status_of(vm): + status = vm.data.get("status") + return status.get("value") if isinstance(status, dict) else status + + +@pytest.mark.parametrize("power_state_status", ["active", "offline"]) +def test_new_vm_keeps_the_power_state_status_by_default(inventory, power_state_status): + cluster = _cluster(inventory) + + vm = _sync_vm(_make_source(inventory), cluster, status=power_state_status) + + assert _status_of(vm) == power_state_status + + +def test_new_vm_gets_the_configured_status(inventory): + cluster = _cluster(inventory) + + vm = _sync_vm(_make_source(inventory, status_on_create="planned"), cluster, status="offline") + + assert _status_of(vm) == "planned" + + +def test_existing_vm_status_follows_the_power_state_by_default(inventory): + cluster = _cluster(inventory) + existing = inventory.add_object(NBVM, data={"name": "vm1", "cluster": cluster, "status": "planned"}, + read_from_netbox=True) + + vm = _sync_vm(_make_source(inventory), cluster, status="active") + + assert vm is existing + assert _status_of(vm) == "active" + + +def test_existing_vm_status_is_preserved_when_listed(inventory): + cluster = _cluster(inventory) + existing = inventory.add_object(NBVM, data={"name": "vm1", "cluster": cluster, "status": "planned"}, + read_from_netbox=True) + + vm = _sync_vm(_make_source(inventory, status_preserve=["planned"]), cluster, status="active") + + assert vm is existing + assert _status_of(vm) == "planned" + + +def test_both_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 settings.vm_status_on_create is None + assert not settings.vm_status_preserve