summaryrefslogtreecommitdiff
path: root/cloudinit/distros
diff options
context:
space:
mode:
Diffstat (limited to 'cloudinit/distros')
-rwxr-xr-xcloudinit/distros/__init__.py190
-rw-r--r--cloudinit/distros/alpine.py165
-rw-r--r--cloudinit/distros/arch.py21
-rw-r--r--cloudinit/distros/bsd.py129
-rw-r--r--cloudinit/distros/bsd_utils.py50
-rw-r--r--cloudinit/distros/debian.py9
-rw-r--r--cloudinit/distros/freebsd.py148
-rw-r--r--cloudinit/distros/gentoo.py17
-rw-r--r--cloudinit/distros/netbsd.py159
-rw-r--r--cloudinit/distros/networking.py212
-rw-r--r--cloudinit/distros/openbsd.py52
-rw-r--r--cloudinit/distros/opensuse.py12
-rw-r--r--cloudinit/distros/parsers/resolv_conf.py7
-rw-r--r--cloudinit/distros/rhel.py9
-rw-r--r--cloudinit/distros/tests/__init__.py0
-rw-r--r--cloudinit/distros/tests/test_init.py156
-rw-r--r--cloudinit/distros/tests/test_networking.py192
-rw-r--r--cloudinit/distros/ubuntu.py2
18 files changed, 1348 insertions, 182 deletions
diff --git a/cloudinit/distros/__init__.py b/cloudinit/distros/__init__.py
index 92598a2d..2537608f 100755
--- a/cloudinit/distros/__init__.py
+++ b/cloudinit/distros/__init__.py
@@ -13,6 +13,8 @@ import abc
import os
import re
import stat
+import string
+import urllib.parse
from io import StringIO
from cloudinit import importer
@@ -23,9 +25,14 @@ from cloudinit.net import network_state
from cloudinit.net import renderers
from cloudinit import ssh_util
from cloudinit import type_utils
+from cloudinit import subp
from cloudinit import util
+from cloudinit.features import \
+ ALLOW_EC2_MIRRORS_ON_NON_AWS_INSTANCE_TYPES
+
from cloudinit.distros.parsers import hosts
+from .networking import LinuxNetworking
# Used when a cloud-config module can be run on all cloud-init distibutions.
@@ -33,12 +40,13 @@ from cloudinit.distros.parsers import hosts
ALL_DISTROS = 'all'
OSFAMILIES = {
+ 'alpine': ['alpine'],
+ 'arch': ['arch'],
'debian': ['debian', 'ubuntu'],
- 'redhat': ['amazon', 'centos', 'fedora', 'rhel'],
- 'gentoo': ['gentoo'],
'freebsd': ['freebsd'],
+ 'gentoo': ['gentoo'],
+ 'redhat': ['amazon', 'centos', 'fedora', 'rhel'],
'suse': ['opensuse', 'sles'],
- 'arch': ['arch'],
}
LOG = logging.getLogger(__name__)
@@ -50,6 +58,9 @@ _EC2_AZ_RE = re.compile('^[a-z][a-z]-(?:[a-z]+-)+[0-9][a-z]$')
# Default NTP Client Configurations
PREFERRED_NTP_CLIENTS = ['chrony', 'systemd-timesyncd', 'ntp', 'ntpdate']
+# Letters/Digits/Hyphen characters, for use in domain name validation
+LDH_ASCII_CHARS = string.ascii_letters + string.digits + "-"
+
class Distro(metaclass=abc.ABCMeta):
@@ -61,11 +72,13 @@ class Distro(metaclass=abc.ABCMeta):
init_cmd = ['service'] # systemctl, service etc
renderer_configs = {}
_preferred_ntp_clients = None
+ networking_cls = LinuxNetworking
def __init__(self, name, cfg, paths):
self._paths = paths
self._cfg = cfg
self.name = name
+ self.networking = self.networking_cls()
@abc.abstractmethod
def install_packages(self, pkglist):
@@ -220,8 +233,8 @@ class Distro(metaclass=abc.ABCMeta):
LOG.debug("Non-persistently setting the system hostname to %s",
hostname)
try:
- util.subp(['hostname', hostname])
- except util.ProcessExecutionError:
+ subp.subp(['hostname', hostname])
+ except subp.ProcessExecutionError:
util.logexc(LOG, "Failed to non-persistently adjust the system "
"hostname to %s", hostname)
@@ -356,12 +369,12 @@ class Distro(metaclass=abc.ABCMeta):
LOG.debug("Attempting to run bring up interface %s using command %s",
device_name, cmd)
try:
- (_out, err) = util.subp(cmd)
+ (_out, err) = subp.subp(cmd)
if len(err):
LOG.warning("Running %s resulted in stderr output: %s",
cmd, err)
return True
- except util.ProcessExecutionError:
+ except subp.ProcessExecutionError:
util.logexc(LOG, "Running interface command %s failed", cmd)
return False
@@ -380,6 +393,9 @@ class Distro(metaclass=abc.ABCMeta):
def add_user(self, name, **kwargs):
"""
Add a user to the system using standard GNU tools
+
+ This should be overriden on distros where useradd is not desirable or
+ not available.
"""
# XXX need to make add_user idempotent somehow as we
# still want to add groups or modify SSH keys on pre-existing
@@ -475,7 +491,7 @@ class Distro(metaclass=abc.ABCMeta):
# Run the command
LOG.debug("Adding user %s", name)
try:
- util.subp(useradd_cmd, logstring=log_useradd_cmd)
+ subp.subp(useradd_cmd, logstring=log_useradd_cmd)
except Exception as e:
util.logexc(LOG, "Failed to create user %s", name)
raise e
@@ -495,7 +511,7 @@ class Distro(metaclass=abc.ABCMeta):
# Run the command
LOG.debug("Adding snap user %s", name)
try:
- (out, err) = util.subp(create_user_cmd, logstring=create_user_cmd,
+ (out, err) = subp.subp(create_user_cmd, logstring=create_user_cmd,
capture=True)
LOG.debug("snap create-user returned: %s:%s", out, err)
jobj = util.load_json(out)
@@ -508,9 +524,22 @@ class Distro(metaclass=abc.ABCMeta):
def create_user(self, name, **kwargs):
"""
- Creates users for the system using the GNU passwd tools. This
- will work on an GNU system. This should be overriden on
- distros where useradd is not desirable or not available.
+ Creates or partially updates the ``name`` user in the system.
+
+ This defers the actual user creation to ``self.add_user`` or
+ ``self.add_snap_user``, and most of the keys in ``kwargs`` will be
+ processed there if and only if the user does not already exist.
+
+ Once the existence of the ``name`` user has been ensured, this method
+ then processes these keys (for both just-created and pre-existing
+ users):
+
+ * ``plain_text_passwd``
+ * ``hashed_passwd``
+ * ``lock_passwd``
+ * ``sudo``
+ * ``ssh_authorized_keys``
+ * ``ssh_redirect_user``
"""
# Add a snap user, if requested
@@ -577,20 +606,21 @@ class Distro(metaclass=abc.ABCMeta):
# passwd must use short '-l' due to SLES11 lacking long form '--lock'
lock_tools = (['passwd', '-l', name], ['usermod', '--lock', name])
try:
- cmd = next(l for l in lock_tools if util.which(l[0]))
- except StopIteration:
+ cmd = next(tool for tool in lock_tools if subp.which(tool[0]))
+ except StopIteration as e:
raise RuntimeError((
"Unable to lock user account '%s'. No tools available. "
- " Tried: %s.") % (name, [c[0] for c in lock_tools]))
+ " Tried: %s.") % (name, [c[0] for c in lock_tools])
+ ) from e
try:
- util.subp(cmd)
+ subp.subp(cmd)
except Exception as e:
util.logexc(LOG, 'Failed to disable password for user %s', name)
raise e
def expire_passwd(self, user):
try:
- util.subp(['passwd', '--expire', user])
+ subp.subp(['passwd', '--expire', user])
except Exception as e:
util.logexc(LOG, "Failed to set 'expire' for %s", user)
raise e
@@ -606,7 +636,7 @@ class Distro(metaclass=abc.ABCMeta):
cmd.append('-e')
try:
- util.subp(cmd, pass_string, logstring="chpasswd for %s" % user)
+ subp.subp(cmd, pass_string, logstring="chpasswd for %s" % user)
except Exception as e:
util.logexc(LOG, "Failed to set password for %s", user)
raise e
@@ -703,7 +733,7 @@ class Distro(metaclass=abc.ABCMeta):
LOG.warning("Skipping creation of existing group '%s'", name)
else:
try:
- util.subp(group_add_cmd)
+ subp.subp(group_add_cmd)
LOG.info("Created new group %s", name)
except Exception:
util.logexc(LOG, "Failed to create group %s", name)
@@ -716,10 +746,115 @@ class Distro(metaclass=abc.ABCMeta):
"; user does not exist.", member, name)
continue
- util.subp(['usermod', '-a', '-G', name, member])
+ subp.subp(['usermod', '-a', '-G', name, member])
LOG.info("Added user '%s' to group '%s'", member, name)
+def _apply_hostname_transformations_to_url(url: str, transformations: list):
+ """
+ Apply transformations to a URL's hostname, return transformed URL.
+
+ This is a separate function because unwrapping and rewrapping only the
+ hostname portion of a URL is complex.
+
+ :param url:
+ The URL to operate on.
+ :param transformations:
+ A list of ``(str) -> Optional[str]`` functions, which will be applied
+ in order to the hostname portion of the URL. If any function
+ (regardless of ordering) returns None, ``url`` will be returned without
+ any modification.
+
+ :return:
+ A string whose value is ``url`` with the hostname ``transformations``
+ applied, or ``None`` if ``url`` is unparseable.
+ """
+ try:
+ parts = urllib.parse.urlsplit(url)
+ except ValueError:
+ # If we can't even parse the URL, we shouldn't use it for anything
+ return None
+ new_hostname = parts.hostname
+ if new_hostname is None:
+ # The URL given doesn't have a hostname component, so (a) we can't
+ # transform it, and (b) it won't work as a mirror; return None.
+ return None
+
+ for transformation in transformations:
+ new_hostname = transformation(new_hostname)
+ if new_hostname is None:
+ # If a transformation returns None, that indicates we should abort
+ # processing and return `url` unmodified
+ return url
+
+ new_netloc = new_hostname
+ if parts.port is not None:
+ new_netloc = "{}:{}".format(new_netloc, parts.port)
+ return urllib.parse.urlunsplit(parts._replace(netloc=new_netloc))
+
+
+def _sanitize_mirror_url(url: str):
+ """
+ Given a mirror URL, replace or remove any invalid URI characters.
+
+ This performs the following actions on the URL's hostname:
+ * Checks if it is an IP address, returning the URL immediately if it is
+ * Converts it to its IDN form (see below for details)
+ * Replaces any non-Letters/Digits/Hyphen (LDH) characters in it with
+ hyphens
+ * TODO: Remove any leading/trailing hyphens from each domain name label
+
+ Before we replace any invalid domain name characters, we first need to
+ ensure that any valid non-ASCII characters in the hostname will not be
+ replaced, by ensuring the hostname is in its Internationalized domain name
+ (IDN) representation (see RFC 5890). This conversion has to be applied to
+ the whole hostname (rather than just the substitution variables), because
+ the Punycode algorithm used by IDNA transcodes each part of the hostname as
+ a whole string (rather than encoding individual characters). It cannot be
+ applied to the whole URL, because (a) the Punycode algorithm expects to
+ operate on domain names so doesn't output a valid URL, and (b) non-ASCII
+ characters in non-hostname parts of the URL aren't encoded via Punycode.
+
+ To put this in RFC 5890's terminology: before we remove or replace any
+ characters from our domain name (which we do to ensure that each label is a
+ valid LDH Label), we first ensure each label is in its A-label form.
+
+ (Note that Python's builtin idna encoding is actually IDNA2003, not
+ IDNA2008. This changes the specifics of how some characters are encoded to
+ ASCII, but doesn't affect the logic here.)
+
+ :param url:
+ The URL to operate on.
+
+ :return:
+ A sanitized version of the URL, which will have been IDNA encoded if
+ necessary, or ``None`` if the generated string is not a parseable URL.
+ """
+ # Acceptable characters are LDH characters, plus "." to separate each label
+ acceptable_chars = LDH_ASCII_CHARS + "."
+ transformations = [
+ # This is an IP address, not a hostname, so no need to apply the
+ # transformations
+ lambda hostname: None if net.is_ip_address(hostname) else hostname,
+
+ # Encode with IDNA to get the correct characters (as `bytes`), then
+ # decode with ASCII so we return a `str`
+ lambda hostname: hostname.encode('idna').decode('ascii'),
+
+ # Replace any unacceptable characters with "-"
+ lambda hostname: ''.join(
+ c if c in acceptable_chars else "-" for c in hostname
+ ),
+
+ # Drop leading/trailing hyphens from each part of the hostname
+ lambda hostname: '.'.join(
+ part.strip('-') for part in hostname.split('.')
+ ),
+ ]
+
+ return _apply_hostname_transformations_to_url(url, transformations)
+
+
def _get_package_mirror_info(mirror_info, data_source=None,
mirror_filter=util.search_for_mirror):
# given a arch specific 'mirror_info' entry (from package_mirrors)
@@ -735,7 +870,12 @@ def _get_package_mirror_info(mirror_info, data_source=None,
# ec2 availability zones are named cc-direction-[0-9][a-d] (us-east-1b)
# the region is us-east-1. so region = az[0:-1]
if _EC2_AZ_RE.match(data_source.availability_zone):
- subst['ec2_region'] = "%s" % data_source.availability_zone[0:-1]
+ ec2_region = data_source.availability_zone[0:-1]
+
+ if ALLOW_EC2_MIRRORS_ON_NON_AWS_INSTANCE_TYPES:
+ subst['ec2_region'] = "%s" % ec2_region
+ elif data_source.platform_type == "ec2":
+ subst['ec2_region'] = "%s" % ec2_region
if data_source and data_source.region:
subst['region'] = data_source.region
@@ -748,9 +888,13 @@ def _get_package_mirror_info(mirror_info, data_source=None,
mirrors = []
for tmpl in searchlist:
try:
- mirrors.append(tmpl % subst)
+ mirror = tmpl % subst
except KeyError:
- pass
+ continue
+
+ mirror = _sanitize_mirror_url(mirror)
+ if mirror is not None:
+ mirrors.append(mirror)
found = mirror_filter(mirrors)
if found:
diff --git a/cloudinit/distros/alpine.py b/cloudinit/distros/alpine.py
new file mode 100644
index 00000000..e42443fc
--- /dev/null
+++ b/cloudinit/distros/alpine.py
@@ -0,0 +1,165 @@
+# Copyright (C) 2016 Matt Dainty
+# Copyright (C) 2020 Dermot Bradley
+#
+# Author: Matt Dainty <matt@bodgit-n-scarper.com>
+# Author: Dermot Bradley <dermot_bradley@yahoo.com>
+#
+# This file is part of cloud-init. See LICENSE file for license information.
+
+from cloudinit import distros
+from cloudinit import helpers
+from cloudinit import log as logging
+from cloudinit import subp
+from cloudinit import util
+
+from cloudinit.distros.parsers.hostname import HostnameConf
+
+from cloudinit.settings import PER_INSTANCE
+
+LOG = logging.getLogger(__name__)
+
+NETWORK_FILE_HEADER = """\
+# This file is generated from information provided by the datasource. Changes
+# to it will not persist across an instance reboot. To disable cloud-init's
+# network configuration capabilities, write a file
+# /etc/cloud/cloud.cfg.d/99-disable-network-config.cfg with the following:
+# network: {config: disabled}
+
+"""
+
+
+class Distro(distros.Distro):
+ init_cmd = ['rc-service'] # init scripts
+ locale_conf_fn = "/etc/profile.d/locale.sh"
+ network_conf_fn = "/etc/network/interfaces"
+ renderer_configs = {
+ "eni": {"eni_path": network_conf_fn,
+ "eni_header": NETWORK_FILE_HEADER}
+ }
+
+ def __init__(self, name, cfg, paths):
+ distros.Distro.__init__(self, name, cfg, paths)
+ # This will be used to restrict certain
+ # calls from repeatly happening (when they
+ # should only happen say once per instance...)
+ self._runner = helpers.Runners(paths)
+ self.default_locale = 'C.UTF-8'
+ self.osfamily = 'alpine'
+ cfg['ssh_svcname'] = 'sshd'
+
+ def get_locale(self):
+ """The default locale for Alpine Linux is different than
+ cloud-init's DataSource default.
+ """
+ return self.default_locale
+
+ def apply_locale(self, locale, out_fn=None):
+ # Alpine has limited locale support due to musl library limitations
+
+ if not locale:
+ locale = self.default_locale
+ if not out_fn:
+ out_fn = self.locale_conf_fn
+
+ lines = [
+ "#",
+ "# This file is created by cloud-init once per new instance boot",
+ "#",
+ "export CHARSET=UTF-8",
+ "export LANG=%s" % locale,
+ "export LC_COLLATE=C",
+ "",
+ ]
+ util.write_file(out_fn, "\n".join(lines), 0o644)
+
+ def install_packages(self, pkglist):
+ self.update_package_sources()
+ self.package_command('add', pkgs=pkglist)
+
+ def _write_network_config(self, netconfig):
+ return self._supported_write_network_config(netconfig)
+
+ def _bring_up_interfaces(self, device_names):
+ use_all = False
+ for d in device_names:
+ if d == 'all':
+ use_all = True
+ if use_all:
+ return distros.Distro._bring_up_interface(self, '-a')
+ else:
+ return distros.Distro._bring_up_interfaces(self, device_names)
+
+ def _write_hostname(self, your_hostname, out_fn):
+ conf = None
+ try:
+ # Try to update the previous one
+ # so lets see if we can read it first.
+ conf = self._read_hostname_conf(out_fn)
+ except IOError:
+ pass
+ if not conf:
+ conf = HostnameConf('')
+ conf.set_hostname(your_hostname)
+ util.write_file(out_fn, str(conf), 0o644)
+
+ def _read_system_hostname(self):
+ sys_hostname = self._read_hostname(self.hostname_conf_fn)
+ return (self.hostname_conf_fn, sys_hostname)
+
+ def _read_hostname_conf(self, filename):
+ conf = HostnameConf(util.load_file(filename))
+ conf.parse()
+ return conf
+
+ def _read_hostname(self, filename, default=None):
+ hostname = None
+ try:
+ conf = self._read_hostname_conf(filename)
+ hostname = conf.hostname
+ except IOError:
+ pass
+ if not hostname:
+ return default
+ return hostname
+
+ def _get_localhost_ip(self):
+ return "127.0.1.1"
+
+ def set_timezone(self, tz):
+ distros.set_etc_timezone(tz=tz, tz_file=self._find_tz_file(tz))
+
+ def package_command(self, command, args=None, pkgs=None):
+ if pkgs is None:
+ pkgs = []
+
+ cmd = ['apk']
+ # Redirect output
+ cmd.append("--quiet")
+
+ if args and isinstance(args, str):
+ cmd.append(args)
+ elif args and isinstance(args, list):
+ cmd.extend(args)
+
+ if command:
+ cmd.append(command)
+
+ pkglist = util.expand_package_list('%s-%s', pkgs)
+ cmd.extend(pkglist)
+
+ # Allow the output of this to flow outwards (ie not be captured)
+ subp.subp(cmd, capture=False)
+
+ def update_package_sources(self):
+ self._runner.run("update-sources", self.package_command,
+ ["update"], freq=PER_INSTANCE)
+
+ @property
+ def preferred_ntp_clients(self):
+ """Allow distro to determine the preferred ntp client list"""
+ if not self._preferred_ntp_clients:
+ self._preferred_ntp_clients = ['chrony', 'ntp']
+
+ return self._preferred_ntp_clients
+
+# vi: ts=4 expandtab
diff --git a/cloudinit/distros/arch.py b/cloudinit/distros/arch.py
index 9f89c5f9..967be168 100644
--- a/cloudinit/distros/arch.py
+++ b/cloudinit/distros/arch.py
@@ -8,6 +8,7 @@ from cloudinit import distros
from cloudinit import helpers
from cloudinit import log as logging
from cloudinit import util
+from cloudinit import subp
from cloudinit.distros import net_util
from cloudinit.distros.parsers.hostname import HostnameConf
@@ -44,7 +45,7 @@ class Distro(distros.Distro):
def apply_locale(self, locale, out_fn=None):
if not out_fn:
out_fn = self.locale_conf_fn
- util.subp(['locale-gen', '-G', locale], capture=False)
+ subp.subp(['locale-gen', '-G', locale], capture=False)
# "" provides trailing newline during join
lines = [
util.make_header(),
@@ -60,9 +61,9 @@ class Distro(distros.Distro):
def _write_network_config(self, netconfig):
try:
return self._supported_write_network_config(netconfig)
- except RendererNotFoundError:
+ except RendererNotFoundError as e:
# Fall back to old _write_network
- raise NotImplementedError
+ raise NotImplementedError from e
def _write_network(self, settings):
entries = net_util.translate_network(settings)
@@ -76,11 +77,11 @@ class Distro(distros.Distro):
def _enable_interface(self, device_name):
cmd = ['netctl', 'reenable', device_name]
try:
- (_out, err) = util.subp(cmd)
+ (_out, err) = subp.subp(cmd)
if len(err):
LOG.warning("Running %s resulted in stderr output: %s",
cmd, err)
- except util.ProcessExecutionError:
+ except subp.ProcessExecutionError:
util.logexc(LOG, "Running interface command %s failed", cmd)
def _bring_up_interface(self, device_name):
@@ -88,12 +89,12 @@ class Distro(distros.Distro):
LOG.debug("Attempting to run bring up interface %s using command %s",
device_name, cmd)
try:
- (_out, err) = util.subp(cmd)
+ (_out, err) = subp.subp(cmd)
if len(err):
LOG.warning("Running %s resulted in stderr output: %s",
cmd, err)
return True
- except util.ProcessExecutionError:
+ except subp.ProcessExecutionError:
util.logexc(LOG, "Running interface command %s failed", cmd)
return False
@@ -158,7 +159,7 @@ class Distro(distros.Distro):
cmd.extend(pkglist)
# Allow the output of this to flow outwards (ie not be captured)
- util.subp(cmd, capture=False)
+ subp.subp(cmd, capture=False)
def update_package_sources(self):
self._runner.run("update-sources", self.package_command,
@@ -173,8 +174,8 @@ def _render_network(entries, target="/", conf_dir="etc/netctl",
devs = []
nameservers = []
- resolv_conf = util.target_path(target, resolv_conf)
- conf_dir = util.target_path(target, conf_dir)
+ resolv_conf = subp.target_path(target, resolv_conf)
+ conf_dir = subp.target_path(target, conf_dir)
for (dev, info) in entries.items():
if dev == 'lo':
diff --git a/cloudinit/distros/bsd.py b/cloudinit/distros/bsd.py
new file mode 100644
index 00000000..2ed7a7d5
--- /dev/null
+++ b/cloudinit/distros/bsd.py
@@ -0,0 +1,129 @@
+import platform
+
+from cloudinit import distros
+from cloudinit.distros import bsd_utils
+from cloudinit import helpers
+from cloudinit import log as logging
+from cloudinit import net
+from cloudinit import subp
+from cloudinit import util
+from .networking import BSDNetworking
+
+LOG = logging.getLogger(__name__)
+
+
+class BSD(distros.Distro):
+ networking_cls = BSDNetworking
+ hostname_conf_fn = '/etc/rc.conf'
+ rc_conf_fn = "/etc/rc.conf"
+
+ # Set in BSD distro subclasses
+ group_add_cmd_prefix = []
+ pkg_cmd_install_prefix = []
+ pkg_cmd_remove_prefix = []
+ # There is no update/upgrade on OpenBSD
+ pkg_cmd_update_prefix = None
+ pkg_cmd_upgrade_prefix = None
+
+ def __init__(self, name, cfg, paths):
+ super().__init__(name, cfg, paths)
+ # This will be used to restrict certain
+ # calls from repeatly happening (when they
+ # should only happen say once per instance...)
+ self._runner = helpers.Runners(paths)
+ cfg['ssh_svcname'] = 'sshd'
+ self.osfamily = platform.system().lower()
+
+ def _read_system_hostname(self):
+ sys_hostname = self._read_hostname(self.hostname_conf_fn)
+ return (self.hostname_conf_fn, sys_hostname)
+
+ def _read_hostname(self, filename, default=None):
+ return bsd_utils.get_rc_config_value('hostname')
+
+ def _get_add_member_to_group_cmd(self, member_name, group_name):
+ raise NotImplementedError('Return list cmd to add member to group')
+
+ def _write_hostname(self, hostname, filename):
+ bsd_utils.set_rc_config_value('hostname', hostname, fn='/etc/rc.conf')
+
+ def create_group(self, name, members=None):
+ if util.is_group(name):
+ LOG.warning("Skipping creation of existing group '%s'", name)
+ else:
+ group_add_cmd = self.group_add_cmd_prefix + [name]
+ try:
+ subp.subp(group_add_cmd)
+ LOG.info("Created new group %s", name)
+ except Exception:
+ util.logexc(LOG, "Failed to create group %s", name)
+
+ if not members:
+ members = []
+ for member in members:
+ if not util.is_user(member):
+ LOG.warning("Unable to add group member '%s' to group '%s'"
+ "; user does not exist.", member, name)
+ continue
+ try:
+ subp.subp(self._get_add_member_to_group_cmd(member, name))
+ LOG.info("Added user '%s' to group '%s'", member, name)
+ except Exception:
+ util.logexc(LOG, "Failed to add user '%s' to group '%s'",
+ member, name)
+
+ def generate_fallback_config(self):
+ nconf = {'config': [], 'version': 1}
+ for mac, name in net.get_interfaces_by_mac().items():
+ nconf['config'].append(
+ {'type': 'physical', 'name': name,
+ 'mac_address': mac, 'subnets': [{'type': 'dhcp'}]})
+ return nconf
+
+ def install_packages(self, pkglist):
+ self.update_package_sources()
+ self.package_command('install', pkgs=pkglist)
+
+ def _get_pkg_cmd_environ(self):
+ """Return environment vars used in *BSD package_command operations"""
+ raise NotImplementedError('BSD subclasses return a dict of env vars')
+
+ def package_command(self, command, args=None, pkgs=None):
+ if pkgs is None:
+ pkgs = []
+
+ if command == 'install':
+ cmd = self.pkg_cmd_install_prefix
+ elif command == 'remove':
+ cmd = self.pkg_cmd_remove_prefix
+ elif command == 'update':
+ if not self.pkg_cmd_update_prefix:
+ return
+ cmd = self.pkg_cmd_update_prefix
+ elif command == 'upgrade':
+ if not self.pkg_cmd_upgrade_prefix:
+ return
+ cmd = self.pkg_cmd_upgrade_prefix
+
+ if args and isinstance(args, str):
+ cmd.append(args)
+ elif args and isinstance(args, list):
+ cmd.extend(args)
+
+ pkglist = util.expand_package_list('%s-%s', pkgs)
+ cmd.extend(pkglist)
+
+ # Allow the output of this to flow outwards (ie not be captured)
+ subp.subp(cmd, env=self._get_pkg_cmd_environ(), capture=False)
+
+ def _write_network_config(self, netconfig):
+ return self._supported_write_network_config(netconfig)
+
+ def set_timezone(self, tz):
+ distros.set_etc_timezone(tz=tz, tz_file=self._find_tz_file(tz))
+
+ def apply_locale(self, locale, out_fn=None):
+ LOG.debug('Cannot set the locale.')
+
+ def apply_network_config_names(self, netconfig):
+ LOG.debug('Cannot rename network interface.')
diff --git a/cloudinit/distros/bsd_utils.py b/cloudinit/distros/bsd_utils.py
new file mode 100644
index 00000000..079d0d53
--- /dev/null
+++ b/cloudinit/distros/bsd_utils.py
@@ -0,0 +1,50 @@
+# This file is part of cloud-init. See LICENSE file for license information.
+
+import shlex
+
+from cloudinit import util
+
+# On NetBSD, /etc/rc.conf comes with a if block:
+# if [ -r /etc/defaults/rc.conf ]; then
+# as a consequence, the file is not a regular key/value list
+# anymore and we cannot use cloudinit.distros.parsers.sys_conf
+# The module comes with a more naive parser, but is able to
+# preserve these if blocks.
+
+
+def _unquote(value):
+ if value[0] == value[-1] and value[0] in ['"', "'"]:
+ return value[1:-1]
+ return value
+
+
+def get_rc_config_value(key, fn='/etc/rc.conf'):
+ key_prefix = '{}='.format(key)
+ for line in util.load_file(fn).splitlines():
+ if line.startswith(key_prefix):
+ value = line.replace(key_prefix, '')
+ return _unquote(value)
+
+
+def set_rc_config_value(key, value, fn='/etc/rc.conf'):
+ lines = []
+ done = False
+ value = shlex.quote(value)
+ original_content = util.load_file(fn)
+ for line in original_content.splitlines():
+ if '=' in line:
+ k, v = line.split('=', 1)
+ if k == key:
+ v = value
+ done = True
+ lines.append('='.join([k, v]))
+ else:
+ lines.append(line)
+ if not done:
+ lines.append('='.join([key, value]))
+ new_content = '\n'.join(lines) + '\n'
+ if new_content != original_content:
+ util.write_file(fn, new_content)
+
+
+# vi: ts=4 expandtab
diff --git a/cloudinit/distros/debian.py b/cloudinit/distros/debian.py
index 128bb523..844aaf21 100644
--- a/cloudinit/distros/debian.py
+++ b/cloudinit/distros/debian.py
@@ -13,6 +13,7 @@ import os
from cloudinit import distros
from cloudinit import helpers
from cloudinit import log as logging
+from cloudinit import subp
from cloudinit import util
from cloudinit.distros.parsers.hostname import HostnameConf
@@ -197,7 +198,7 @@ class Distro(distros.Distro):
# Allow the output of this to flow outwards (ie not be captured)
util.log_time(logfunc=LOG.debug,
msg="apt-%s [%s]" % (command, ' '.join(cmd)),
- func=util.subp,
+ func=subp.subp,
args=(cmd,), kwargs={'env': e, 'capture': False})
def update_package_sources(self):
@@ -214,7 +215,7 @@ def _get_wrapper_prefix(cmd, mode):
if (util.is_true(mode) or
(str(mode).lower() == "auto" and cmd[0] and
- util.which(cmd[0]))):
+ subp.which(cmd[0]))):
return cmd
else:
return []
@@ -269,7 +270,7 @@ def update_locale_conf(locale, sys_path, keyname='LANG'):
"""Update system locale config"""
LOG.debug('Updating %s with locale setting %s=%s',
sys_path, keyname, locale)
- util.subp(
+ subp.subp(
['update-locale', '--locale-file=' + sys_path,
'%s=%s' % (keyname, locale)], capture=False)
@@ -291,7 +292,7 @@ def regenerate_locale(locale, sys_path, keyname='LANG'):
# finally, trigger regeneration
LOG.debug('Generating locales for %s', locale)
- util.subp(['locale-gen', locale], capture=False)
+ subp.subp(['locale-gen', locale], capture=False)
# vi: ts=4 expandtab
diff --git a/cloudinit/distros/freebsd.py b/cloudinit/distros/freebsd.py
index 026d1142..dde34d41 100644
--- a/cloudinit/distros/freebsd.py
+++ b/cloudinit/distros/freebsd.py
@@ -8,34 +8,25 @@ import os
import re
from io import StringIO
-from cloudinit import distros
-from cloudinit import helpers
+import cloudinit.distros.bsd
from cloudinit import log as logging
-from cloudinit import net
-from cloudinit import ssh_util
+from cloudinit import subp
from cloudinit import util
-from cloudinit.distros import rhel_util
from cloudinit.settings import PER_INSTANCE
LOG = logging.getLogger(__name__)
-class Distro(distros.Distro):
+class Distro(cloudinit.distros.bsd.BSD):
usr_lib_exec = '/usr/local/lib'
- rc_conf_fn = "/etc/rc.conf"
login_conf_fn = '/etc/login.conf'
login_conf_fn_bak = '/etc/login.conf.orig'
ci_sudoers_fn = '/usr/local/etc/sudoers.d/90-cloud-init-users'
- hostname_conf_fn = '/etc/rc.conf'
-
- def __init__(self, name, cfg, paths):
- distros.Distro.__init__(self, name, cfg, paths)
- # This will be used to restrict certain
- # calls from repeatly happening (when they
- # should only happen say once per instance...)
- self._runner = helpers.Runners(paths)
- self.osfamily = 'freebsd'
- cfg['ssh_svcname'] = 'sshd'
+ group_add_cmd_prefix = ['pw', 'group', 'add']
+ pkg_cmd_install_prefix = ["pkg", "install"]
+ pkg_cmd_remove_prefix = ["pkg", "remove"]
+ pkg_cmd_update_prefix = ["pkg", "update"]
+ pkg_cmd_upgrade_prefix = ["pkg", "upgrade"]
def _select_hostname(self, hostname, fqdn):
# Should be FQDN if available. See rc.conf(5) in FreeBSD
@@ -43,45 +34,8 @@ class Distro(distros.Distro):
return fqdn
return hostname
- def _read_system_hostname(self):
- sys_hostname = self._read_hostname(self.hostname_conf_fn)
- return (self.hostname_conf_fn, sys_hostname)
-
- def _read_hostname(self, filename, default=None):
- (_exists, contents) = rhel_util.read_sysconfig_file(filename)
- if contents.get('hostname'):
- return contents['hostname']
- else:
- return default
-
- def _write_hostname(self, hostname, filename):
- rhel_util.update_sysconfig_file(filename, {'hostname': hostname})
-
- def create_group(self, name, members):
- group_add_cmd = ['pw', 'group', 'add', name]
- if util.is_group(name):
- LOG.warning("Skipping creation of existing group '%s'", name)
- else:
- try:
- util.subp(group_add_cmd)
- LOG.info("Created new group %s", name)
- except Exception:
- util.logexc(LOG, "Failed to create group %s", name)
- raise
- if not members:
- members = []
-
- for member in members:
- if not util.is_user(member):
- LOG.warning("Unable to add group member '%s' to group '%s'"
- "; user does not exist.", member, name)
- continue
- try:
- util.subp(['pw', 'usermod', '-n', name, '-G', member])
- LOG.info("Added user '%s' to group '%s'", member, name)
- except Exception:
- util.logexc(LOG, "Failed to add user '%s' to group '%s'",
- member, name)
+ def _get_add_member_to_group_cmd(self, member_name, group_name):
+ return ['pw', 'usermod', '-n', member_name, '-G', group_name]
def add_user(self, name, **kwargs):
if util.is_user(name):
@@ -125,7 +79,7 @@ class Distro(distros.Distro):
# Run the command
LOG.info("Adding user %s", name)
try:
- util.subp(pw_useradd_cmd, logstring=log_pw_useradd_cmd)
+ subp.subp(pw_useradd_cmd, logstring=log_pw_useradd_cmd)
except Exception:
util.logexc(LOG, "Failed to create user %s", name)
raise
@@ -137,7 +91,7 @@ class Distro(distros.Distro):
def expire_passwd(self, user):
try:
- util.subp(['pw', 'usermod', user, '-p', '01-Jan-1970'])
+ subp.subp(['pw', 'usermod', user, '-p', '01-Jan-1970'])
except Exception:
util.logexc(LOG, "Failed to set pw expiration for %s", user)
raise
@@ -149,7 +103,7 @@ class Distro(distros.Distro):
hash_opt = "-h"
try:
- util.subp(['pw', 'usermod', user, hash_opt, '0'],
+ subp.subp(['pw', 'usermod', user, hash_opt, '0'],
data=passwd, logstring="chpasswd for %s" % user)
except Exception:
util.logexc(LOG, "Failed to set password for %s", user)
@@ -157,45 +111,13 @@ class Distro(distros.Distro):
def lock_passwd(self, name):
try:
- util.subp(['pw', 'usermod', name, '-h', '-'])
+ subp.subp(['pw', 'usermod', name, '-h', '-'])
except Exception:
util.logexc(LOG, "Failed to lock user %s", name)
raise
- def create_user(self, name, **kwargs):
- self.add_user(name, **kwargs)
-
- # Set password if plain-text password provided and non-empty
- if 'plain_text_passwd' in kwargs and kwargs['plain_text_passwd']:
- self.set_passwd(name, kwargs['plain_text_passwd'])
-
- # Default locking down the account. 'lock_passwd' defaults to True.
- # lock account unless lock_password is False.
- if kwargs.get('lock_passwd', True):
- self.lock_passwd(name)
-
- # Configure sudo access
- if 'sudo' in kwargs and kwargs['sudo'] is not False:
- self.write_sudo_rules(name, kwargs['sudo'])
-
- # Import SSH keys
- if 'ssh_authorized_keys' in kwargs:
- keys = set(kwargs['ssh_authorized_keys']) or []
- ssh_util.setup_user_keys(keys, name, options=None)
-
- def generate_fallback_config(self):
- nconf = {'config': [], 'version': 1}
- for mac, name in net.get_interfaces_by_mac().items():
- nconf['config'].append(
- {'type': 'physical', 'name': name,
- 'mac_address': mac, 'subnets': [{'type': 'dhcp'}]})
- return nconf
-
- def _write_network_config(self, netconfig):
- return self._supported_write_network_config(netconfig)
-
def apply_locale(self, locale, out_fn=None):
- # Adjust the locals value to the new value
+ # Adjust the locales value to the new value
newconf = StringIO()
for line in util.load_file(self.login_conf_fn).splitlines():
newconf.write(re.sub(r'^default:',
@@ -210,8 +132,8 @@ class Distro(distros.Distro):
try:
LOG.debug("Running cap_mkdb for %s", locale)
- util.subp(['cap_mkdb', self.login_conf_fn])
- except util.ProcessExecutionError:
+ subp.subp(['cap_mkdb', self.login_conf_fn])
+ except subp.ProcessExecutionError:
# cap_mkdb failed, so restore the backup.
util.logexc(LOG, "Failed to apply locale %s", locale)
try:
@@ -225,39 +147,17 @@ class Distro(distros.Distro):
# /etc/rc.conf a line with the following format:
# ifconfig_OLDNAME_name=NEWNAME
# FreeBSD network script will rename the interface automatically.
- return
-
- def install_packages(self, pkglist):
- self.update_package_sources()
- self.package_command('install', pkgs=pkglist)
-
- def package_command(self, command, args=None, pkgs=None):
- if pkgs is None:
- pkgs = []
+ pass
+ def _get_pkg_cmd_environ(self):
+ """Return environment vars used in *BSD package_command operations"""
e = os.environ.copy()
e['ASSUME_ALWAYS_YES'] = 'YES'
-
- cmd = ['pkg']
- if args and isinstance(args, str):
- cmd.append(args)
- elif args and isinstance(args, list):
- cmd.extend(args)
-
- if command:
- cmd.append(command)
-
- pkglist = util.expand_package_list('%s-%s', pkgs)
- cmd.extend(pkglist)
-
- # Allow the output of this to flow outwards (ie not be captured)
- util.subp(cmd, env=e, capture=False)
-
- def set_timezone(self, tz):
- distros.set_etc_timezone(tz=tz, tz_file=self._find_tz_file(tz))
+ return e
def update_package_sources(self):
- self._runner.run("update-sources", self.package_command,
- ["update"], freq=PER_INSTANCE)
+ self._runner.run(
+ "update-sources", self.package_command,
+ ["update"], freq=PER_INSTANCE)
# vi: ts=4 expandtab
diff --git a/cloudinit/distros/gentoo.py b/cloudinit/distros/gentoo.py
index dc57717d..2bee1c89 100644
--- a/cloudinit/distros/gentoo.py
+++ b/cloudinit/distros/gentoo.py
@@ -9,6 +9,7 @@
from cloudinit import distros
from cloudinit import helpers
from cloudinit import log as logging
+from cloudinit import subp
from cloudinit import util
from cloudinit.distros import net_util
@@ -39,7 +40,7 @@ class Distro(distros.Distro):
def apply_locale(self, locale, out_fn=None):
if not out_fn:
out_fn = self.locale_conf_fn
- util.subp(['locale-gen', '-G', locale], capture=False)
+ subp.subp(['locale-gen', '-G', locale], capture=False)
# "" provides trailing newline during join
lines = [
util.make_header(),
@@ -94,11 +95,11 @@ class Distro(distros.Distro):
cmd = ['rc-update', 'add', 'net.{name}'.format(name=dev),
'default']
try:
- (_out, err) = util.subp(cmd)
+ (_out, err) = subp.subp(cmd)
if len(err):
LOG.warning("Running %s resulted in stderr output: %s",
cmd, err)
- except util.ProcessExecutionError:
+ except subp.ProcessExecutionError:
util.logexc(LOG, "Running interface command %s failed",
cmd)
@@ -119,12 +120,12 @@ class Distro(distros.Distro):
LOG.debug("Attempting to run bring up interface %s using command %s",
device_name, cmd)
try:
- (_out, err) = util.subp(cmd)
+ (_out, err) = subp.subp(cmd)
if len(err):
LOG.warning("Running %s resulted in stderr output: %s",
cmd, err)
return True
- except util.ProcessExecutionError:
+ except subp.ProcessExecutionError:
util.logexc(LOG, "Running interface command %s failed", cmd)
return False
@@ -137,11 +138,11 @@ class Distro(distros.Distro):
# Grab device names from init scripts
cmd = ['ls', '/etc/init.d/net.*']
try:
- (_out, err) = util.subp(cmd)
+ (_out, err) = subp.subp(cmd)
if len(err):
LOG.warning("Running %s resulted in stderr output: %s",
cmd, err)
- except util.ProcessExecutionError:
+ except subp.ProcessExecutionError:
util.logexc(LOG, "Running interface command %s failed", cmd)
return False
devices = [x.split('.')[2] for x in _out.split(' ')]
@@ -208,7 +209,7 @@ class Distro(distros.Distro):
cmd.extend(pkglist)
# Allow the output of this to flow outwards (ie not be captured)
- util.subp(cmd, capture=False)
+ subp.subp(cmd, capture=False)
def update_package_sources(self):
self._runner.run("update-sources", self.package_command,
diff --git a/cloudinit/distros/netbsd.py b/cloudinit/distros/netbsd.py
new file mode 100644
index 00000000..f1a9b182
--- /dev/null
+++ b/cloudinit/distros/netbsd.py
@@ -0,0 +1,159 @@
+# Copyright (C) 2019-2020 Gonéri Le Bouder
+#
+# This file is part of cloud-init. See LICENSE file for license information.
+
+import crypt
+import os
+import platform
+
+import cloudinit.distros.bsd
+from cloudinit import log as logging
+from cloudinit import subp
+from cloudinit import util
+
+LOG = logging.getLogger(__name__)
+
+
+class NetBSD(cloudinit.distros.bsd.BSD):
+ """
+ Distro subclass for NetBSD.
+
+ (N.B. OpenBSD inherits from this class.)
+ """
+
+ ci_sudoers_fn = '/usr/pkg/etc/sudoers.d/90-cloud-init-users'
+ group_add_cmd_prefix = ["groupadd"]
+
+ def __init__(self, name, cfg, paths):
+ super().__init__(name, cfg, paths)
+ if os.path.exists("/usr/pkg/bin/pkgin"):
+ self.pkg_cmd_install_prefix = ['pkgin', '-y', 'install']
+ self.pkg_cmd_remove_prefix = ['pkgin', '-y', 'remove']
+ self.pkg_cmd_update_prefix = ['pkgin', '-y', 'update']
+ self.pkg_cmd_upgrade_prefix = ['pkgin', '-y', 'full-upgrade']
+ else:
+ self.pkg_cmd_install_prefix = ['pkg_add', '-U']
+ self.pkg_cmd_remove_prefix = ['pkg_delete']
+
+ def _get_add_member_to_group_cmd(self, member_name, group_name):
+ return ['usermod', '-G', group_name, member_name]
+
+ def add_user(self, name, **kwargs):
+ if util.is_user(name):
+ LOG.info("User %s already exists, skipping.", name)
+ return False
+
+ adduser_cmd = ['useradd']
+ log_adduser_cmd = ['useradd']
+
+ adduser_opts = {
+ "homedir": '-d',
+ "gecos": '-c',
+ "primary_group": '-g',
+ "groups": '-G',
+ "shell": '-s',
+ }
+ adduser_flags = {
+ "no_user_group": '--no-user-group',
+ "system": '--system',
+ "no_log_init": '--no-log-init',
+ }
+
+ for key, val in kwargs.items():
+ if key in adduser_opts and val and isinstance(val, str):
+ adduser_cmd.extend([adduser_opts[key], val])
+
+ elif key in adduser_flags and val:
+ adduser_cmd.append(adduser_flags[key])
+ log_adduser_cmd.append(adduser_flags[key])
+
+ if 'no_create_home' not in kwargs or 'system' not in kwargs:
+ adduser_cmd += ['-m']
+ log_adduser_cmd += ['-m']
+
+ adduser_cmd += [name]
+ log_adduser_cmd += [name]
+
+ # Run the command
+ LOG.info("Adding user %s", name)
+ try:
+ subp.subp(adduser_cmd, logstring=log_adduser_cmd)
+ except Exception:
+ util.logexc(LOG, "Failed to create user %s", name)
+ raise
+ # Set the password if it is provided
+ # For security consideration, only hashed passwd is assumed
+ passwd_val = kwargs.get('passwd', None)
+ if passwd_val is not None:
+ self.set_passwd(name, passwd_val, hashed=True)
+
+ def set_passwd(self, user, passwd, hashed=False):
+ if hashed:
+ hashed_pw = passwd
+ elif not hasattr(crypt, 'METHOD_BLOWFISH'):
+ # crypt.METHOD_BLOWFISH comes with Python 3.7 which is available
+ # on NetBSD 7 and 8.
+ LOG.error((
+ 'Cannot set non-encrypted password for user %s. '
+ 'Python >= 3.7 is required.'), user)
+ return
+ else:
+ method = crypt.METHOD_BLOWFISH # pylint: disable=E1101
+ hashed_pw = crypt.crypt(
+ passwd,
+ crypt.mksalt(method)
+ )
+
+ try:
+ subp.subp(['usermod', '-p', hashed_pw, user])
+ except Exception:
+ util.logexc(LOG, "Failed to set password for %s", user)
+ raise
+ self.unlock_passwd(user)
+
+ def force_passwd_change(self, user):
+ try:
+ subp.subp(['usermod', '-F', user])
+ except Exception:
+ util.logexc(LOG, "Failed to set pw expiration for %s", user)
+ raise
+
+ def lock_passwd(self, name):
+ try:
+ subp.subp(['usermod', '-C', 'yes', name])
+ except Exception:
+ util.logexc(LOG, "Failed to lock user %s", name)
+ raise
+
+ def unlock_passwd(self, name):
+ try:
+ subp.subp(['usermod', '-C', 'no', name])
+ except Exception:
+ util.logexc(LOG, "Failed to unlock user %s", name)
+ raise
+
+ def apply_locale(self, locale, out_fn=None):
+ LOG.debug('Cannot set the locale.')
+
+ def apply_network_config_names(self, netconfig):
+ LOG.debug('NetBSD cannot rename network interface.')
+
+ def _get_pkg_cmd_environ(self):
+ """Return env vars used in NetBSD package_command operations"""
+ os_release = platform.release()
+ os_arch = platform.machine()
+ e = os.environ.copy()
+ e['PKG_PATH'] = (
+ 'http://cdn.netbsd.org/pub/pkgsrc/'
+ 'packages/NetBSD/%s/%s/All'
+ ) % (os_arch, os_release)
+ return e
+
+ def update_package_sources(self):
+ pass
+
+
+class Distro(NetBSD):
+ pass
+
+# vi: ts=4 expandtab
diff --git a/cloudinit/distros/networking.py b/cloudinit/distros/networking.py
new file mode 100644
index 00000000..10ed249d
--- /dev/null
+++ b/cloudinit/distros/networking.py
@@ -0,0 +1,212 @@
+import abc
+import logging
+import os
+
+from cloudinit import net, util
+
+
+LOG = logging.getLogger(__name__)
+
+
+# Type aliases (https://docs.python.org/3/library/typing.html#type-aliases),
+# used to make the signatures of methods a little clearer
+DeviceName = str
+NetworkConfig = dict
+
+
+class Networking(metaclass=abc.ABCMeta):
+ """The root of the Networking hierarchy in cloud-init.
+
+ This is part of an ongoing refactor in the cloud-init codebase, for more
+ details see "``cloudinit.net`` -> ``cloudinit.distros.networking``
+ Hierarchy" in HACKING.rst for full details.
+ """
+
+ def _get_current_rename_info(self) -> dict:
+ return net._get_current_rename_info()
+
+ def _rename_interfaces(self, renames: list, *, current_info=None) -> None:
+ return net._rename_interfaces(renames, current_info=current_info)
+
+ def apply_network_config_names(self, netcfg: NetworkConfig) -> None:
+ return net.apply_network_config_names(netcfg)
+
+ def device_devid(self, devname: DeviceName):
+ return net.device_devid(devname)
+
+ def device_driver(self, devname: DeviceName):
+ return net.device_driver(devname)
+
+ def extract_physdevs(self, netcfg: NetworkConfig) -> list:
+ return net.extract_physdevs(netcfg)
+
+ def find_fallback_nic(self, *, blacklist_drivers=None):
+ return net.find_fallback_nic(blacklist_drivers=blacklist_drivers)
+
+ def generate_fallback_config(
+ self, *, blacklist_drivers=None, config_driver: bool = False
+ ):
+ return net.generate_fallback_config(
+ blacklist_drivers=blacklist_drivers, config_driver=config_driver
+ )
+
+ def get_devicelist(self) -> list:
+ return net.get_devicelist()
+
+ def get_ib_hwaddrs_by_interface(self) -> dict:
+ return net.get_ib_hwaddrs_by_interface()
+
+ def get_ib_interface_hwaddr(
+ self, devname: DeviceName, ethernet_format: bool
+ ):
+ return net.get_ib_interface_hwaddr(devname, ethernet_format)
+
+ def get_interface_mac(self, devname: DeviceName):
+ return net.get_interface_mac(devname)
+
+ def get_interfaces(self) -> list:
+ return net.get_interfaces()
+
+ def get_interfaces_by_mac(self) -> dict:
+ return net.get_interfaces_by_mac()
+
+ def get_master(self, devname: DeviceName):
+ return net.get_master(devname)
+
+ def interface_has_own_mac(
+ self, devname: DeviceName, *, strict: bool = False
+ ) -> bool:
+ return net.interface_has_own_mac(devname, strict=strict)
+
+ def is_bond(self, devname: DeviceName) -> bool:
+ return net.is_bond(devname)
+
+ def is_bridge(self, devname: DeviceName) -> bool:
+ return net.is_bridge(devname)
+
+ @abc.abstractmethod
+ def is_physical(self, devname: DeviceName) -> bool:
+ """
+ Is ``devname`` a physical network device?
+
+ Examples of non-physical network devices: bonds, bridges, tunnels,
+ loopback devices.
+ """
+
+ def is_renamed(self, devname: DeviceName) -> bool:
+ return net.is_renamed(devname)
+
+ def is_up(self, devname: DeviceName) -> bool:
+ return net.is_up(devname)
+
+ def is_vlan(self, devname: DeviceName) -> bool:
+ return net.is_vlan(devname)
+
+ def master_is_bridge_or_bond(self, devname: DeviceName) -> bool:
+ return net.master_is_bridge_or_bond(devname)
+
+ @abc.abstractmethod
+ def settle(self, *, exists=None) -> None:
+ """Wait for device population in the system to complete.
+
+ :param exists:
+ An optional optimisation. If given, only perform as much of the
+ settle process as is required for the given DeviceName to be
+ present in the system. (This may include skipping the settle
+ process entirely, if the device already exists.)
+ :type exists: Optional[DeviceName]
+ """
+
+ def wait_for_physdevs(
+ self, netcfg: NetworkConfig, *, strict: bool = True
+ ) -> None:
+ """Wait for all the physical devices in `netcfg` to exist on the system
+
+ Specifically, this will call `self.settle` 5 times, and check after
+ each one if the physical devices are now present in the system.
+
+ :param netcfg:
+ The NetworkConfig from which to extract physical devices to wait
+ for.
+ :param strict:
+ Raise a `RuntimeError` if any physical devices are not present
+ after waiting.
+ """
+ physdevs = self.extract_physdevs(netcfg)
+
+ # set of expected iface names and mac addrs
+ expected_ifaces = dict([(iface[0], iface[1]) for iface in physdevs])
+ expected_macs = set(expected_ifaces.keys())
+
+ # set of current macs
+ present_macs = self.get_interfaces_by_mac().keys()
+
+ # compare the set of expected mac address values to
+ # the current macs present; we only check MAC as cloud-init
+ # has not yet renamed interfaces and the netcfg may include
+ # such renames.
+ for _ in range(0, 5):
+ if expected_macs.issubset(present_macs):
+ LOG.debug("net: all expected physical devices present")
+ return
+
+ missing = expected_macs.difference(present_macs)
+ LOG.debug("net: waiting for expected net devices: %s", missing)
+ for mac in missing:
+ # trigger a settle, unless this interface exists
+ devname = expected_ifaces[mac]
+ msg = "Waiting for settle or {} exists".format(devname)
+ util.log_time(
+ LOG.debug,
+ msg,
+ func=self.settle,
+ kwargs={"exists": devname},
+ )
+
+ # update present_macs after settles
+ present_macs = self.get_interfaces_by_mac().keys()
+
+ msg = "Not all expected physical devices present: %s" % missing
+ LOG.warning(msg)
+ if strict:
+ raise RuntimeError(msg)
+
+
+class BSDNetworking(Networking):
+ """Implementation of networking functionality shared across BSDs."""
+
+ def is_physical(self, devname: DeviceName) -> bool:
+ raise NotImplementedError()
+
+ def settle(self, *, exists=None) -> None:
+ """BSD has no equivalent to `udevadm settle`; noop."""
+
+
+class LinuxNetworking(Networking):
+ """Implementation of networking functionality common to Linux distros."""
+
+ def get_dev_features(self, devname: DeviceName) -> str:
+ return net.get_dev_features(devname)
+
+ def has_netfail_standby_feature(self, devname: DeviceName) -> bool:
+ return net.has_netfail_standby_feature(devname)
+
+ def is_netfailover(self, devname: DeviceName) -> bool:
+ return net.is_netfailover(devname)
+
+ def is_netfail_master(self, devname: DeviceName) -> bool:
+ return net.is_netfail_master(devname)
+
+ def is_netfail_primary(self, devname: DeviceName) -> bool:
+ return net.is_netfail_primary(devname)
+
+ def is_netfail_standby(self, devname: DeviceName) -> bool:
+ return net.is_netfail_standby(devname)
+
+ def is_physical(self, devname: DeviceName) -> bool:
+ return os.path.exists(net.sys_dev_path(devname, "device"))
+
+ def settle(self, *, exists=None) -> None:
+ if exists is not None:
+ exists = net.sys_dev_path(exists)
+ util.udevadm_settle(exists=exists)
diff --git a/cloudinit/distros/openbsd.py b/cloudinit/distros/openbsd.py
new file mode 100644
index 00000000..720c9cf3
--- /dev/null
+++ b/cloudinit/distros/openbsd.py
@@ -0,0 +1,52 @@
+# Copyright (C) 2019-2020 Gonéri Le Bouder
+#
+# This file is part of cloud-init. See LICENSE file for license information.
+
+import os
+import platform
+
+import cloudinit.distros.netbsd
+from cloudinit import log as logging
+from cloudinit import subp
+from cloudinit import util
+
+LOG = logging.getLogger(__name__)
+
+
+class Distro(cloudinit.distros.netbsd.NetBSD):
+ hostname_conf_fn = '/etc/myname'
+
+ def _read_hostname(self, filename, default=None):
+ return util.load_file(self.hostname_conf_fn)
+
+ def _write_hostname(self, hostname, filename):
+ content = hostname + '\n'
+ util.write_file(self.hostname_conf_fn, content)
+
+ def _get_add_member_to_group_cmd(self, member_name, group_name):
+ return ['usermod', '-G', group_name, member_name]
+
+ def lock_passwd(self, name):
+ try:
+ subp.subp(['usermod', '-p', '*', name])
+ except Exception:
+ util.logexc(LOG, "Failed to lock user %s", name)
+ raise
+
+ def unlock_passwd(self, name):
+ pass
+
+ def _get_pkg_cmd_environ(self):
+ """Return env vars used in OpenBSD package_command operations"""
+ os_release = platform.release()
+ os_arch = platform.machine()
+ e = os.environ.copy()
+ e['PKG_PATH'] = (
+ 'ftp://ftp.openbsd.org/pub/OpenBSD/{os_release}/'
+ 'packages/{os_arch}/').format(
+ os_arch=os_arch, os_release=os_release
+ )
+ return e
+
+
+# vi: ts=4 expandtab
diff --git a/cloudinit/distros/opensuse.py b/cloudinit/distros/opensuse.py
index dd56a3f4..b8e557b8 100644
--- a/cloudinit/distros/opensuse.py
+++ b/cloudinit/distros/opensuse.py
@@ -14,6 +14,7 @@ from cloudinit.distros.parsers.hostname import HostnameConf
from cloudinit import helpers
from cloudinit import log as logging
+from cloudinit import subp
from cloudinit import util
from cloudinit.distros import rhel_util as rhutil
@@ -97,7 +98,7 @@ class Distro(distros.Distro):
cmd.extend(pkglist)
# Allow the output of this to flow outwards (ie not be captured)
- util.subp(cmd, capture=False)
+ subp.subp(cmd, capture=False)
def set_timezone(self, tz):
tz_file = self._find_tz_file(tz)
@@ -129,7 +130,7 @@ class Distro(distros.Distro):
if self.uses_systemd() and filename.endswith('/previous-hostname'):
return util.load_file(filename).strip()
elif self.uses_systemd():
- (out, _err) = util.subp(['hostname'])
+ (out, _err) = subp.subp(['hostname'])
if len(out):
return out
else:
@@ -144,6 +145,9 @@ class Distro(distros.Distro):
return default
return hostname
+ def _get_localhost_ip(self):
+ return "127.0.1.1"
+
def _read_hostname_conf(self, filename):
conf = HostnameConf(util.load_file(filename))
conf.parse()
@@ -160,7 +164,7 @@ class Distro(distros.Distro):
if self.uses_systemd() and out_fn.endswith('/previous-hostname'):
util.write_file(out_fn, hostname)
elif self.uses_systemd():
- util.subp(['hostnamectl', 'set-hostname', str(hostname)])
+ subp.subp(['hostnamectl', 'set-hostname', str(hostname)])
else:
conf = None
try:
@@ -181,7 +185,7 @@ class Distro(distros.Distro):
def preferred_ntp_clients(self):
"""The preferred ntp client is dependent on the version."""
- """Allow distro to determine the preferred ntp client list"""
+ # Allow distro to determine the preferred ntp client list
if not self._preferred_ntp_clients:
distro_info = util.system_info()['dist']
name = distro_info[0]
diff --git a/cloudinit/distros/parsers/resolv_conf.py b/cloudinit/distros/parsers/resolv_conf.py
index 299d54b5..62929d03 100644
--- a/cloudinit/distros/parsers/resolv_conf.py
+++ b/cloudinit/distros/parsers/resolv_conf.py
@@ -150,9 +150,10 @@ class ResolvConf(object):
tail = ''
try:
(cfg_opt, cfg_values) = head.split(None, 1)
- except (IndexError, ValueError):
- raise IOError("Incorrectly formatted resolv.conf line %s"
- % (i + 1))
+ except (IndexError, ValueError) as e:
+ raise IOError(
+ "Incorrectly formatted resolv.conf line %s" % (i + 1)
+ ) from e
if cfg_opt not in ['nameserver', 'domain',
'search', 'sortlist', 'options']:
raise IOError("Unexpected resolv.conf option %s" % (cfg_opt))
diff --git a/cloudinit/distros/rhel.py b/cloudinit/distros/rhel.py
index f55d96f7..c72f7c17 100644
--- a/cloudinit/distros/rhel.py
+++ b/cloudinit/distros/rhel.py
@@ -11,6 +11,7 @@
from cloudinit import distros
from cloudinit import helpers
from cloudinit import log as logging
+from cloudinit import subp
from cloudinit import util
from cloudinit.distros import rhel_util
@@ -83,7 +84,7 @@ class Distro(distros.Distro):
if self.uses_systemd() and out_fn.endswith('/previous-hostname'):
util.write_file(out_fn, hostname)
elif self.uses_systemd():
- util.subp(['hostnamectl', 'set-hostname', str(hostname)])
+ subp.subp(['hostnamectl', 'set-hostname', str(hostname)])
else:
host_cfg = {
'HOSTNAME': hostname,
@@ -108,7 +109,7 @@ class Distro(distros.Distro):
if self.uses_systemd() and filename.endswith('/previous-hostname'):
return util.load_file(filename).strip()
elif self.uses_systemd():
- (out, _err) = util.subp(['hostname'])
+ (out, _err) = subp.subp(['hostname'])
if len(out):
return out
else:
@@ -146,7 +147,7 @@ class Distro(distros.Distro):
if pkgs is None:
pkgs = []
- if util.which('dnf'):
+ if subp.which('dnf'):
LOG.debug('Using DNF for package management')
cmd = ['dnf']
else:
@@ -173,7 +174,7 @@ class Distro(distros.Distro):
cmd.extend(pkglist)
# Allow the output of this to flow outwards (ie not be captured)
- util.subp(cmd, capture=False)
+ subp.subp(cmd, capture=False)
def update_package_sources(self):
self._runner.run("update-sources", self.package_command,
diff --git a/cloudinit/distros/tests/__init__.py b/cloudinit/distros/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/cloudinit/distros/tests/__init__.py
diff --git a/cloudinit/distros/tests/test_init.py b/cloudinit/distros/tests/test_init.py
new file mode 100644
index 00000000..db534654
--- /dev/null
+++ b/cloudinit/distros/tests/test_init.py
@@ -0,0 +1,156 @@
+# Copyright (C) 2020 Canonical Ltd.
+#
+# Author: Daniel Watkins <oddbloke@ubuntu.com>
+#
+# This file is part of cloud-init. See LICENSE file for license information.
+"""Tests for cloudinit/distros/__init__.py"""
+
+from unittest import mock
+
+import pytest
+
+from cloudinit.distros import _get_package_mirror_info, LDH_ASCII_CHARS
+
+
+# Define a set of characters we would expect to be replaced
+INVALID_URL_CHARS = [
+ chr(x) for x in range(127) if chr(x) not in LDH_ASCII_CHARS
+]
+for separator in [":", ".", "/", "#", "?", "@", "[", "]"]:
+ # Remove from the set characters that either separate hostname parts (":",
+ # "."), terminate hostnames ("/", "#", "?", "@"), or cause Python to be
+ # unable to parse URLs ("[", "]").
+ INVALID_URL_CHARS.remove(separator)
+
+
+class TestGetPackageMirrorInfo:
+ """
+ Tests for cloudinit.distros._get_package_mirror_info.
+
+ These supplement the tests in tests/unittests/test_distros/test_generic.py
+ which are more focused on testing a single production-like configuration.
+ These tests are more focused on specific aspects of the unit under test.
+ """
+
+ @pytest.mark.parametrize('mirror_info,expected', [
+ # Empty info gives empty return
+ ({}, {}),
+ # failsafe values used if present
+ ({'failsafe': {'primary': 'http://value', 'security': 'http://other'}},
+ {'primary': 'http://value', 'security': 'http://other'}),
+ # search values used if present
+ ({'search': {'primary': ['http://value'],
+ 'security': ['http://other']}},
+ {'primary': ['http://value'], 'security': ['http://other']}),
+ # failsafe values used if search value not present
+ ({'search': {'primary': ['http://value']},
+ 'failsafe': {'security': 'http://other'}},
+ {'primary': ['http://value'], 'security': 'http://other'})
+ ])
+ def test_get_package_mirror_info_failsafe(self, mirror_info, expected):
+ """
+ Test the interaction between search and failsafe inputs
+
+ (This doesn't test the case where the mirror_filter removes all search
+ options; test_failsafe_used_if_all_search_results_filtered_out covers
+ that.)
+ """
+ assert expected == _get_package_mirror_info(mirror_info,
+ mirror_filter=lambda x: x)
+
+ def test_failsafe_used_if_all_search_results_filtered_out(self):
+ """Test the failsafe option used if all search options eliminated."""
+ mirror_info = {
+ 'search': {'primary': ['http://value']},
+ 'failsafe': {'primary': 'http://other'}
+ }
+ assert {'primary': 'http://other'} == _get_package_mirror_info(
+ mirror_info, mirror_filter=lambda x: False)
+
+ @pytest.mark.parametrize('allow_ec2_mirror, platform_type', [
+ (True, 'ec2')
+ ])
+ @pytest.mark.parametrize('availability_zone,region,patterns,expected', (
+ # Test ec2_region alone
+ ('fk-fake-1f', None, ['http://EC2-%(ec2_region)s/ubuntu'],
+ ['http://ec2-fk-fake-1/ubuntu']),
+ # Test availability_zone alone
+ ('fk-fake-1f', None, ['http://AZ-%(availability_zone)s/ubuntu'],
+ ['http://az-fk-fake-1f/ubuntu']),
+ # Test region alone
+ (None, 'fk-fake-1', ['http://RG-%(region)s/ubuntu'],
+ ['http://rg-fk-fake-1/ubuntu']),
+ # Test that ec2_region is not available for non-matching AZs
+ ('fake-fake-1f', None,
+ ['http://EC2-%(ec2_region)s/ubuntu',
+ 'http://AZ-%(availability_zone)s/ubuntu'],
+ ['http://az-fake-fake-1f/ubuntu']),
+ # Test that template order maintained
+ (None, 'fake-region',
+ ['http://RG-%(region)s-2/ubuntu', 'http://RG-%(region)s-1/ubuntu'],
+ ['http://rg-fake-region-2/ubuntu', 'http://rg-fake-region-1/ubuntu']),
+ # Test that non-ASCII hostnames are IDNA encoded;
+ # "IDNA-ТεЅТ̣".encode('idna') == b"xn--idna--4kd53hh6aba3q"
+ (None, 'ТεЅТ̣', ['http://www.IDNA-%(region)s.com/ubuntu'],
+ ['http://www.xn--idna--4kd53hh6aba3q.com/ubuntu']),
+ # Test that non-ASCII hostnames with a port are IDNA encoded;
+ # "IDNA-ТεЅТ̣".encode('idna') == b"xn--idna--4kd53hh6aba3q"
+ (None, 'ТεЅТ̣', ['http://www.IDNA-%(region)s.com:8080/ubuntu'],
+ ['http://www.xn--idna--4kd53hh6aba3q.com:8080/ubuntu']),
+ # Test that non-ASCII non-hostname parts of URLs are unchanged
+ (None, 'ТεЅТ̣', ['http://www.example.com/%(region)s/ubuntu'],
+ ['http://www.example.com/ТεЅТ̣/ubuntu']),
+ # Test that IPv4 addresses are unchanged
+ (None, 'fk-fake-1', ['http://192.168.1.1:8080/%(region)s/ubuntu'],
+ ['http://192.168.1.1:8080/fk-fake-1/ubuntu']),
+ # Test that IPv6 addresses are unchanged
+ (None, 'fk-fake-1',
+ ['http://[2001:67c:1360:8001::23]/%(region)s/ubuntu'],
+ ['http://[2001:67c:1360:8001::23]/fk-fake-1/ubuntu']),
+ # Test that unparseable URLs are filtered out of the mirror list
+ (None, 'inv[lid',
+ ['http://%(region)s.in.hostname/should/be/filtered',
+ 'http://but.not.in.the.path/%(region)s'],
+ ['http://but.not.in.the.path/inv[lid']),
+ (None, '-some-region-',
+ ['http://-lead-ing.%(region)s.trail-ing-.example.com/ubuntu'],
+ ['http://lead-ing.some-region.trail-ing.example.com/ubuntu']),
+ ) + tuple(
+ # Dynamically generate a test case for each non-LDH
+ # (Letters/Digits/Hyphen) ASCII character, testing that it is
+ # substituted with a hyphen
+ (None, 'fk{0}fake{0}1'.format(invalid_char),
+ ['http://%(region)s/ubuntu'], ['http://fk-fake-1/ubuntu'])
+ for invalid_char in INVALID_URL_CHARS
+ ))
+ def test_valid_substitution(self,
+ allow_ec2_mirror,
+ platform_type,
+ availability_zone,
+ region,
+ patterns,
+ expected):
+ """Test substitution works as expected."""
+ flag_path = "cloudinit.distros." \
+ "ALLOW_EC2_MIRRORS_ON_NON_AWS_INSTANCE_TYPES"
+
+ m_data_source = mock.Mock(
+ availability_zone=availability_zone,
+ region=region,
+ platform_type=platform_type
+ )
+ mirror_info = {'search': {'primary': patterns}}
+
+ with mock.patch(flag_path, allow_ec2_mirror):
+ ret = _get_package_mirror_info(
+ mirror_info,
+ data_source=m_data_source,
+ mirror_filter=lambda x: x
+ )
+ print(allow_ec2_mirror)
+ print(platform_type)
+ print(availability_zone)
+ print(region)
+ print(patterns)
+ print(expected)
+ assert {'primary': expected} == ret
diff --git a/cloudinit/distros/tests/test_networking.py b/cloudinit/distros/tests/test_networking.py
new file mode 100644
index 00000000..b9a63842
--- /dev/null
+++ b/cloudinit/distros/tests/test_networking.py
@@ -0,0 +1,192 @@
+from unittest import mock
+
+import pytest
+
+from cloudinit import net
+from cloudinit.distros.networking import (
+ BSDNetworking,
+ LinuxNetworking,
+ Networking,
+)
+
+# See https://docs.pytest.org/en/stable/example
+# /parametrize.html#parametrizing-conditional-raising
+from contextlib import ExitStack as does_not_raise
+
+
+@pytest.yield_fixture
+def generic_networking_cls():
+ """Returns a direct Networking subclass which errors on /sys usage.
+
+ This enables the direct testing of functionality only present on the
+ ``Networking`` super-class, and provides a check on accidentally using /sys
+ in that context.
+ """
+
+ class TestNetworking(Networking):
+ def is_physical(self, *args, **kwargs):
+ raise NotImplementedError
+
+ def settle(self, *args, **kwargs):
+ raise NotImplementedError
+
+ error = AssertionError("Unexpectedly used /sys in generic networking code")
+ with mock.patch(
+ "cloudinit.net.get_sys_class_path", side_effect=error,
+ ):
+ yield TestNetworking
+
+
+@pytest.yield_fixture
+def sys_class_net(tmpdir):
+ sys_class_net_path = tmpdir.join("sys/class/net")
+ sys_class_net_path.ensure_dir()
+ with mock.patch(
+ "cloudinit.net.get_sys_class_path",
+ return_value=sys_class_net_path.strpath + "/",
+ ):
+ yield sys_class_net_path
+
+
+class TestBSDNetworkingIsPhysical:
+ def test_raises_notimplementederror(self):
+ with pytest.raises(NotImplementedError):
+ BSDNetworking().is_physical("eth0")
+
+
+class TestLinuxNetworkingIsPhysical:
+ def test_returns_false_by_default(self, sys_class_net):
+ assert not LinuxNetworking().is_physical("eth0")
+
+ def test_returns_false_if_devname_exists_but_not_physical(
+ self, sys_class_net
+ ):
+ devname = "eth0"
+ sys_class_net.join(devname).mkdir()
+ assert not LinuxNetworking().is_physical(devname)
+
+ def test_returns_true_if_device_is_physical(self, sys_class_net):
+ devname = "eth0"
+ device_dir = sys_class_net.join(devname)
+ device_dir.mkdir()
+ device_dir.join("device").write("")
+
+ assert LinuxNetworking().is_physical(devname)
+
+
+class TestBSDNetworkingSettle:
+ def test_settle_doesnt_error(self):
+ # This also implicitly tests that it doesn't use subp.subp
+ BSDNetworking().settle()
+
+
+@pytest.mark.usefixtures("sys_class_net")
+@mock.patch("cloudinit.distros.networking.util.udevadm_settle", autospec=True)
+class TestLinuxNetworkingSettle:
+ def test_no_arguments(self, m_udevadm_settle):
+ LinuxNetworking().settle()
+
+ assert [mock.call(exists=None)] == m_udevadm_settle.call_args_list
+
+ def test_exists_argument(self, m_udevadm_settle):
+ LinuxNetworking().settle(exists="ens3")
+
+ expected_path = net.sys_dev_path("ens3")
+ assert [
+ mock.call(exists=expected_path)
+ ] == m_udevadm_settle.call_args_list
+
+
+class TestNetworkingWaitForPhysDevs:
+ @pytest.fixture
+ def wait_for_physdevs_netcfg(self):
+ """This config is shared across all the tests in this class."""
+
+ def ethernet(mac, name, driver=None, device_id=None):
+ v2_cfg = {"set-name": name, "match": {"macaddress": mac}}
+ if driver:
+ v2_cfg["match"].update({"driver": driver})
+ if device_id:
+ v2_cfg["match"].update({"device_id": device_id})
+
+ return v2_cfg
+
+ physdevs = [
+ ["aa:bb:cc:dd:ee:ff", "eth0", "virtio", "0x1000"],
+ ["00:11:22:33:44:55", "ens3", "e1000", "0x1643"],
+ ]
+ netcfg = {
+ "version": 2,
+ "ethernets": {args[1]: ethernet(*args) for args in physdevs},
+ }
+ return netcfg
+
+ def test_skips_settle_if_all_present(
+ self, generic_networking_cls, wait_for_physdevs_netcfg,
+ ):
+ networking = generic_networking_cls()
+ with mock.patch.object(
+ networking, "get_interfaces_by_mac"
+ ) as m_get_interfaces_by_mac:
+ m_get_interfaces_by_mac.side_effect = iter(
+ [{"aa:bb:cc:dd:ee:ff": "eth0", "00:11:22:33:44:55": "ens3"}]
+ )
+ with mock.patch.object(
+ networking, "settle", autospec=True
+ ) as m_settle:
+ networking.wait_for_physdevs(wait_for_physdevs_netcfg)
+ assert 0 == m_settle.call_count
+
+ def test_calls_udev_settle_on_missing(
+ self, generic_networking_cls, wait_for_physdevs_netcfg,
+ ):
+ networking = generic_networking_cls()
+ with mock.patch.object(
+ networking, "get_interfaces_by_mac"
+ ) as m_get_interfaces_by_mac:
+ m_get_interfaces_by_mac.side_effect = iter(
+ [
+ {
+ "aa:bb:cc:dd:ee:ff": "eth0"
+ }, # first call ens3 is missing
+ {
+ "aa:bb:cc:dd:ee:ff": "eth0",
+ "00:11:22:33:44:55": "ens3",
+ }, # second call has both
+ ]
+ )
+ with mock.patch.object(
+ networking, "settle", autospec=True
+ ) as m_settle:
+ networking.wait_for_physdevs(wait_for_physdevs_netcfg)
+ m_settle.assert_called_with(exists="ens3")
+
+ @pytest.mark.parametrize(
+ "strict,expectation",
+ [(True, pytest.raises(RuntimeError)), (False, does_not_raise())],
+ )
+ def test_retrying_and_strict_behaviour(
+ self,
+ strict,
+ expectation,
+ generic_networking_cls,
+ wait_for_physdevs_netcfg,
+ ):
+ networking = generic_networking_cls()
+ with mock.patch.object(
+ networking, "get_interfaces_by_mac"
+ ) as m_get_interfaces_by_mac:
+ m_get_interfaces_by_mac.return_value = {}
+
+ with mock.patch.object(
+ networking, "settle", autospec=True
+ ) as m_settle:
+ with expectation:
+ networking.wait_for_physdevs(
+ wait_for_physdevs_netcfg, strict=strict
+ )
+
+ assert (
+ 5 * len(wait_for_physdevs_netcfg["ethernets"])
+ == m_settle.call_count
+ )
diff --git a/cloudinit/distros/ubuntu.py b/cloudinit/distros/ubuntu.py
index 23be3bdd..b4c4b0c3 100644
--- a/cloudinit/distros/ubuntu.py
+++ b/cloudinit/distros/ubuntu.py
@@ -49,7 +49,5 @@ class Distro(debian.Distro):
copy.deepcopy(PREFERRED_NTP_CLIENTS))
return self._preferred_ntp_clients
- pass
-
# vi: ts=4 expandtab