diff --git a/cloudinit/cmd/main.py b/cloudinit/cmd/main.py index 0a75d7cc502..a451dfa18a6 100644 --- a/cloudinit/cmd/main.py +++ b/cloudinit/cmd/main.py @@ -19,7 +19,7 @@ import traceback import logging import yaml -from typing import Tuple, Callable +from typing import Optional, Tuple, Callable, Union from cloudinit import netinfo from cloudinit import signal_handler @@ -39,6 +39,7 @@ from cloudinit.config import cc_set_hostname from cloudinit.config.modules import Modules from cloudinit.config.schema import validate_cloudconfig_schema +from cloudinit.lifecycle import log_with_downgradable_level from cloudinit.reporting import events from cloudinit.settings import ( PER_INSTANCE, @@ -47,6 +48,8 @@ CLOUD_CONFIG, ) +Reason = str + # Welcome message template WELCOME_MSG_TPL = ( "Cloud-init v. {version} running '{action}' at " @@ -334,6 +337,90 @@ def _should_bring_up_interfaces(init, args): return not args.local +def _should_wait_via_cloud_config( + raw_config: Optional[Union[str, bytes]] +) -> Tuple[bool, Reason]: + if not raw_config: + return False, "no configuration found" + + # If our header is anything other than #cloud-config, wait + possible_header: Union[bytes, str] = raw_config.strip()[:13] + if isinstance(possible_header, str): + decoded_header = possible_header + elif isinstance(possible_header, bytes): + try: + decoded_header = possible_header.decode("utf-8") + except UnicodeDecodeError: + return True, "Binary user data found" + if not decoded_header.startswith("#cloud-config"): + return True, "non-cloud-config user data found" + + try: + parsed_yaml = yaml.safe_load(raw_config) + except Exception as e: + log_with_downgradable_level( + logger=LOG, + version="24.4", + requested_level=logging.WARNING, + msg="Unexpected failure parsing userdata: %s", + args=e, + ) + return True, "failed to parse user data as yaml" + + # These all have the potential to require network access, so we should wait + if "write_files" in parsed_yaml and any( + "source" in item for item in parsed_yaml["write_files"] + ): + return True, "write_files with source found" + if parsed_yaml.get("bootcmd"): + return True, "bootcmd found" + if parsed_yaml.get("random_seed", {}).get("command"): + return True, "random_seed command found" + if parsed_yaml.get("mounts"): + return True, "mounts found" + return False, "cloud-config does not contain network requiring elements" + + +def _should_wait_on_network( + datasource: Optional[sources.DataSource], +) -> Tuple[bool, Reason]: + """Determine if we should wait on network connectivity for cloud-init. + + We need to wait if: + - We have no datasource + - We have user data that is anything other than cloud-config + - This can likely be further optimized in the future to include + other user data types + - We have user data that requires network access + """ + if not datasource: + return True, "no datasource found" + user_should_wait, user_reason = _should_wait_via_cloud_config( + datasource.get_userdata_raw() + ) + if user_should_wait: + return True, f"{user_reason} in user data" + vendor_should_wait, vendor_reason = _should_wait_via_cloud_config( + datasource.get_vendordata_raw() + ) + if vendor_should_wait: + return True, f"{vendor_reason} in vendor data" + vendor2_should_wait, vendor2_reason = _should_wait_via_cloud_config( + datasource.get_vendordata2_raw() + ) + if vendor2_should_wait: + return True, f"{vendor2_reason} in vendor data2" + + return ( + False, + ( + f"user data: {user_reason}, " + f"vendor data: {vendor_reason}, " + f"vendor data2: {vendor2_reason}" + ), + ) + + def main_init(name, args): deps = [sources.DEP_FILESYSTEM, sources.DEP_NETWORK] if args.local: @@ -411,6 +498,9 @@ def main_init(name, args): mode = sources.DSMODE_LOCAL if args.local else sources.DSMODE_NETWORK if mode == sources.DSMODE_NETWORK: + if os.path.exists(init.paths.get_runpath(".wait-on-network")): + LOG.debug("Will wait for network connectivity before continuing") + init.distro.wait_for_network() existing = "trust" sys.stderr.write("%s\n" % (netinfo.debug_info())) else: @@ -478,9 +568,25 @@ def main_init(name, args): # dhcp clients to advertize this hostname to any DDNS services # LP: #1746455. _maybe_set_hostname(init, stage="local", retry_stage="network") + init.apply_network_config(bring_up=bring_up_interfaces) if mode == sources.DSMODE_LOCAL: + should_wait, reason = _should_wait_on_network(init.datasource) + if should_wait: + LOG.debug( + "Network connectivity determined necessary for " + "cloud-init's network stage. Reason: %s", + reason, + ) + util.write_file(init.paths.get_runpath(".wait-on-network"), "") + else: + LOG.debug( + "Network connectivity determined unnecessary for " + "cloud-init's network stage. %s", + reason, + ) + if init.datasource.dsmode != mode: LOG.debug( "[%s] Exiting. datasource %s not in local mode.", diff --git a/cloudinit/distros/__init__.py b/cloudinit/distros/__init__.py index e65cbfb5d89..db64b733b5b 100644 --- a/cloudinit/distros/__init__.py +++ b/cloudinit/distros/__init__.py @@ -349,15 +349,16 @@ def dhcp_client(self) -> dhcp.DhcpClient: raise dhcp.NoDHCPLeaseMissingDhclientError() @property - def network_activator(self) -> Optional[Type[activators.NetworkActivator]]: - """Return the configured network activator for this environment.""" + def network_activator(self) -> Type[activators.NetworkActivator]: + """Return the configured network activator for this environment. + + :returns: The network activator class to use + "raises": NoActivatorException if no activator is found + """ priority = util.get_cfg_by_path( self._cfg, ("network", "activators"), None ) - try: - return activators.select_activator(priority=priority) - except activators.NoActivatorException: - return None + return activators.select_activator(priority=priority) @property def network_renderer(self) -> Renderer: @@ -460,8 +461,9 @@ def apply_network_config(self, netconfig, bring_up=False) -> bool: # Now try to bring them up if bring_up: LOG.debug("Bringing up newly configured network interfaces") - network_activator = self.network_activator - if not network_activator: + try: + network_activator = self.network_activator + except activators.NoActivatorException: LOG.warning( "No network activator found, not bringing up " "network interfaces" @@ -1574,6 +1576,19 @@ def device_part_info(devpath: str) -> tuple: # name in /dev/ return diskdevpath, ptnum + def wait_for_network(self): + """Ensure that cloud-init has network connectivity. + + For most distros, this is a no-op as cloud-init's network service is + ordered in boot to start after network connectivity has been achieved. + As an optimization, distros may opt to order cloud-init's network + service immediately after cloud-init's local service, and only + require network connectivity if it has been deemed necessary. + This method is a hook for distros to implement this optimization. + It is called during cloud-init's network stage if it was determined + that network connectivity is necessary in cloud-init's network stage. + """ + def _apply_hostname_transformations_to_url(url: str, transformations: list): """ diff --git a/cloudinit/distros/ubuntu.py b/cloudinit/distros/ubuntu.py index fbee6e89737..6f432e19661 100644 --- a/cloudinit/distros/ubuntu.py +++ b/cloudinit/distros/ubuntu.py @@ -10,11 +10,15 @@ # This file is part of cloud-init. See LICENSE file for license information. import copy +import logging from cloudinit.distros import PREFERRED_NTP_CLIENTS, debian from cloudinit.distros.package_management.snap import Snap +from cloudinit.net import activators from cloudinit.net.netplan import CLOUDINIT_NETPLAN_FILE +LOG = logging.getLogger(__name__) + class Distro(debian.Distro): def __init__(self, name, cfg, paths): @@ -49,3 +53,12 @@ def preferred_ntp_clients(self): if not self._preferred_ntp_clients: self._preferred_ntp_clients = copy.deepcopy(PREFERRED_NTP_CLIENTS) return self._preferred_ntp_clients + + def wait_for_network(self) -> None: + """Ensure that cloud-init's network service has network connectivity""" + try: + self.network_activator.wait_for_network() + except activators.NoActivatorException: + LOG.error("Failed to wait for network. No network activator found") + except Exception as e: + LOG.error("Failed to wait for network: %s", e) diff --git a/cloudinit/helpers.py b/cloudinit/helpers.py index 470a5b2013f..2470afafc75 100644 --- a/cloudinit/helpers.py +++ b/cloudinit/helpers.py @@ -351,6 +351,7 @@ def __init__(self, path_cfgs: dict, ds=None): "vendor_scripts": "scripts/vendor", "warnings": "warnings", "hotplug.enabled": "hotplug.enabled", + ".wait-on-network": ".wait-on-network", } # Set when a datasource becomes active self.datasource = ds diff --git a/cloudinit/net/activators.py b/cloudinit/net/activators.py index 36e1cedcc1f..6c907755b66 100644 --- a/cloudinit/net/activators.py +++ b/cloudinit/net/activators.py @@ -5,12 +5,9 @@ from typing import Callable, Dict, Iterable, List, Optional, Type, Union from cloudinit import subp, util -from cloudinit.net.eni import available as eni_available +from cloudinit.net import eni, netplan, network_manager, networkd from cloudinit.net.netops.iproute2 import Iproute2 -from cloudinit.net.netplan import available as netplan_available -from cloudinit.net.network_manager import available as nm_available from cloudinit.net.network_state import NetworkState -from cloudinit.net.networkd import available as networkd_available LOG = logging.getLogger(__name__) @@ -88,6 +85,11 @@ def bring_up_all_interfaces(cls, network_state: NetworkState) -> bool: [i["name"] for i in network_state.iter_interfaces()] ) + @staticmethod + def wait_for_network() -> None: + """Wait for network to come up.""" + raise NotImplementedError() + class IfUpDownActivator(NetworkActivator): # Note that we're not overriding bring_up_interfaces to pass something @@ -97,7 +99,7 @@ class IfUpDownActivator(NetworkActivator): @staticmethod def available(target: Optional[str] = None) -> bool: """Return true if ifupdown can be used on this system.""" - return eni_available(target=target) + return eni.available(target=target) @staticmethod def bring_up_interface(device_name: str) -> bool: @@ -149,7 +151,7 @@ class NetworkManagerActivator(NetworkActivator): @staticmethod def available(target=None) -> bool: """Return true if NetworkManager can be used on this system.""" - return nm_available(target=target) + return network_manager.available(target=target) @staticmethod def bring_up_interface(device_name: str) -> bool: @@ -215,7 +217,7 @@ class NetplanActivator(NetworkActivator): @staticmethod def available(target=None) -> bool: """Return true if netplan can be used on this system.""" - return netplan_available(target=target) + return netplan.available(target=target) @staticmethod def bring_up_interface(device_name: str) -> bool: @@ -269,12 +271,21 @@ def bring_down_interface(device_name: str) -> bool: NetplanActivator.NETPLAN_CMD, "all", warn_on_stderr=False ) + @staticmethod + def wait_for_network() -> None: + """On networkd systems, wait for systemd-networkd-wait-online""" + # At the moment, this is only supported using the networkd renderer. + if network_manager.available(): + LOG.debug("NetworkManager is enabled, skipping networkd wait") + return + NetworkdActivator.wait_for_network() + class NetworkdActivator(NetworkActivator): @staticmethod def available(target=None) -> bool: """Return true if ifupdown can be used on this system.""" - return networkd_available(target=target) + return networkd.available(target=target) @staticmethod def bring_up_interface(device_name: str) -> bool: @@ -296,6 +307,31 @@ def bring_down_interface(device_name: str) -> bool: partial(Iproute2.link_down, device_name) ) + @staticmethod + def wait_for_network() -> None: + """Wait for systemd-networkd-wait-online.""" + wait_online_def: str = subp.subp( + ["systemctl", "cat", "systemd-networkd-wait-online.service"] + ).stdout + + # We need to extract the ExecStart= lines from the service definition. + # If we come across an ExecStart= line that is empty, that clears any + # previously found commands, which we should expect from the drop-in. + # Since the service is a oneshot, we can have multiple ExecStart= lines + # and systemd runs them in parallel. We'll run them serially since + # there's really no gain for us in running them in parallel. + wait_commands: List[List[str]] = [] + for line in wait_online_def.splitlines(): + if line.startswith("ExecStart="): + command_str = line.split("=", 1)[1].strip() + if not command_str: + wait_commands.clear() + else: + wait_commands.append(command_str.split()) + + for command in wait_commands: + subp.subp(command) + # This section is mostly copied and pasted from renderers.py. An abstract # version to encompass both seems overkill at this point @@ -318,18 +354,22 @@ def bring_down_interface(device_name: str) -> bool: def search_activator( priority: List[str], target: Union[str, None] -) -> List[Type[NetworkActivator]]: +) -> Optional[Type[NetworkActivator]]: + """Returns the first available activator from the priority list or None.""" unknown = [i for i in priority if i not in DEFAULT_PRIORITY] if unknown: raise ValueError( - "Unknown activators provided in priority list: %s" % unknown + f"Unknown activators provided in priority list: {unknown}" ) activator_classes = [NAME_TO_ACTIVATOR[name] for name in priority] - return [ - activator_cls - for activator_cls in activator_classes - if activator_cls.available(target) - ] + return next( + ( + activator_cls + for activator_cls in activator_classes + if activator_cls.available(target) + ), + None, + ) def select_activator( @@ -337,16 +377,13 @@ def select_activator( ) -> Type[NetworkActivator]: if priority is None: priority = DEFAULT_PRIORITY - found = search_activator(priority, target) - if not found: - tmsg = "" - if target and target != "/": - tmsg = " in target=%s" % target + selected = search_activator(priority, target) + if not selected: + tmsg = f" in target={target}" if target and target != "/" else "" raise NoActivatorException( - "No available network activators found%s. Searched " - "through list: %s" % (tmsg, priority) + f"No available network activators found{tmsg}. " + f"Searched through list: {priority}" ) - selected = found[0] LOG.debug( "Using selected activator: %s from priority: %s", selected, priority ) diff --git a/systemd/cloud-init-network.service.tmpl b/systemd/cloud-init-network.service.tmpl index bed96a855ad..1269b416206 100644 --- a/systemd/cloud-init-network.service.tmpl +++ b/systemd/cloud-init-network.service.tmpl @@ -9,7 +9,9 @@ Wants=cloud-init-local.service Wants=sshd-keygen.service Wants=sshd.service After=cloud-init-local.service +{% if variant not in ["ubuntu"] %} After=systemd-networkd-wait-online.service +{% endif %} {% if variant in ["ubuntu", "unknown", "debian"] %} After=networking.service {% endif %} diff --git a/tests/integration_tests/datasources/test_nocloud.py b/tests/integration_tests/datasources/test_nocloud.py index c44418c3e08..0929e77914b 100644 --- a/tests/integration_tests/datasources/test_nocloud.py +++ b/tests/integration_tests/datasources/test_nocloud.py @@ -15,11 +15,12 @@ override_kernel_command_line, verify_clean_boot, verify_clean_log, + wait_online_called, ) VENDOR_DATA = """\ #cloud-config -runcmd: +bootcmd: - touch /var/tmp/seeded_vendordata_test_file """ @@ -99,6 +100,7 @@ def test_nocloud_seedfrom_vendordata(client: IntegrationInstance): client.restart() assert client.execute("cloud-init status").ok assert "seeded_vendordata_test_file" in client.execute("ls /var/tmp") + assert wait_online_called(client.execute("cat /var/log/cloud-init.log")) SMBIOS_USERDATA = """\ diff --git a/tests/integration_tests/modules/test_boothook.py b/tests/integration_tests/modules/test_boothook.py index e9e1e796d17..4dbe266e2d2 100644 --- a/tests/integration_tests/modules/test_boothook.py +++ b/tests/integration_tests/modules/test_boothook.py @@ -4,7 +4,11 @@ import pytest from tests.integration_tests.instances import IntegrationInstance -from tests.integration_tests.util import verify_clean_boot, verify_clean_log +from tests.integration_tests.util import ( + verify_clean_boot, + verify_clean_log, + wait_online_called, +) USER_DATA = """\ ## template: jinja @@ -18,24 +22,39 @@ @pytest.mark.user_data(USER_DATA) -def test_boothook_header_runs_part_per_instance(client: IntegrationInstance): - """Test boothook handling creates a script that runs per-boot. - Streams stderr and stdout are directed to /var/log/cloud-init-output.log. - """ - instance_id = client.instance.execute("cloud-init query instance-id") - RE_BOOTHOOK = f"BOOTHOOK: {instance_id}: is called every boot" - log = client.read_from_file("/var/log/cloud-init.log") - verify_clean_log(log) - verify_clean_boot(client) - output = client.read_from_file("/boothook.txt") - assert 1 == len(re.findall(RE_BOOTHOOK, output)) - client.restart() - output = client.read_from_file("/boothook.txt") - assert 2 == len(re.findall(RE_BOOTHOOK, output)) - output_log = client.read_from_file("/var/log/cloud-init-output.log") - expected_msgs = [ - "BOOTHOOKstdout", - "boothooks/part-001: 3: BOOTHOOK/0: not found", - ] - for msg in expected_msgs: - assert msg in output_log +class TestBoothook: + def test_boothook_header_runs_part_per_instance( + self, + class_client: IntegrationInstance, + ): + """Test boothook handling creates a script that runs per-boot. + Streams stderr and stdout are directed to + /var/log/cloud-init-output.log. + """ + client = class_client + instance_id = client.instance.execute("cloud-init query instance-id") + RE_BOOTHOOK = f"BOOTHOOK: {instance_id}: is called every boot" + log = client.read_from_file("/var/log/cloud-init.log") + verify_clean_log(log) + verify_clean_boot(client) + output = client.read_from_file("/boothook.txt") + assert 1 == len(re.findall(RE_BOOTHOOK, output)) + client.restart() + output = client.read_from_file("/boothook.txt") + assert 2 == len(re.findall(RE_BOOTHOOK, output)) + output_log = client.read_from_file("/var/log/cloud-init-output.log") + expected_msgs = [ + "BOOTHOOKstdout", + "boothooks/part-001: 3: BOOTHOOK/0: not found", + ] + for msg in expected_msgs: + assert msg in output_log + + def test_boothook_waits_for_network( + self, class_client: IntegrationInstance + ): + """Test boothook handling waits for network before running.""" + client = class_client + assert wait_online_called( + client.read_from_file("/var/log/cloud-init.log") + ) diff --git a/tests/integration_tests/modules/test_combined.py b/tests/integration_tests/modules/test_combined.py index 759c9528e62..5e4369e7881 100644 --- a/tests/integration_tests/modules/test_combined.py +++ b/tests/integration_tests/modules/test_combined.py @@ -30,6 +30,7 @@ verify_clean_boot, verify_clean_log, verify_ordered_items_in_text, + wait_online_called, ) USER_DATA = """\ @@ -541,6 +542,13 @@ def test_unicode(self, class_client: IntegrationInstance): client = class_client assert "💩" == client.read_from_file("/var/tmp/unicode_data") + @pytest.mark.skipif(not IS_UBUNTU, reason="Ubuntu-only behavior") + def test_networkd_wait_online(self, class_client: IntegrationInstance): + client = class_client + assert not wait_online_called( + client.read_from_file("/var/log/cloud-init.log") + ) + @pytest.mark.user_data(USER_DATA) class TestCombinedNoCI: diff --git a/tests/integration_tests/util.py b/tests/integration_tests/util.py index 828dae2e99a..19e11936550 100644 --- a/tests/integration_tests/util.py +++ b/tests/integration_tests/util.py @@ -611,3 +611,7 @@ def push_and_enable_systemd_unit( client.write_to_file(service_filename, content) client.execute(f"chmod 0644 {service_filename}", use_sudo=True) client.execute(f"systemctl enable {unit_name}", use_sudo=True) + + +def wait_online_called(log: str) -> bool: + return "Running command ['/lib/systemd/systemd-networkd-wait-online" in log diff --git a/tests/unittests/cmd/test_main.py b/tests/unittests/cmd/test_main.py index 482bf576930..4b4eaa76915 100644 --- a/tests/unittests/cmd/test_main.py +++ b/tests/unittests/cmd/test_main.py @@ -3,19 +3,39 @@ import copy import getpass import os +import textwrap from collections import namedtuple from unittest import mock import pytest -from cloudinit import safeyaml +from cloudinit import safeyaml, util from cloudinit.cmd import main +from cloudinit.subp import SubpResult from cloudinit.util import ensure_dir, load_text_file, write_file MyArgs = namedtuple( "MyArgs", "debug files force local reporter subcommand skip_log_setup" ) +FAKE_SERVICE_FILE = """\ +[Unit] +Description=Wait for Network to be Configured + +[Service] +Type=oneshot +ExecStart=/usr/lib/systemd/systemd-networkd-wait-online + +# /run/systemd/system/systemd-networkd-wait-online.service.d/10-netplan.conf +[Unit] +ConditionPathIsSymbolicLink=/run/systemd/generator/network-online.target.wants/systemd-networkd-wait-online.service + +[Service] +ExecStart= +ExecStart=/lib/systemd/systemd-networkd-wait-online -i eth0:degraded +ExecStart=/lib/systemd/systemd-networkd-wait-online --any -o routable -i eth0 +""" # noqa: E501 + class TestMain: @pytest.fixture(autouse=True) @@ -139,6 +159,175 @@ def test_main_sys_argv( main.main() m_clean_get_parser.assert_called_once() + @pytest.mark.parametrize( + "ds,userdata,expected", + [ + # If we have no datasource, wait regardless + (None, None, True), + (None, "#!/bin/bash\n - echo hello", True), + # Empty user data shouldn't wait + (mock.Mock(), "", False), + # Bootcmd always wait + (mock.Mock(), "#cloud-config\nbootcmd:\n - echo hello", True), + # Bytes are valid too + (mock.Mock(), b"#cloud-config\nbootcmd:\n - echo hello", True), + # write_files with 'source' wait + ( + mock.Mock(), + textwrap.dedent( + """\ + #cloud-config + write_files: + - source: + uri: http://example.com + headers: + Authorization: Basic stuff + User-Agent: me + """ + ), + True, + ), + # write_files without 'source' don't wait + ( + mock.Mock(), + textwrap.dedent( + """\ + #cloud-config + write_files: + - content: hello + encoding: b64 + owner: root:root + path: /etc/sysconfig/selinux + permissions: '0644' + """ + ), + False, + ), + # random_seed with 'command' wait + ( + mock.Mock(), + "#cloud-config\nrandom_seed:\n command: true", + True, + ), + # random_seed without 'command' no wait + ( + mock.Mock(), + textwrap.dedent( + """\ + #cloud-config + random_seed: + data: 4 + encoding: raw + file: /dev/urandom + """ + ), + False, + ), + # mounts always wait + ( + mock.Mock(), + "#cloud-config\nmounts:\n - [ /dev/sdb, /mnt, ext4 ]", + True, + ), + # Not parseable as yaml + (mock.Mock(), "#cloud-config\nbootcmd:\necho hello", True), + # Non-cloud-config + (mock.Mock(), "#!/bin/bash\n - echo hello", True), + # Something that won't decode to utf-8 + (mock.Mock(), os.urandom(100), True), + # Something small that shouldn't decode to utf-8 + (mock.Mock(), os.urandom(5), True), + ], + ) + def test_should_wait_on_network(self, ds, userdata, expected): + if ds: + ds.get_userdata_raw = mock.Mock(return_value=userdata) + ds.get_vendordata_raw = mock.Mock(return_value=None) + ds.get_vendordata2_raw = mock.Mock(return_value=None) + assert main._should_wait_on_network(ds)[0] is expected + + # Here we rotate our configs to ensure that any of userdata, + # vendordata, or vendordata2 can be the one that causes us to wait. + for _ in range(2): + if ds: + ( + ds.get_userdata_raw, + ds.get_vendordata_raw, + ds.get_vendordata2_raw, + ) = ( + ds.get_vendordata_raw, + ds.get_vendordata2_raw, + ds.get_userdata_raw, + ) + assert main._should_wait_on_network(ds)[0] is expected + + @pytest.mark.parametrize( + "distro,should_wait,expected_add_wait", + [ + ("ubuntu", True, True), + ("ubuntu", False, False), + ("debian", True, False), + ("debian", False, False), + ("centos", True, False), + ("rhel", False, False), + ("fedora", True, False), + ("suse", False, False), + ("gentoo", True, False), + ("arch", False, False), + ("alpine", False, False), + ], + ) + def test_distro_wait_for_network( + self, + distro, + should_wait, + expected_add_wait, + cloud_cfg, + mocker, + fake_filesystem, + ): + def fake_subp(*args, **kwargs): + if args == ( + ["systemctl", "cat", "systemd-networkd-wait-online.service"], + ): + return SubpResult(FAKE_SERVICE_FILE, "") + return SubpResult("", "") + + mocker.patch("cloudinit.net.netplan.available", return_value=True) + m_nm = mocker.patch( + "cloudinit.net.network_manager.available", return_value=False + ) + m_subp = mocker.patch("cloudinit.subp.subp", side_effect=fake_subp) + if should_wait: + util.write_file(".wait-on-network", "") + + cfg, cloud_cfg_file = cloud_cfg + cfg["system_info"]["distro"] = distro + write_file(cloud_cfg_file, safeyaml.dumps(cfg)) + cmdargs = MyArgs( + debug=False, + files=None, + force=False, + local=False, + reporter=None, + subcommand="init", + skip_log_setup=False, + ) + main.main_init("init", cmdargs) + expected_subps = [ + "/lib/systemd/systemd-networkd-wait-online -i eth0:degraded", + "/lib/systemd/systemd-networkd-wait-online --any -o routable -i eth0", # noqa: E501 + ] + if expected_add_wait: + m_nm.assert_called_once() + for expected_subp in expected_subps: + assert ( + mock.call(expected_subp.split()) in m_subp.call_args_list + ) + else: + m_nm.assert_not_called() + m_subp.assert_not_called() + class TestShouldBringUpInterfaces: @pytest.mark.parametrize( diff --git a/tests/unittests/test_net_activators.py b/tests/unittests/test_net_activators.py index f34202167b8..a720ada811d 100644 --- a/tests/unittests/test_net_activators.py +++ b/tests/unittests/test_net_activators.py @@ -84,7 +84,7 @@ def unavailable_mocks(): class TestSearchAndSelect: def test_empty_list(self, available_mocks): resp = search_activator(priority=DEFAULT_PRIORITY, target=None) - assert resp == [NAME_TO_ACTIVATOR[name] for name in DEFAULT_PRIORITY] + assert resp == NAME_TO_ACTIVATOR[DEFAULT_PRIORITY[0]] activator = select_activator() assert activator == NAME_TO_ACTIVATOR[DEFAULT_PRIORITY[0]] @@ -92,10 +92,10 @@ def test_empty_list(self, available_mocks): def test_priority(self, available_mocks): new_order = ["netplan", "network-manager"] resp = search_activator(priority=new_order, target=None) - assert resp == [NAME_TO_ACTIVATOR[name] for name in new_order] + assert resp == NetplanActivator activator = select_activator(priority=new_order) - assert activator == NAME_TO_ACTIVATOR[new_order[0]] + assert activator == NetplanActivator def test_target(self, available_mocks): search_activator(priority=DEFAULT_PRIORITY, target="/tmp") @@ -109,10 +109,10 @@ def test_target(self, available_mocks): return_value=False, ) def test_first_not_available(self, m_available, available_mocks): + # We've mocked out IfUpDownActivator as unavailable, so expect the + # next in the list of default priorities resp = search_activator(priority=DEFAULT_PRIORITY, target=None) - assert resp == [ - NAME_TO_ACTIVATOR[activator] for activator in DEFAULT_PRIORITY[1:] - ] + assert resp == NAME_TO_ACTIVATOR[DEFAULT_PRIORITY[1]] resp = select_activator() assert resp == NAME_TO_ACTIVATOR[DEFAULT_PRIORITY[1]] @@ -125,7 +125,7 @@ def test_priority_not_exist(self, available_mocks): def test_none_available(self, unavailable_mocks): resp = search_activator(priority=DEFAULT_PRIORITY, target=None) - assert resp == [] + assert resp is None with pytest.raises(NoActivatorException): select_activator()