diff --git a/cloudinit/sources/DataSourceVMware.py b/cloudinit/sources/DataSourceVMware.py index d69f3673185..eeeb3aa591e 100644 --- a/cloudinit/sources/DataSourceVMware.py +++ b/cloudinit/sources/DataSourceVMware.py @@ -28,10 +28,13 @@ import time from cloudinit import atomic_helper, dmi, net, netinfo, sources, util +from cloudinit.event import EventScope, EventType, userdata_to_events from cloudinit.log import loggers from cloudinit.sources.helpers.vmware.imc import guestcust_util from cloudinit.subp import ProcessExecutionError, subp, which +from typing import Dict, Set + PRODUCT_UUID_FILE_PATH = "/sys/class/dmi/id/product_uuid" LOG = logging.getLogger(__name__) @@ -53,6 +56,20 @@ WAIT_ON_NETWORK_IPV4 = "ipv4" WAIT_ON_NETWORK_IPV6 = "ipv6" +SUPPORTED_UPDATE_EVENTS = { + # Support network reconfiguration for the following events: + EventScope.NETWORK: { + EventType.BOOT, + EventType.BOOT_LEGACY, + EventType.BOOT_NEW_INSTANCE, + EventType.HOTPLUG, + # TODO(akutz) Add support for METADATA_CHANGE and USER_REQUEST when + # those events are supported by Cloud-Init. + } +} + +SUPPORTED_UPDATE_EVENTS_GUEST_INFO_KEY = "cloudinit.supported-events" + class DataSourceVMware(sources.DataSource): """ @@ -93,6 +110,8 @@ class DataSourceVMware(sources.DataSource): dsname = "VMware" + supported_update_events = copy.deepcopy(SUPPORTED_UPDATE_EVENTS) + def __init__(self, sys_cfg, distro, paths, ud_proc=None): sources.DataSource.__init__(self, sys_cfg, distro, paths, ud_proc) @@ -101,6 +120,12 @@ def __init__(self, sys_cfg, distro, paths, ud_proc=None): self.rpctool = None self.rpctool_fn = None + # The default list of configured update events should be the same as + # the list of supported update events. + self.default_update_events = copy.deepcopy( + self.supported_update_events + ) + # A list includes all possible data transports, each tuple represents # one data transport type. This datasource will try to get data from # each of transports follows the tuples order in this list. @@ -121,6 +146,18 @@ def _unpickle(self, ci_pkl_version: int) -> None: setattr(self, attr, None) if not hasattr(self, "cfg"): setattr(self, "cfg", {}) + if not hasattr(self, "supported_update_events"): + setattr( + self, + "supported_update_events", + copy.deepcopy(SUPPORTED_UPDATE_EVENTS), + ) + if not hasattr(self, "default_update_events"): + setattr( + self, + "default_update_events", + copy.deepcopy(self.supported_update_events), + ) if not hasattr(self, "possible_data_access_method_list"): setattr( self, @@ -229,6 +266,22 @@ def setup(self, is_new_instance): # persisted with the metadata. self.persist_instance_data() + def activate(self, cfg, is_new_instance): + """activate(cfg, is_new_instance) + + This is called before the init_modules will be called but after + the user-data and vendor-data have been fully processed. + + The cfg is fully up to date config, it contains a merged view of + system config, datasource config, user config, vendor config. + It should be used rather than the sys_cfg passed to __init__. + + is_new_instance is a boolean indicating if this is a new instance. + """ + + # Reflect the supported update events into guestinfo. + self.advertise_supported_events(cfg) + def _get_subplatform(self): get_key_name_fn = None if self.data_access_method == DATA_ACCESS_METHOD_ENVVAR: @@ -262,6 +315,23 @@ def network_config(self): } return self.metadata["network"]["config"] + def advertise_supported_events(self, cfg): + default_events: Dict[EventScope, Set[EventType]] = copy.deepcopy( + self.default_update_events + ) + user_events: Dict[EventScope, Set[EventType]] = userdata_to_events( + cfg.get("updates", {}) + ) + allowed_events = util.mergemanydict( + [ + copy.deepcopy(user_events), + copy.deepcopy(default_events), + ] + ) + return advertise_supported_events( + allowed_events, self.rpctool, self.rpctool_fn + ) + def get_instance_id(self): # Pull the instance ID out of the metadata if present. Otherwise # read the file /sys/class/dmi/id/product_uuid for the instance ID. @@ -527,6 +597,40 @@ def advertise_local_ip_addrs(host_info, rpctool, rpctool_fn): LOG.info("advertised local ipv6 address %s in guestinfo", local_ipv6) +def advertise_supported_events(supported_update_events, rpctool, rpctool_fn): + """ + advertise_supported_events publishes the types of supported events + to guestinfo + """ + if not rpctool or not rpctool_fn: + return + + sz = "" + for event_scope in supported_update_events: + if len(sz) > 0: + sz += "," + sz += "{}=".format(event_scope) + event_types = [] + for event_type in supported_update_events[event_scope]: + event_types.append("{}".format(event_type)) + event_types.sort() + szt = "" + for event_type in event_types: + if len(szt) > 0: + szt += ";" + szt += event_type + sz += szt + + if len(sz) > 0: + guestinfo_set_value( + SUPPORTED_UPDATE_EVENTS_GUEST_INFO_KEY, sz, rpctool, rpctool_fn + ) + LOG.info("advertised supported update events in guestinfo: %s", sz) + return sz + + return None + + def handle_returned_guestinfo_val(key, val): """ handle_returned_guestinfo_val returns the provided value if it is diff --git a/tests/unittests/sources/test_vmware.py b/tests/unittests/sources/test_vmware.py index cfeff6d53a7..e72cd824bae 100644 --- a/tests/unittests/sources/test_vmware.py +++ b/tests/unittests/sources/test_vmware.py @@ -14,6 +14,7 @@ import pytest from cloudinit import dmi, helpers, safeyaml, settings, util +from cloudinit.event import EventScope, EventType from cloudinit.sources import DataSourceVMware from cloudinit.sources.helpers.vmware.imc import guestcust_util from cloudinit.subp import ProcessExecutionError @@ -106,6 +107,13 @@ "addr": "fd42:baa2:3dd:17a:216:3eff:fe16:db54", } +VMW_EXPECTED_DEFAULT_EVENTS = { + EventType.BOOT, + EventType.BOOT_LEGACY, + EventType.BOOT_NEW_INSTANCE, + EventType.HOTPLUG, +} + def generate_test_netdev_data(ipv4=None, ipv6=None): ipv4 = ipv4 or [] @@ -414,6 +422,16 @@ def test_wait_on_network(self, m_fn): self.assertTrue(host_info[DataSourceVMware.LOCAL_IPV4]) self.assertTrue(host_info[DataSourceVMware.LOCAL_IPV4] == "10.10.10.1") + @mock.patch("cloudinit.sources.DataSourceVMware.guestinfo_set_value") + def test_advertise_supported_events(self, m_set_fn): + sz = DataSourceVMware.advertise_supported_events( + DataSourceVMware.SUPPORTED_UPDATE_EVENTS, "rpctool", len + ) + self.assertEqual(1, m_set_fn.call_count) + self.assertEqual( + "network=boot;boot-legacy;boot-new-instance;hotplug", sz + ) + class TestDataSourceVMwareEnvVars(FilesystemMockingTestCase): """ @@ -469,6 +487,16 @@ def test_get_subplatform(self, m_fn): ), ) + # Test to ensure that network is configured from metadata on each boot. + self.assertSetEqual( + VMW_EXPECTED_DEFAULT_EVENTS, + ds.default_update_events[EventScope.NETWORK], + ) + self.assertSetEqual( + VMW_EXPECTED_DEFAULT_EVENTS, + ds.supported_update_events[EventScope.NETWORK], + ) + @mock.patch( "cloudinit.sources.DataSourceVMware.guestinfo_envvar_get_value" ) @@ -602,6 +630,16 @@ def test_get_subplatform(self, m_which_fn, m_fn): ), ) + # Test to ensure that network is configured from metadata on each boot. + self.assertSetEqual( + VMW_EXPECTED_DEFAULT_EVENTS, + ds.default_update_events[EventScope.NETWORK], + ) + self.assertSetEqual( + VMW_EXPECTED_DEFAULT_EVENTS, + ds.supported_update_events[EventScope.NETWORK], + ) + @mock.patch("cloudinit.sources.DataSourceVMware.guestinfo_get_value") @mock.patch("cloudinit.sources.DataSourceVMware.which") def test_get_data_metadata_with_vmware_rpctool(self, m_which_fn, m_fn): @@ -702,6 +740,40 @@ def test_get_data_metadata_gz_b64(self, m_which_fn, m_fn): m_fn.side_effect = [data, "gz+b64", "", ""] self.assert_get_data_ok(m_fn, m_fn_call_count=4) + @mock.patch("cloudinit.sources.DataSourceVMware.guestinfo_set_value") + @mock.patch("cloudinit.sources.DataSourceVMware.guestinfo_get_value") + @mock.patch("cloudinit.sources.DataSourceVMware.which") + def test_advertise_supported_events(self, m_which_fn, m_get_fn, m_set_fn): + m_which_fn.side_effect = ["vmtoolsd", "vmware-rpctool"] + m_get_fn.side_effect = [VMW_METADATA_YAML, "", "", "", "", ""] + ds = self.assert_get_data_ok(m_get_fn, m_fn_call_count=4) + sz = ds.advertise_supported_events({}) + self.assertEqual(1, m_set_fn.call_count) + self.assertEqual( + "network=boot;boot-legacy;boot-new-instance;hotplug", sz + ) + + @mock.patch("cloudinit.sources.DataSourceVMware.guestinfo_set_value") + @mock.patch("cloudinit.sources.DataSourceVMware.guestinfo_get_value") + @mock.patch("cloudinit.sources.DataSourceVMware.which") + def test_advertise_supported_events_with_events_from_user_data( + self, m_which_fn, m_get_fn, m_set_fn + ): + m_which_fn.side_effect = ["vmtoolsd", "vmware-rpctool"] + m_get_fn.side_effect = [VMW_METADATA_YAML, "", "", "", "", ""] + ds = self.assert_get_data_ok(m_get_fn, m_fn_call_count=4) + sz = ds.advertise_supported_events( + { + "updates": { + "network": { + "when": ["boot"], + }, + }, + } + ) + self.assertEqual(1, m_set_fn.call_count) + self.assertEqual("network=boot", sz) + class TestDataSourceVMwareGuestInfo_InvalidPlatform(FilesystemMockingTestCase): """ @@ -746,6 +818,81 @@ def setUp(self): self.datasource = DataSourceVMware.DataSourceVMware self.tdir = self.tmp_dir() + def test_get_subplatform(self): + paths = helpers.Paths({"cloud_dir": self.tdir}) + ds = self.datasource( + sys_cfg={"disable_vmware_customization": True}, + distro={}, + paths=paths, + ) + # Prepare the conf file + conf_file = self.tmp_path("test-cust", self.tdir) + conf_content = dedent( + """\ + [CLOUDINIT] + METADATA = test-meta + """ + ) + util.write_file(conf_file, conf_content) + # Prepare the meta data file + metadata_file = self.tmp_path("test-meta", self.tdir) + metadata_content = dedent( + """\ + { + "instance-id": "cloud-vm", + "local-hostname": "my-host.domain.com", + "network": { + "version": 2, + "ethernets": { + "eths": { + "match": { + "name": "ens*" + }, + "dhcp4": true + } + } + } + } + """ + ) + util.write_file(metadata_file, metadata_content) + + with mock.patch( + MPATH + "guestcust_util.set_customization_status", + return_value=("msg", b""), + ): + result = wrap_and_call( + "cloudinit.sources.DataSourceVMware", + { + "dmi.read_dmi_data": "vmware", + "util.del_dir": True, + "guestcust_util.search_file": self.tdir, + "guestcust_util.wait_for_cust_cfg_file": conf_file, + "guestcust_util.get_imc_dir_path": self.tdir, + }, + ds._get_data, + ) + self.assertTrue(result) + + self.assertEqual( + ds.subplatform, + "%s (%s)" + % ( + DataSourceVMware.DATA_ACCESS_METHOD_IMC, + DataSourceVMware.get_imc_key_name("metadata"), + ), + ) + + # Test to ensure that network is configured from metadata on each boot. + self.assertSetEqual( + VMW_EXPECTED_DEFAULT_EVENTS, + ds.default_update_events[EventScope.NETWORK], + ) + self.assertSetEqual( + VMW_EXPECTED_DEFAULT_EVENTS, + ds.supported_update_events[EventScope.NETWORK], + ) + def test_get_data_false_on_none_dmi_data(self): """When dmi for system-product-name is None, get_data returns False.""" paths = helpers.Paths({"cloud_dir": self.tdir}) diff --git a/tests/unittests/test_upgrade.py b/tests/unittests/test_upgrade.py index 32a3d7c2ba3..fdf368d6f53 100644 --- a/tests/unittests/test_upgrade.py +++ b/tests/unittests/test_upgrade.py @@ -156,8 +156,10 @@ class TestUpgrade: "Vultr": {"netcfg"}, "VMware": { "data_access_method", + "default_update_events", "rpctool", "rpctool_fn", + "supported_update_events", }, "WSL": {"instance_name"}, }