Skip to content

Commit

Permalink
feat(vmware): Support network events
Browse files Browse the repository at this point in the history
This patch updates the DataSource for VMware to support network
reconfiguration when the BOOT, BOOT_LEGACY, BOOT_NEW_INSTANCE,
and HOTPLUG events are received.

Previously the datasource could only reconfigure the network if
a new instance ID was detected. However, due to features like
backup/restore, migrating VMs, etc., it was determined that it is
valuable to support reconfiguring the network without changing the
instance ID. This is because changing the instance ID also means
running the per-instance configuration modules, such as regenerating
the system's SSH host keys, which could lock out automation.

This patch also indicates the datasource supports network reconfig
for HOTPLUG events, such as when a new NIC is added to a VM.
  • Loading branch information
akutz committed Feb 28, 2025
1 parent 9704ba8 commit ff6818a
Show file tree
Hide file tree
Showing 3 changed files with 253 additions and 0 deletions.
104 changes: 104 additions & 0 deletions cloudinit/sources/DataSourceVMware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -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):
"""
Expand Down Expand Up @@ -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)

Expand All @@ -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.
Expand All @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
147 changes: 147 additions & 0 deletions tests/unittests/sources/test_vmware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 []
Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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"
)
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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})
Expand Down
2 changes: 2 additions & 0 deletions tests/unittests/test_upgrade.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,10 @@ class TestUpgrade:
"Vultr": {"netcfg"},
"VMware": {
"data_access_method",
"default_update_events",
"rpctool",
"rpctool_fn",
"supported_update_events",
},
"WSL": {"instance_name"},
}
Expand Down

0 comments on commit ff6818a

Please sign in to comment.