From 94838def772349387e16cc642b3642020e22deda Mon Sep 17 00:00:00 2001 From: Gonéri Le Bouder Date: Thu, 12 Mar 2020 14:37:08 -0400 Subject: Add Netbsd support (#62) Add support for the NetBSD Operating System. Features in this branch: * Add BSD distro parent class from which NetBSD and FreeBSD can specialize * Add *bsd util functions to cloudinit.net and cloudinit.net.bsd_utils * subclass cloudinit.distro.freebsd.Distro from bsd.Distro * Add new cloudinit.distro.netbsd and cloudinit.net.renderer for netbsd * Add lru_cached util.is_NetBSD functions * Add NetBSD detection for ConfigDrive and NoCloud datasources This branch has been tested with: - NoCloud and OpenStack (with and without config-drive) - NetBSD 8.1. and 9.0 - FreeBSD 11.2 and 12.1 - Python 3.7 only, because of the dependency oncrypt.METHOD_BLOWFISH. This version is available in NetBSD 7, 8 and 9 anyway --- config/cloud.cfg.tmpl | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) (limited to 'config') diff --git a/config/cloud.cfg.tmpl b/config/cloud.cfg.tmpl index 99f96ea1..50cfbb2f 100644 --- a/config/cloud.cfg.tmpl +++ b/config/cloud.cfg.tmpl @@ -2,7 +2,7 @@ # The top level settings are used as module # and system configuration. -{% if variant in ["freebsd"] %} +{% if variant in ["freebsd", "netbsd"] %} syslog_fix_perms: root:wheel {% elif variant in ["suse"] %} syslog_fix_perms: root:root @@ -33,7 +33,7 @@ ssh_pwauth: 0 # This will cause the set+update hostname module to not operate (if true) preserve_hostname: false -{% if variant in ["freebsd"] %} +{% if variant in ["freebsd", "netbsd"] %} # This should not be required, but leave it in place until the real cause of # not finding -any- datasources is resolved. datasource_list: ['NoCloud', 'ConfigDrive', 'Azure', 'OpenStack', 'Ec2'] @@ -55,18 +55,22 @@ network: # The modules that run in the 'init' stage cloud_init_modules: - migrator +{% if variant not in ["netbsd"] %} - seed_random +{% endif %} - bootcmd - write-files +{% if variant not in ["netbsd"] %} - growpart - resizefs -{% if variant not in ["freebsd"] %} +{% endif %} +{% if variant not in ["freebsd", "netbsd"] %} - disk_setup - mounts {% endif %} - set_hostname - update_hostname -{% if variant not in ["freebsd"] %} +{% if variant not in ["freebsd", "netbsd"] %} - update_etc_hosts - ca-certs - rsyslog @@ -100,7 +104,7 @@ cloud_config_modules: {% if variant in ["suse"] %} - zypper-add-repo {% endif %} -{% if variant not in ["freebsd"] %} +{% if variant not in ["freebsd", "netbsd"] %} - ntp {% endif %} - timezone @@ -121,7 +125,7 @@ cloud_final_modules: {% if variant in ["ubuntu", "unknown"] %} - ubuntu-drivers {% endif %} -{% if variant not in ["freebsd"] %} +{% if variant not in ["freebsd", "netbsd"] %} - puppet - chef - mcollective @@ -143,7 +147,7 @@ cloud_final_modules: # (not accessible to handlers/transforms) system_info: # This will affect which distro class gets used -{% if variant in ["amazon", "arch", "centos", "debian", "fedora", "freebsd", "rhel", "suse", "ubuntu"] %} +{% if variant in ["amazon", "arch", "centos", "debian", "fedora", "freebsd", "netbsd", "rhel", "suse", "ubuntu"] %} distro: {{ variant }} {% else %} # Unknown/fallback distro. @@ -226,4 +230,16 @@ system_info: groups: [wheel] sudo: ["ALL=(ALL) NOPASSWD:ALL"] shell: /bin/tcsh +{% elif variant in ["netbsd"] %} + default_user: + name: netbsd + lock_passwd: True + gecos: NetBSD + groups: [wheel] + sudo: ["ALL=(ALL) NOPASSWD:ALL"] + shell: /bin/sh +{% endif %} +{% if variant in ["freebsd", "netbsd"] %} + network: + renderers: ['{{ variant }}'] {% endif %} -- cgit v1.2.3 From 44039629e539ed48298703028ac8f10ad3c60d6e Mon Sep 17 00:00:00 2001 From: Gonéri Le Bouder Date: Thu, 26 Mar 2020 16:07:51 -0400 Subject: add Openbsd support (#147) - tested on OpenBSD 6.6 - tested on OpenStack without config drive, and NoCloud with ISO config drive --- cloudinit/distros/bsd.py | 6 ++++ cloudinit/distros/netbsd.py | 13 +++++++-- cloudinit/distros/openbsd.py | 47 ++++++++++++++++++++++++++++++ cloudinit/net/__init__.py | 23 +++++++++++++-- cloudinit/net/openbsd.py | 44 ++++++++++++++++++++++++++++ cloudinit/net/renderers.py | 5 +++- cloudinit/sources/DataSourceConfigDrive.py | 2 +- cloudinit/util.py | 31 +++++++++++++++++++- config/cloud.cfg.tmpl | 16 +++++++--- doc/rtd/topics/network-config.rst | 2 +- setup.py | 5 ++-- tests/unittests/test_util.py | 19 ++++++++++++ tools/build-on-openbsd | 27 +++++++++++++++++ tools/render-cloudcfg | 5 ++-- 14 files changed, 228 insertions(+), 17 deletions(-) create mode 100644 cloudinit/distros/openbsd.py create mode 100644 cloudinit/net/openbsd.py create mode 100755 tools/build-on-openbsd (limited to 'config') diff --git a/cloudinit/distros/bsd.py b/cloudinit/distros/bsd.py index c58f897c..efc73d5d 100644 --- a/cloudinit/distros/bsd.py +++ b/cloudinit/distros/bsd.py @@ -114,3 +114,9 @@ class BSD(distros.Distro): 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/netbsd.py b/cloudinit/distros/netbsd.py index 96794d76..69d07846 100644 --- a/cloudinit/distros/netbsd.py +++ b/cloudinit/distros/netbsd.py @@ -13,7 +13,13 @@ from cloudinit import util LOG = logging.getLogger(__name__) -class Distro(cloudinit.distros.bsd.BSD): +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"] @@ -115,7 +121,7 @@ class Distro(cloudinit.distros.bsd.BSD): LOG.debug('NetBSD cannot rename network interface.') def _get_pkg_cmd_environ(self): - """Return environment vars used in *BSD package_command operations""" + """Return env vars used in NetBSD package_command operations""" os_release = platform.release() os_arch = platform.machine() e = os.environ.copy() @@ -128,4 +134,7 @@ class Distro(cloudinit.distros.bsd.BSD): pass +class Distro(NetBSD): + pass + # vi: ts=4 expandtab diff --git a/cloudinit/distros/openbsd.py b/cloudinit/distros/openbsd.py new file mode 100644 index 00000000..dbe1f069 --- /dev/null +++ b/cloudinit/distros/openbsd.py @@ -0,0 +1,47 @@ +# 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 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: + util.subp(['usermod', '-p', '*', name]) + except Exception: + util.logexc(LOG, "Failed to lock user %s", name) + raise + + 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/net/__init__.py b/cloudinit/net/__init__.py index 400d7870..67e3d578 100644 --- a/cloudinit/net/__init__.py +++ b/cloudinit/net/__init__.py @@ -334,13 +334,13 @@ def find_fallback_nic(blacklist_drivers=None): """Return the name of the 'fallback' network device.""" if util.is_FreeBSD(): return find_fallback_nic_on_freebsd(blacklist_drivers) - elif util.is_NetBSD(): - return find_fallback_nic_on_netbsd(blacklist_drivers) + elif util.is_NetBSD() or util.is_OpenBSD(): + return find_fallback_nic_on_netbsd_or_openbsd(blacklist_drivers) else: return find_fallback_nic_on_linux(blacklist_drivers) -def find_fallback_nic_on_netbsd(blacklist_drivers=None): +def find_fallback_nic_on_netbsd_or_openbsd(blacklist_drivers=None): values = list(sorted( get_interfaces_by_mac().values(), key=natural_sort_key)) @@ -811,6 +811,8 @@ def get_interfaces_by_mac(): return get_interfaces_by_mac_on_freebsd() elif util.is_NetBSD(): return get_interfaces_by_mac_on_netbsd() + elif util.is_OpenBSD(): + return get_interfaces_by_mac_on_openbsd() else: return get_interfaces_by_mac_on_linux() @@ -857,6 +859,21 @@ def get_interfaces_by_mac_on_netbsd(): return ret +def get_interfaces_by_mac_on_openbsd(): + ret = {} + re_field_match = ( + r"(?P\w+).*lladdr\s" + r"(?P([\da-f]{2}[:-]){5}([\da-f]{2})).*") + (out, _) = util.subp(['ifconfig', '-a']) + if_lines = re.sub(r'\n\s+', ' ', out).splitlines() + for line in if_lines: + m = re.match(re_field_match, line) + if m: + fields = m.groupdict() + ret[fields['mac']] = fields['ifname'] + return ret + + def get_interfaces_by_mac_on_linux(): """Build a dictionary of tuples {mac: name}. diff --git a/cloudinit/net/openbsd.py b/cloudinit/net/openbsd.py new file mode 100644 index 00000000..b9897e90 --- /dev/null +++ b/cloudinit/net/openbsd.py @@ -0,0 +1,44 @@ +# This file is part of cloud-init. See LICENSE file for license information. + +from cloudinit import log as logging +from cloudinit import util +import cloudinit.net.bsd + +LOG = logging.getLogger(__name__) + + +class Renderer(cloudinit.net.bsd.BSDRenderer): + + def write_config(self): + for device_name, v in self.interface_configurations.items(): + if_file = 'etc/hostname.{}'.format(device_name) + fn = util.target_path(self.target, if_file) + if device_name in self.dhcp_interfaces(): + content = 'dhcp\n' + elif isinstance(v, dict): + try: + content = "inet {address} {netmask}\n".format( + address=v['address'], + netmask=v['netmask']) + except KeyError: + LOG.error( + "Invalid static configuration for %s", + device_name) + util.write_file(fn, content) + + def start_services(self, run=False): + if not self._postcmds: + LOG.debug("openbsd generate postcmd disabled") + return + util.subp(['sh', '/etc/netstart'], capture=True) + + def set_route(self, network, netmask, gateway): + if network == '0.0.0.0': + if_file = 'etc/mygate' + fn = util.target_path(self.target, if_file) + content = gateway + '\n' + util.write_file(fn, content) + + +def available(target=None): + return util.is_OpenBSD() diff --git a/cloudinit/net/renderers.py b/cloudinit/net/renderers.py index e4bcae9d..e2de4d55 100644 --- a/cloudinit/net/renderers.py +++ b/cloudinit/net/renderers.py @@ -5,6 +5,7 @@ from . import freebsd from . import netbsd from . import netplan from . import RendererNotFoundError +from . import openbsd from . import sysconfig NAME_TO_RENDERER = { @@ -12,10 +13,12 @@ NAME_TO_RENDERER = { "freebsd": freebsd, "netbsd": netbsd, "netplan": netplan, + "openbsd": openbsd, "sysconfig": sysconfig, } -DEFAULT_PRIORITY = ["eni", "sysconfig", "netplan", "freebsd", "netbsd"] +DEFAULT_PRIORITY = ["eni", "sysconfig", "netplan", "freebsd", + "netbsd", "openbsd"] def search(priority=None, target=None, first=False): diff --git a/cloudinit/sources/DataSourceConfigDrive.py b/cloudinit/sources/DataSourceConfigDrive.py index dee8cde4..ae31934b 100644 --- a/cloudinit/sources/DataSourceConfigDrive.py +++ b/cloudinit/sources/DataSourceConfigDrive.py @@ -72,7 +72,7 @@ class DataSourceConfigDrive(openstack.SourceMixin, sources.DataSource): dslist = self.sys_cfg.get('datasource_list') for dev in find_candidate_devs(dslist=dslist): mtype = None - if (util.is_FreeBSD() or util.is_NetBSD()): + if util.is_BSD(): if dev.startswith("/dev/cd"): mtype = "cd9660" try: diff --git a/cloudinit/util.py b/cloudinit/util.py index db60b9d2..541a486b 100644 --- a/cloudinit/util.py +++ b/cloudinit/util.py @@ -557,6 +557,11 @@ def is_NetBSD(): return system_info()['variant'] == "netbsd" +@lru_cache() +def is_OpenBSD(): + return system_info()['variant'] == "openbsd" + + def get_cfg_option_bool(yobj, key, default=False): if key not in yobj: return default @@ -679,7 +684,7 @@ def system_info(): var = 'suse' else: var = 'linux' - elif system in ('windows', 'darwin', "freebsd", "netbsd"): + elif system in ('windows', 'darwin', "freebsd", "netbsd", "openbsd"): var = system info['variant'] = var @@ -1279,6 +1284,27 @@ def find_devs_with_netbsd(criteria=None, oformat='device', return [] +def find_devs_with_openbsd(criteria=None, oformat='device', + tag=None, no_cache=False, path=None): + out, _err = subp(['sysctl', '-n', 'hw.disknames'], rcs=[0]) + devlist = [] + for entry in out.split(','): + if not entry.endswith(':'): + # ffs partition with a serial, not a config-drive + continue + if entry == 'fd0:': + continue + part_id = 'a' if entry.startswith('cd') else 'i' + devlist.append(entry[:-1] + part_id) + if criteria == "TYPE=iso9660": + devlist = [i for i in devlist if i.startswith('cd')] + elif criteria in ["LABEL=CONFIG-2", "TYPE=vfat"]: + devlist = [i for i in devlist if not i.startswith('cd')] + elif criteria: + LOG.debug("Unexpected criteria: %s", criteria) + return ['/dev/' + i for i in devlist] + + def find_devs_with(criteria=None, oformat='device', tag=None, no_cache=False, path=None): """ @@ -1291,6 +1317,9 @@ def find_devs_with(criteria=None, oformat='device', if is_NetBSD(): return find_devs_with_netbsd(criteria, oformat, tag, no_cache, path) + elif is_OpenBSD(): + return find_devs_with_openbsd(criteria, oformat, + tag, no_cache, path) blk_id_cmd = ['blkid'] options = [] diff --git a/config/cloud.cfg.tmpl b/config/cloud.cfg.tmpl index 50cfbb2f..2b052c0f 100644 --- a/config/cloud.cfg.tmpl +++ b/config/cloud.cfg.tmpl @@ -2,7 +2,7 @@ # The top level settings are used as module # and system configuration. -{% if variant in ["freebsd", "netbsd"] %} +{% if variant.endswith("bsd") %} syslog_fix_perms: root:wheel {% elif variant in ["suse"] %} syslog_fix_perms: root:root @@ -33,7 +33,7 @@ ssh_pwauth: 0 # This will cause the set+update hostname module to not operate (if true) preserve_hostname: false -{% if variant in ["freebsd", "netbsd"] %} +{% if variant.endswith("bsd") %} # This should not be required, but leave it in place until the real cause of # not finding -any- datasources is resolved. datasource_list: ['NoCloud', 'ConfigDrive', 'Azure', 'OpenStack', 'Ec2'] @@ -147,7 +147,7 @@ cloud_final_modules: # (not accessible to handlers/transforms) system_info: # This will affect which distro class gets used -{% if variant in ["amazon", "arch", "centos", "debian", "fedora", "freebsd", "netbsd", "rhel", "suse", "ubuntu"] %} +{% if variant in ["amazon", "arch", "centos", "debian", "fedora", "freebsd", "netbsd", "openbsd", "rhel", "suse", "ubuntu"] %} distro: {{ variant }} {% else %} # Unknown/fallback distro. @@ -238,8 +238,16 @@ system_info: groups: [wheel] sudo: ["ALL=(ALL) NOPASSWD:ALL"] shell: /bin/sh +{% elif variant in ["openbsd"] %} + default_user: + name: openbsd + lock_passwd: True + gecos: OpenBSD + groups: [wheel] + sudo: ["ALL=(ALL) NOPASSWD:ALL"] + shell: /bin/ksh {% endif %} -{% if variant in ["freebsd", "netbsd"] %} +{% if variant in ["freebsd", "netbsd", "openbsd"] %} network: renderers: ['{{ variant }}'] {% endif %} diff --git a/doc/rtd/topics/network-config.rst b/doc/rtd/topics/network-config.rst index ba00a889..8eeadebf 100644 --- a/doc/rtd/topics/network-config.rst +++ b/doc/rtd/topics/network-config.rst @@ -197,7 +197,7 @@ supplying an updated configuration in cloud-config. :: system_info: network: - renderers: ['netplan', 'eni', 'sysconfig', 'freebsd', 'netbsd'] + renderers: ['netplan', 'eni', 'sysconfig', 'freebsd', 'netbsd', 'openbsd'] Network Configuration Tools diff --git a/setup.py b/setup.py index 69bb1a51..22b32f6f 100755 --- a/setup.py +++ b/setup.py @@ -15,6 +15,7 @@ import os import shutil import sys import tempfile +import platform import setuptools from setuptools.command.install import install @@ -230,7 +231,7 @@ class InitsysInstallData(install): if self.init_system and isinstance(self.init_system, str): self.init_system = self.init_system.split(",") - if len(self.init_system) == 0: + if len(self.init_system) == 0 and not platform.system().endswith('BSD'): self.init_system = ['systemd'] bad = [f for f in self.init_system if f not in INITSYS_TYPES] @@ -274,7 +275,7 @@ data_files = [ (USR + '/share/doc/cloud-init/examples/seed', [f for f in glob('doc/examples/seed/*') if is_f(f)]), ] -if os.uname()[0] not in ['FreeBSD', 'NetBSD']: +if not platform.system().endswith('BSD'): data_files.extend([ (ETC + '/NetworkManager/dispatcher.d/', ['tools/hook-network-manager']), diff --git a/tests/unittests/test_util.py b/tests/unittests/test_util.py index 5b2eaa69..2900997b 100644 --- a/tests/unittests/test_util.py +++ b/tests/unittests/test_util.py @@ -1171,4 +1171,23 @@ class TestGetProcEnv(helpers.TestCase): my_ppid = os.getppid() self.assertEqual(my_ppid, util.get_proc_ppid(my_pid)) + +@mock.patch('cloudinit.util.subp') +def test_find_devs_with_openbsd(m_subp): + m_subp.return_value = ( + 'cd0:,sd0:630d98d32b5d3759,sd1:,fd0:', '' + ) + devlist = util.find_devs_with_openbsd() + assert devlist == ['/dev/cd0a', '/dev/sd1i'] + + +@mock.patch('cloudinit.util.subp') +def test_find_devs_with_openbsd_with_criteria(m_subp): + m_subp.return_value = ( + 'cd0:,sd0:630d98d32b5d3759,sd1:,fd0:', '' + ) + devlist = util.find_devs_with_openbsd(criteria="TYPE=iso9660") + assert devlist == ['/dev/cd0a'] + + # vi: ts=4 expandtab diff --git a/tools/build-on-openbsd b/tools/build-on-openbsd new file mode 100755 index 00000000..ca028606 --- /dev/null +++ b/tools/build-on-openbsd @@ -0,0 +1,27 @@ +#!/bin/sh + +fail() { echo "FAILED:" "$@" 1>&2; exit 1; } + +# Check dependencies: +depschecked=/tmp/c-i.dependencieschecked +pkgs=" + bash + dmidecode + py3-configobj + py3-jinja2 + py3-jsonschema + py3-oauthlib + py3-requests + py3-setuptools + py3-six + py3-yaml + sudo-- +" +[ -f "$depschecked" ] || pkg_add ${pkgs} || fail "install packages" + +touch $depschecked + +python3 setup.py build +python3 setup.py install -O1 --distro openbsd --skip-build + +echo "Installation completed." diff --git a/tools/render-cloudcfg b/tools/render-cloudcfg index 50f85369..9322b2c3 100755 --- a/tools/render-cloudcfg +++ b/tools/render-cloudcfg @@ -4,8 +4,9 @@ import argparse import os import sys -VARIANTS = ["amazon", "arch", "centos", "debian", "fedora", "freebsd", "netbsd", - "rhel", "suse", "ubuntu", "unknown"] +VARIANTS = ["amazon", "arch", "centos", "debian", "fedora", "freebsd", + "netbsd", "openbsd", "rhel", "suse", "ubuntu", "unknown"] + if "avoid-pep8-E402-import-not-top-of-file": _tdir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) -- cgit v1.2.3 From 4fb6fd8a046a6bcce01216c386f3b691a2c466bb Mon Sep 17 00:00:00 2001 From: Chad Smith Date: Mon, 30 Mar 2020 21:24:51 -0600 Subject: net: ubuntu focal prioritize netplan over eni even if both present (#267) On Focal and later, Ubuntu will prioritize netplan renderer over eni, even if ifupdown and netplan are both installed. ENI on Focal and later is considered an unsupported configuration so cloud-init should generally prefer netplan. On many cloud images, the /etc/network/interfaces config file does not include the dir /etc/network/interfaces.d thereby ignoring cloud-init's /etc/network/interfaces.d/50-cloud-init.cfg file. LP: #1867029 --- config/cloud.cfg.tmpl | 3 ++ tests/unittests/test_net.py | 86 ++++++++++++++++++--------------- tests/unittests/test_render_cloudcfg.py | 57 ++++++++++++++++++++++ 3 files changed, 106 insertions(+), 40 deletions(-) create mode 100644 tests/unittests/test_render_cloudcfg.py (limited to 'config') diff --git a/config/cloud.cfg.tmpl b/config/cloud.cfg.tmpl index 2b052c0f..e6f7a9a1 100644 --- a/config/cloud.cfg.tmpl +++ b/config/cloud.cfg.tmpl @@ -162,6 +162,9 @@ system_info: groups: [adm, audio, cdrom, dialout, dip, floppy, lxd, netdev, plugdev, sudo, video] sudo: ["ALL=(ALL) NOPASSWD:ALL"] shell: /bin/bash +{# SRU_BLOCKER: do not ship network renderers on Xenial, Bionic or Eoan #} + network: + renderers: ['netplan', 'eni', 'sysconfig'] # Automatically discover the best ntp_client ntp_client: auto # Other config here will be given to the distro class and/or path classes diff --git a/tests/unittests/test_net.py b/tests/unittests/test_net.py index e03857c4..e075a64c 100644 --- a/tests/unittests/test_net.py +++ b/tests/unittests/test_net.py @@ -24,6 +24,7 @@ import re import textwrap from yaml.serializer import Serializer +import pytest DHCP_CONTENT_1 = """ DEVICE='eth0' @@ -4671,6 +4672,51 @@ class TestEniRoundTrip(CiTestCase): files['/etc/network/interfaces'].splitlines()) +class TestRenderersSelect: + + @pytest.mark.parametrize( + 'renderer_selected,netplan,eni,nm,scfg,sys', ( + # -netplan -ifupdown -nm -scfg -sys raises error + (net.RendererNotFoundError, False, False, False, False, False), + # -netplan +ifupdown -nm -scfg -sys selects eni + ('eni', False, True, False, False, False), + # +netplan +ifupdown -nm -scfg -sys selects eni + ('eni', True, True, False, False, False), + # +netplan -ifupdown -nm -scfg -sys selects netplan + ('netplan', True, False, False, False, False), + # Ubuntu with Network-Manager installed + # +netplan -ifupdown +nm -scfg -sys selects netplan + ('netplan', True, False, True, False, False), + # Centos/OpenSuse with Network-Manager installed selects sysconfig + # -netplan -ifupdown +nm -scfg +sys selects netplan + ('sysconfig', False, False, True, False, True), + ), + ) + @mock.patch("cloudinit.net.renderers.netplan.available") + @mock.patch("cloudinit.net.renderers.sysconfig.available") + @mock.patch("cloudinit.net.renderers.sysconfig.available_sysconfig") + @mock.patch("cloudinit.net.renderers.sysconfig.available_nm") + @mock.patch("cloudinit.net.renderers.eni.available") + def test_valid_renderer_from_defaults_depending_on_availability( + self, m_eni_avail, m_nm_avail, m_scfg_avail, m_sys_avail, + m_netplan_avail, renderer_selected, netplan, eni, nm, scfg, sys + ): + """Assert proper renderer per DEFAULT_PRIORITY given availability.""" + m_eni_avail.return_value = eni # ifupdown pkg presence + m_nm_avail.return_value = nm # network-manager presence + m_scfg_avail.return_value = scfg # sysconfig presence + m_sys_avail.return_value = sys # sysconfig/ifup/down presence + m_netplan_avail.return_value = netplan # netplan presence + if isinstance(renderer_selected, str): + (renderer_name, _rnd_class) = renderers.select( + priority=renderers.DEFAULT_PRIORITY + ) + assert renderer_selected == renderer_name + else: + with pytest.raises(renderer_selected): + renderers.select(priority=renderers.DEFAULT_PRIORITY) + + class TestNetRenderers(CiTestCase): @mock.patch("cloudinit.net.renderers.sysconfig.available") @mock.patch("cloudinit.net.renderers.eni.available") @@ -4714,46 +4760,6 @@ class TestNetRenderers(CiTestCase): self.assertRaises(net.RendererNotFoundError, renderers.select, priority=['sysconfig', 'eni']) - @mock.patch("cloudinit.net.renderers.netplan.available") - @mock.patch("cloudinit.net.renderers.sysconfig.available") - @mock.patch("cloudinit.net.renderers.sysconfig.available_sysconfig") - @mock.patch("cloudinit.net.renderers.sysconfig.available_nm") - @mock.patch("cloudinit.net.renderers.eni.available") - @mock.patch("cloudinit.net.renderers.sysconfig.util.get_linux_distro") - def test_sysconfig_selected_on_sysconfig_enabled_distros(self, m_distro, - m_eni, m_sys_nm, - m_sys_scfg, - m_sys_avail, - m_netplan): - """sysconfig only selected on specific distros (rhel/sles).""" - - # Ubuntu with Network-Manager installed - m_eni.return_value = False # no ifupdown (ifquery) - m_sys_scfg.return_value = False # no sysconfig/ifup/ifdown - m_sys_nm.return_value = True # network-manager is installed - m_netplan.return_value = True # netplan is installed - m_sys_avail.return_value = False # no sysconfig on Ubuntu - m_distro.return_value = ('ubuntu', None, None) - self.assertEqual('netplan', renderers.select(priority=None)[0]) - - # Centos with Network-Manager installed - m_eni.return_value = False # no ifupdown (ifquery) - m_sys_scfg.return_value = False # no sysconfig/ifup/ifdown - m_sys_nm.return_value = True # network-manager is installed - m_netplan.return_value = False # netplan is not installed - m_sys_avail.return_value = True # sysconfig is available on centos - m_distro.return_value = ('centos', None, None) - self.assertEqual('sysconfig', renderers.select(priority=None)[0]) - - # OpenSuse with Network-Manager installed - m_eni.return_value = False # no ifupdown (ifquery) - m_sys_scfg.return_value = False # no sysconfig/ifup/ifdown - m_sys_nm.return_value = True # network-manager is installed - m_netplan.return_value = False # netplan is not installed - m_sys_avail.return_value = True # sysconfig is available on opensuse - m_distro.return_value = ('opensuse', None, None) - self.assertEqual('sysconfig', renderers.select(priority=None)[0]) - @mock.patch("cloudinit.net.sysconfig.available_sysconfig") @mock.patch("cloudinit.util.get_linux_distro") def test_sysconfig_available_uses_variant_mapping(self, m_distro, m_avail): diff --git a/tests/unittests/test_render_cloudcfg.py b/tests/unittests/test_render_cloudcfg.py new file mode 100644 index 00000000..8b1e6042 --- /dev/null +++ b/tests/unittests/test_render_cloudcfg.py @@ -0,0 +1,57 @@ +"""Tests for tools/render-cloudcfg""" + +import os +import sys + +import pytest + +from cloudinit import util + +# TODO(Look to align with tools.render-cloudcfg or cloudinit.distos.OSFAMILIES) +DISTRO_VARIANTS = ["amazon", "arch", "centos", "debian", "fedora", "freebsd", + "netbsd", "openbsd", "rhel", "suse", "ubuntu", "unknown"] + + +class TestRenderCloudCfg: + + cmd = [sys.executable, os.path.realpath('tools/render-cloudcfg')] + tmpl_path = os.path.realpath('config/cloud.cfg.tmpl') + + @pytest.mark.parametrize('variant', (DISTRO_VARIANTS)) + def test_variant_sets_distro_in_cloud_cfg(self, variant, tmpdir): + outfile = tmpdir.join('outcfg').strpath + util.subp( + self.cmd + ['--variant', variant, self.tmpl_path, outfile]) + with open(outfile) as stream: + system_cfg = util.load_yaml(stream.read()) + if variant == 'unknown': + variant = 'ubuntu' # Unknown is defaulted to ubuntu + assert system_cfg['system_info']['distro'] == variant + + @pytest.mark.parametrize('variant', (DISTRO_VARIANTS)) + def test_variant_sets_default_user_in_cloud_cfg(self, variant, tmpdir): + outfile = tmpdir.join('outcfg').strpath + util.subp( + self.cmd + ['--variant', variant, self.tmpl_path, outfile]) + with open(outfile) as stream: + system_cfg = util.load_yaml(stream.read()) + + default_user_exceptions = { + 'amazon': 'ec2-user', 'debian': 'ubuntu', 'unknown': 'ubuntu'} + default_user = system_cfg['system_info']['default_user']['name'] + assert default_user == default_user_exceptions.get(variant, variant) + + @pytest.mark.parametrize('variant,renderers', ( + ('freebsd', ['freebsd']), ('netbsd', ['netbsd']), + ('openbsd', ['openbsd']), ('ubuntu', ['netplan', 'eni', 'sysconfig'])) + ) + def test_variant_sets_network_renderer_priority_in_cloud_cfg( + self, variant, renderers, tmpdir + ): + outfile = tmpdir.join('outcfg').strpath + util.subp( + self.cmd + ['--variant', variant, self.tmpl_path, outfile]) + with open(outfile) as stream: + system_cfg = util.load_yaml(stream.read()) + + assert renderers == system_cfg['system_info']['network']['renderers'] -- cgit v1.2.3 From 8377897bdd1a88d4dc3b6456618231085c55af42 Mon Sep 17 00:00:00 2001 From: "Mina Galić (deprecated: Igor Galić)" Date: Wed, 27 May 2020 21:11:58 +0200 Subject: enable Puppet, Chef mcollective in default config (#385) These config management things work on BSD, they also claim to work on all distros, so enabling them! LP: #1880279 --- config/cloud.cfg.tmpl | 2 -- 1 file changed, 2 deletions(-) (limited to 'config') diff --git a/config/cloud.cfg.tmpl b/config/cloud.cfg.tmpl index e6f7a9a1..1bb97f83 100644 --- a/config/cloud.cfg.tmpl +++ b/config/cloud.cfg.tmpl @@ -125,11 +125,9 @@ cloud_final_modules: {% if variant in ["ubuntu", "unknown"] %} - ubuntu-drivers {% endif %} -{% if variant not in ["freebsd", "netbsd"] %} - puppet - chef - mcollective -{% endif %} - salt-minion - rightscale_userdata - scripts-vendor -- cgit v1.2.3 From 287bfca19d8ed386cd41dbc47753b7711ac1c848 Mon Sep 17 00:00:00 2001 From: James Falcon Date: Fri, 12 Jun 2020 14:48:53 -0500 Subject: Default to UTF-8 in /var/log/cloud-init.log (#427) On a system with a non-utf8 default locale, the logger will silently not log anything if the message contains an unsupported character. --- config/cloud.cfg.d/05_logging.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'config') diff --git a/config/cloud.cfg.d/05_logging.cfg b/config/cloud.cfg.d/05_logging.cfg index 937b07f8..bf917a95 100644 --- a/config/cloud.cfg.d/05_logging.cfg +++ b/config/cloud.cfg.d/05_logging.cfg @@ -44,7 +44,7 @@ _log: class=FileHandler level=DEBUG formatter=arg0Formatter - args=('/var/log/cloud-init.log',) + args=('/var/log/cloud-init.log', 'a', 'UTF-8') - &log_syslog | [handler_cloudLogHandler] class=handlers.SysLogHandler -- cgit v1.2.3 From d373a8e1ae602c98bf89dc962d0d2a27815fb183 Mon Sep 17 00:00:00 2001 From: Adam Dobrawy Date: Tue, 7 Jul 2020 15:20:11 +0200 Subject: Add update_etc_hosts as default module on *BSD (#479) * Add update_etc_hosts as default module on *BSD * Set preference of IPv6 over IPv4 in FreeBSD /etc/hosts --- config/cloud.cfg.tmpl | 2 +- templates/hosts.freebsd.tmpl | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) (limited to 'config') diff --git a/config/cloud.cfg.tmpl b/config/cloud.cfg.tmpl index 1bb97f83..b44cbce7 100644 --- a/config/cloud.cfg.tmpl +++ b/config/cloud.cfg.tmpl @@ -70,8 +70,8 @@ cloud_init_modules: {% endif %} - set_hostname - update_hostname -{% if variant not in ["freebsd", "netbsd"] %} - update_etc_hosts +{% if not variant.endswith("bsd") %} - ca-certs - rsyslog {% endif %} diff --git a/templates/hosts.freebsd.tmpl b/templates/hosts.freebsd.tmpl index 7ded762f..5cd5d3bc 100644 --- a/templates/hosts.freebsd.tmpl +++ b/templates/hosts.freebsd.tmpl @@ -11,14 +11,13 @@ you need to add the following to config: # a.) make changes to the master file in /etc/cloud/templates/hosts.freebsd.tmpl # b.) change or remove the value of 'manage_etc_hosts' in # /etc/cloud/cloud.cfg or cloud-config from user-data -# -# The following lines are desirable for IPv4 capable hosts -127.0.0.1 {{fqdn}} {{hostname}} -127.0.0.1 localhost.localdomain localhost -127.0.0.1 localhost4.localdomain4 localhost4 # The following lines are desirable for IPv6 capable hosts ::1 {{fqdn}} {{hostname}} ::1 localhost.localdomain localhost ::1 localhost6.localdomain6 localhost6 +# The following lines are desirable for IPv4 capable hosts +127.0.0.1 {{fqdn}} {{hostname}} +127.0.0.1 localhost.localdomain localhost +127.0.0.1 localhost4.localdomain4 localhost4 -- cgit v1.2.3 From 79a8ce7e714ae1686c10bff77612eab0f6eccc95 Mon Sep 17 00:00:00 2001 From: dermotbradley Date: Thu, 20 Aug 2020 00:18:25 +0100 Subject: Add Alpine Linux support. (#535) Add new module cc_apk_configure for creating Alpine /etc/apk/repositories file. Modify cc_ca_certs, cc_ntp, cc_power_state_change, and cc_resolv_conf for Alpine. Add Alpine template files for Chrony and Busybox NTP support. Add Alpine template file for /etc/hosts. --- README.md | 2 +- cloudinit/config/cc_apk_configure.py | 263 ++++++++++++++++++ cloudinit/config/cc_ca_certs.py | 22 +- cloudinit/config/cc_ntp.py | 61 ++++- cloudinit/config/cc_power_state_change.py | 57 +++- cloudinit/config/cc_resolv_conf.py | 4 +- cloudinit/distros/__init__.py | 7 +- cloudinit/distros/alpine.py | 165 ++++++++++++ cloudinit/util.py | 3 +- config/cloud.cfg.tmpl | 21 +- doc/rtd/topics/availability.rst | 13 +- doc/rtd/topics/instancedata.rst | 1 + doc/rtd/topics/modules.rst | 1 + doc/rtd/topics/network-config.rst | 2 +- templates/chrony.conf.alpine.tmpl | 38 +++ templates/hosts.alpine.tmpl | 28 ++ templates/ntp.conf.alpine.tmpl | 10 + tests/unittests/test_cli.py | 2 +- .../test_handler/test_handler_apk_configure.py | 299 +++++++++++++++++++++ .../test_handler/test_handler_ca_certs.py | 11 +- tests/unittests/test_handler/test_handler_ntp.py | 129 ++++++--- .../test_handler/test_handler_power_state.py | 29 +- tests/unittests/test_handler/test_schema.py | 1 + tools/render-cloudcfg | 5 +- 24 files changed, 1068 insertions(+), 106 deletions(-) create mode 100644 cloudinit/config/cc_apk_configure.py create mode 100644 cloudinit/distros/alpine.py create mode 100644 templates/chrony.conf.alpine.tmpl create mode 100644 templates/hosts.alpine.tmpl create mode 100644 templates/ntp.conf.alpine.tmpl create mode 100644 tests/unittests/test_handler/test_handler_apk_configure.py (limited to 'config') diff --git a/README.md b/README.md index a3455135..435405da 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ get in contact with that distribution and send them our way! | Supported OSes | Supported Public Clouds | Supported Private Clouds | | --- | --- | --- | -| Ubuntu
SLES/openSUSE
RHEL/CentOS
Fedora
Gentoo Linux
Debian
ArchLinux
FreeBSD
NetBSD
OpenBSD










| Amazon Web Services
Microsoft Azure
Google Cloud Platform
Oracle Cloud Infrastructure
Softlayer
Rackspace Public Cloud
IBM Cloud
Digital Ocean
Bigstep
Hetzner
Joyent
CloudSigma
Alibaba Cloud
OVH
OpenNebula
Exoscale
Scaleway
CloudStack
AltCloud
SmartOS
HyperOne
Rootbox
| Bare metal installs
OpenStack
LXD
KVM
Metal-as-a-Service (MAAS)















| +| Alpine Linux
ArchLinux
Debian
Fedora
FreeBSD
Gentoo Linux
NetBSD
OpenBSD
RHEL/CentOS
SLES/openSUSE
Ubuntu










| Amazon Web Services
Microsoft Azure
Google Cloud Platform
Oracle Cloud Infrastructure
Softlayer
Rackspace Public Cloud
IBM Cloud
Digital Ocean
Bigstep
Hetzner
Joyent
CloudSigma
Alibaba Cloud
OVH
OpenNebula
Exoscale
Scaleway
CloudStack
AltCloud
SmartOS
HyperOne
Rootbox
| Bare metal installs
OpenStack
LXD
KVM
Metal-as-a-Service (MAAS)















| ## To start developing cloud-init diff --git a/cloudinit/config/cc_apk_configure.py b/cloudinit/config/cc_apk_configure.py new file mode 100644 index 00000000..84d7a0b6 --- /dev/null +++ b/cloudinit/config/cc_apk_configure.py @@ -0,0 +1,263 @@ +# Copyright (c) 2020 Dermot Bradley +# +# Author: Dermot Bradley +# +# This file is part of cloud-init. See LICENSE file for license information. + +"""Apk Configure: Configures apk repositories file.""" + +from textwrap import dedent + +from cloudinit import log as logging +from cloudinit import temp_utils +from cloudinit import templater +from cloudinit import util +from cloudinit.config.schema import ( + get_schema_doc, validate_cloudconfig_schema) +from cloudinit.settings import PER_INSTANCE + +LOG = logging.getLogger(__name__) + + +# If no mirror is specified then use this one +DEFAULT_MIRROR = "https://alpine.global.ssl.fastly.net/alpine" + + +REPOSITORIES_TEMPLATE = """\ +## template:jinja +# +# Created by cloud-init +# +# This file is written on first boot of an instance +# + +{{ alpine_baseurl }}/{{ alpine_version }}/main +{% if community_enabled -%} +{{ alpine_baseurl }}/{{ alpine_version }}/community +{% endif -%} +{% if testing_enabled -%} +{% if alpine_version != 'edge' %} +# +# Testing - using with non-Edge installation may cause problems! +# +{% endif %} +{{ alpine_baseurl }}/edge/testing +{% endif %} +{% if local_repo != '' %} + +# +# Local repo +# +{{ local_repo }}/{{ alpine_version }} +{% endif %} + +""" + + +frequency = PER_INSTANCE +distros = ['alpine'] +schema = { + 'id': 'cc_apk_configure', + 'name': 'APK Configure', + 'title': 'Configure apk repositories file', + 'description': dedent("""\ + This module handles configuration of the /etc/apk/repositories file. + + .. note:: + To ensure that apk configuration is valid yaml, any strings + containing special characters, especially ``:`` should be quoted. + """), + 'distros': distros, + 'examples': [ + dedent("""\ + # Keep the existing /etc/apk/repositories file unaltered. + apk_repos: + preserve_repositories: true + """), + dedent("""\ + # Create repositories file for Alpine v3.12 main and community + # using default mirror site. + apk_repos: + alpine_repo: + community_enabled: true + version: 'v3.12' + """), + dedent("""\ + # Create repositories file for Alpine Edge main, community, and + # testing using a specified mirror site and also a local repo. + apk_repos: + alpine_repo: + base_url: 'https://some-alpine-mirror/alpine' + community_enabled: true + testing_enabled: true + version: 'edge' + local_repo_base_url: 'https://my-local-server/local-alpine' + """), + ], + 'frequency': frequency, + 'type': 'object', + 'properties': { + 'apk_repos': { + 'type': 'object', + 'properties': { + 'preserve_repositories': { + 'type': 'boolean', + 'default': False, + 'description': dedent("""\ + By default, cloud-init will generate a new repositories + file ``/etc/apk/repositories`` based on any valid + configuration settings specified within a apk_repos + section of cloud config. To disable this behavior and + preserve the repositories file from the pristine image, + set ``preserve_repositories`` to ``true``. + + The ``preserve_repositories`` option overrides + all other config keys that would alter + ``/etc/apk/repositories``. + """) + }, + 'alpine_repo': { + 'type': ['object', 'null'], + 'properties': { + 'base_url': { + 'type': 'string', + 'default': DEFAULT_MIRROR, + 'description': dedent("""\ + The base URL of an Alpine repository, or + mirror, to download official packages from. + If not specified then it defaults to ``{}`` + """.format(DEFAULT_MIRROR)) + }, + 'community_enabled': { + 'type': 'boolean', + 'default': False, + 'description': dedent("""\ + Whether to add the Community repo to the + repositories file. By default the Community + repo is not included. + """) + }, + 'testing_enabled': { + 'type': 'boolean', + 'default': False, + 'description': dedent("""\ + Whether to add the Testing repo to the + repositories file. By default the Testing + repo is not included. It is only recommended + to use the Testing repo on a machine running + the ``Edge`` version of Alpine as packages + installed from Testing may have dependancies + that conflict with those in non-Edge Main or + Community repos." + """) + }, + 'version': { + 'type': 'string', + 'description': dedent("""\ + The Alpine version to use (e.g. ``v3.12`` or + ``edge``) + """) + }, + }, + 'required': ['version'], + 'minProperties': 1, + 'additionalProperties': False, + }, + 'local_repo_base_url': { + 'type': 'string', + 'description': dedent("""\ + The base URL of an Alpine repository containing + unofficial packages + """) + } + }, + 'required': [], + 'minProperties': 1, # Either preserve_repositories or alpine_repo + 'additionalProperties': False, + } + } +} + +__doc__ = get_schema_doc(schema) + + +def handle(name, cfg, cloud, log, _args): + """ + Call to handle apk_repos sections in cloud-config file. + + @param name: The module name "apk-configure" from cloud.cfg + @param cfg: A nested dict containing the entire cloud config contents. + @param cloud: The CloudInit object in use. + @param log: Pre-initialized Python logger object to use for logging. + @param _args: Any module arguments from cloud.cfg + """ + + # If there is no "apk_repos" section in the configuration + # then do nothing. + apk_section = cfg.get('apk_repos') + if not apk_section: + LOG.debug(("Skipping module named %s," + " no 'apk_repos' section found"), name) + return + + validate_cloudconfig_schema(cfg, schema) + + # If "preserve_repositories" is explicitly set to True in + # the configuration do nothing. + if util.get_cfg_option_bool(apk_section, 'preserve_repositories', False): + LOG.debug(("Skipping module named %s," + " 'preserve_repositories' is set"), name) + return + + # If there is no "alpine_repo" subsection of "apk_repos" present in the + # configuration then do nothing, as at least "version" is required to + # create valid repositories entries. + alpine_repo = apk_section.get('alpine_repo') + if not alpine_repo: + LOG.debug(("Skipping module named %s," + " no 'alpine_repo' configuration found"), name) + return + + # If there is no "version" value present in configuration then do nothing. + alpine_version = alpine_repo.get('version') + if not alpine_version: + LOG.debug(("Skipping module named %s," + " 'version' not specified in alpine_repo"), name) + return + + local_repo = apk_section.get('local_repo_base_url', '') + + _write_repositories_file(alpine_repo, alpine_version, local_repo) + + +def _write_repositories_file(alpine_repo, alpine_version, local_repo): + """ + Write the /etc/apk/repositories file with the specified entries. + + @param alpine_repo: A nested dict of the alpine_repo configuration. + @param alpine_version: A string of the Alpine version to use. + @param local_repo: A string containing the base URL of a local repo. + """ + + repo_file = '/etc/apk/repositories' + + alpine_baseurl = alpine_repo.get('base_url', DEFAULT_MIRROR) + + params = {'alpine_baseurl': alpine_baseurl, + 'alpine_version': alpine_version, + 'community_enabled': alpine_repo.get('community_enabled'), + 'testing_enabled': alpine_repo.get('testing_enabled'), + 'local_repo': local_repo} + + tfile = temp_utils.mkstemp(prefix='template_name-', suffix=".tmpl") + template_fn = tfile[1] # Filepath is second item in tuple + util.write_file(template_fn, content=REPOSITORIES_TEMPLATE) + + LOG.debug('Generating Alpine repository configuration file: %s', + repo_file) + templater.render_to_file(template_fn, repo_file, params) + # Clean up temporary template + util.del_file(template_fn) + + +# vi: ts=4 expandtab diff --git a/cloudinit/config/cc_ca_certs.py b/cloudinit/config/cc_ca_certs.py index 910b78de..3c453d91 100644 --- a/cloudinit/config/cc_ca_certs.py +++ b/cloudinit/config/cc_ca_certs.py @@ -16,11 +16,16 @@ can be removed from the system with the configuration option certificates must be specified using valid yaml. in order to specify a multiline certificate, the yaml multiline list syntax must be used +.. note:: + For Alpine Linux the "remove-defaults" functionality works if the + ca-certificates package is installed but not if the + ca-certificates-bundle package is installed. + **Internal name:** ``cc_ca_certs`` **Module frequency:** per instance -**Supported distros:** ubuntu, debian +**Supported distros:** alpine, debian, ubuntu **Config keys**:: @@ -45,7 +50,7 @@ CA_CERT_CONFIG = "/etc/ca-certificates.conf" CA_CERT_SYSTEM_PATH = "/etc/ssl/certs/" CA_CERT_FULL_PATH = os.path.join(CA_CERT_PATH, CA_CERT_FILENAME) -distros = ['ubuntu', 'debian'] +distros = ['alpine', 'debian', 'ubuntu'] def update_ca_certs(): @@ -83,7 +88,7 @@ def add_ca_certs(certs): util.write_file(CA_CERT_CONFIG, out, omode="wb") -def remove_default_ca_certs(): +def remove_default_ca_certs(distro_name): """ Removes all default trusted CA certificates from the system. To actually apply the change you must also call L{update_ca_certs}. @@ -91,11 +96,14 @@ def remove_default_ca_certs(): util.delete_dir_contents(CA_CERT_PATH) util.delete_dir_contents(CA_CERT_SYSTEM_PATH) util.write_file(CA_CERT_CONFIG, "", mode=0o644) - debconf_sel = "ca-certificates ca-certificates/trust_new_crts select no" - subp.subp(('debconf-set-selections', '-'), debconf_sel) + + if distro_name != 'alpine': + debconf_sel = ( + "ca-certificates ca-certificates/trust_new_crts " + "select no") + subp.subp(('debconf-set-selections', '-'), debconf_sel) -def handle(name, cfg, _cloud, log, _args): +def handle(name, cfg, cloud, log, _args): """ Call to handle ca-cert sections in cloud-config file. @@ -117,7 +125,7 @@ def handle(name, cfg, _cloud, log, _args): # default trusted CA certs first. if ca_cert_cfg.get("remove-defaults", False): log.debug("Removing default certificates") - remove_default_ca_certs() + remove_default_ca_certs(cloud.distro.name) # If we are given any new trusted CA certs to add, add them. if "trusted" in ca_cert_cfg: diff --git a/cloudinit/config/cc_ntp.py b/cloudinit/config/cc_ntp.py index 7d3f73ff..3d7279d6 100644 --- a/cloudinit/config/cc_ntp.py +++ b/cloudinit/config/cc_ntp.py @@ -24,7 +24,8 @@ LOG = logging.getLogger(__name__) frequency = PER_INSTANCE NTP_CONF = '/etc/ntp.conf' NR_POOL_SERVERS = 4 -distros = ['centos', 'debian', 'fedora', 'opensuse', 'rhel', 'sles', 'ubuntu'] +distros = ['alpine', 'centos', 'debian', 'fedora', 'opensuse', 'rhel', + 'sles', 'ubuntu'] NTP_CLIENT_CONFIG = { 'chrony': { @@ -63,6 +64,17 @@ NTP_CLIENT_CONFIG = { # This is Distro-specific configuration overrides of the base config DISTRO_CLIENT_CONFIG = { + 'alpine': { + 'chrony': { + 'confpath': '/etc/chrony/chrony.conf', + 'service_name': 'chronyd', + }, + 'ntp': { + 'confpath': '/etc/ntp.conf', + 'packages': [], + 'service_name': 'ntpd', + }, + }, 'debian': { 'chrony': { 'confpath': '/etc/chrony/chrony.conf', @@ -114,11 +126,11 @@ schema = { Handle ntp configuration. If ntp is not installed on the system and ntp configuration is specified, ntp will be installed. If there is a default ntp config file in the image or one is present in the - distro's ntp package, it will be copied to ``/etc/ntp.conf.dist`` - before any changes are made. A list of ntp pools and ntp servers can - be provided under the ``ntp`` config key. If no ntp ``servers`` or - ``pools`` are provided, 4 pools will be used in the format - ``{0-3}.{distro}.pool.ntp.org``."""), + distro's ntp package, it will be copied to a file with ``.dist`` + appended to the filename before any changes are made. A list of ntp + pools and ntp servers can be provided under the ``ntp`` config key. + If no ntp ``servers`` or ``pools`` are provided, 4 pools will be used + in the format ``{0-3}.{distro}.pool.ntp.org``."""), 'distros': distros, 'examples': [ dedent("""\ @@ -171,7 +183,10 @@ schema = { 'description': dedent("""\ List of ntp pools. If both pools and servers are empty, 4 default pool servers will be provided of - the format ``{0-3}.{distro}.pool.ntp.org``.""") + the format ``{0-3}.{distro}.pool.ntp.org``. NOTE: + for Alpine Linux when using the Busybox NTP client + this setting will be ignored due to the limited + functionality of Busybox's ntpd.""") }, 'servers': { 'type': 'array', @@ -364,21 +379,30 @@ def generate_server_names(distro): """ names = [] pool_distro = distro - # For legal reasons x.pool.sles.ntp.org does not exist, - # use the opensuse pool + if distro == 'sles': + # For legal reasons x.pool.sles.ntp.org does not exist, + # use the opensuse pool pool_distro = 'opensuse' + elif distro == 'alpine': + # Alpine-specific pool (i.e. x.alpine.pool.ntp.org) does not exist + # so use general x.pool.ntp.org instead. + pool_distro = '' + for x in range(0, NR_POOL_SERVERS): - name = "%d.%s.pool.ntp.org" % (x, pool_distro) - names.append(name) + names.append(".".join( + [n for n in [str(x)] + [pool_distro] + ['pool.ntp.org'] if n])) + return names -def write_ntp_config_template(distro_name, servers=None, pools=None, - path=None, template_fn=None, template=None): +def write_ntp_config_template(distro_name, service_name=None, servers=None, + pools=None, path=None, template_fn=None, + template=None): """Render a ntp client configuration for the specified client. @param distro_name: string. The distro class name. + @param service_name: string. The name of the NTP client service. @param servers: A list of strings specifying ntp servers. Defaults to empty list. @param pools: A list of strings specifying ntp pools. Defaults to empty @@ -397,7 +421,14 @@ def write_ntp_config_template(distro_name, servers=None, pools=None, if not pools: pools = [] - if len(servers) == 0 and len(pools) == 0: + if (len(servers) == 0 and distro_name == 'alpine' and + service_name == 'ntpd'): + # Alpine's Busybox ntpd only understands "servers" configuration + # and not "pool" configuration. + servers = generate_server_names(distro_name) + LOG.debug( + 'Adding distro default ntp servers: %s', ','.join(servers)) + elif len(servers) == 0 and len(pools) == 0: pools = generate_server_names(distro_name) LOG.debug( 'Adding distro default ntp pool servers: %s', ','.join(pools)) @@ -532,6 +563,8 @@ def handle(name, cfg, cloud, log, _args): raise RuntimeError(msg) write_ntp_config_template(cloud.distro.name, + service_name=ntp_client_config.get( + 'service_name'), servers=ntp_cfg.get('servers', []), pools=ntp_cfg.get('pools', []), path=ntp_client_config.get('confpath'), diff --git a/cloudinit/config/cc_power_state_change.py b/cloudinit/config/cc_power_state_change.py index 41ffb46c..ab953a0d 100644 --- a/cloudinit/config/cc_power_state_change.py +++ b/cloudinit/config/cc_power_state_change.py @@ -22,9 +22,8 @@ The ``delay`` key specifies a duration to be added onto any shutdown command used. Therefore, if a 5 minute delay and a 120 second shutdown are specified, the maximum amount of time between cloud-init starting and the system shutting down is 7 minutes, and the minimum amount of time is 5 minutes. The ``delay`` -key must have an argument in a form that the ``shutdown`` utility recognizes. -The most common format is the form ``+5`` for 5 minutes. See ``man shutdown`` -for more options. +key must have an argument in either the form ``+5`` for 5 minutes or ``now`` +for immediate shutdown. Optionally, a command can be run to determine whether or not the system should shut down. The command to be run should be specified in the @@ -33,6 +32,10 @@ the system should shut down. The command to be run should be specified in the ``condition`` key is omitted or the command specified by the ``condition`` key returns 0. +.. note:: + With Alpine Linux any message value specified is ignored as Alpine's halt, + poweroff, and reboot commands do not support broadcasting a message. + **Internal name:** ``cc_power_state_change`` **Module frequency:** per instance @@ -112,9 +115,9 @@ def check_condition(cond, log=None): return False -def handle(_name, cfg, _cloud, log, _args): +def handle(_name, cfg, cloud, log, _args): try: - (args, timeout, condition) = load_power_state(cfg) + (args, timeout, condition) = load_power_state(cfg, cloud.distro.name) if args is None: log.debug("no power_state provided. doing nothing") return @@ -141,7 +144,19 @@ def handle(_name, cfg, _cloud, log, _args): condition, execmd, [args, devnull_fp]) -def load_power_state(cfg): +def convert_delay(delay, fmt=None, scale=None): + if not fmt: + fmt = "+%s" + if not scale: + scale = 1 + + if delay != "now": + delay = fmt % int(int(delay) * int(scale)) + + return delay + + +def load_power_state(cfg, distro_name): # returns a tuple of shutdown_command, timeout # shutdown_command is None if no config found pstate = cfg.get('power_state') @@ -161,20 +176,34 @@ def load_power_state(cfg): (','.join(opt_map.keys()), mode)) delay = pstate.get("delay", "now") - # convert integer 30 or string '30' to '+30' + message = pstate.get("message") + scale = 1 + fmt = "+%s" + command = ["shutdown", opt_map[mode]] + + if distro_name == 'alpine': + # Convert integer 30 or string '30' to '1800' (seconds) as Alpine's + # halt/poweroff/reboot commands take seconds rather than minutes. + scale = 60 + # No "+" in front of delay value as not supported by Alpine's commands. + fmt = "%s" + if delay == "now": + # Alpine's commands do not understand "now". + delay = "0" + command = [mode, "-d"] + # Alpine's commands don't support a message. + message = None + try: - delay = "+%s" % int(delay) + delay = convert_delay(delay, fmt=fmt, scale=scale) except ValueError: - pass - - if delay != "now" and not re.match(r"\+[0-9]+", delay): raise TypeError( "power_state[delay] must be 'now' or '+m' (minutes)." " found '%s'." % delay) - args = ["shutdown", opt_map[mode], delay] - if pstate.get("message"): - args.append(pstate.get("message")) + args = command + [delay] + if message: + args.append(message) try: timeout = float(pstate.get('timeout', 30.0)) diff --git a/cloudinit/config/cc_resolv_conf.py b/cloudinit/config/cc_resolv_conf.py index 69f4768a..519e66eb 100644 --- a/cloudinit/config/cc_resolv_conf.py +++ b/cloudinit/config/cc_resolv_conf.py @@ -30,7 +30,7 @@ are configured correctly. **Module frequency:** per instance -**Supported distros:** fedora, rhel, sles +**Supported distros:** alpine, fedora, rhel, sles **Config keys**:: @@ -55,7 +55,7 @@ LOG = logging.getLogger(__name__) frequency = PER_INSTANCE -distros = ['fedora', 'opensuse', 'rhel', 'sles'] +distros = ['alpine', 'fedora', 'opensuse', 'rhel', 'sles'] def generate_resolv_conf(template_fn, params, target_fname="/etc/resolv.conf"): diff --git a/cloudinit/distros/__init__.py b/cloudinit/distros/__init__.py index c7163e1c..effb4276 100755 --- a/cloudinit/distros/__init__.py +++ b/cloudinit/distros/__init__.py @@ -40,12 +40,13 @@ from .networking import LinuxNetworking 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__) 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 +# Author: Dermot Bradley +# +# 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/util.py b/cloudinit/util.py index edd37039..dd263803 100644 --- a/cloudinit/util.py +++ b/cloudinit/util.py @@ -548,7 +548,8 @@ def system_info(): if system == "linux": linux_dist = info['dist'][0].lower() if linux_dist in ( - 'arch', 'centos', 'debian', 'fedora', 'rhel', 'suse'): + 'alpine', 'arch', 'centos', 'debian', 'fedora', 'rhel', + 'suse'): var = linux_dist elif linux_dist in ('ubuntu', 'linuxmint', 'mint'): var = 'ubuntu' diff --git a/config/cloud.cfg.tmpl b/config/cloud.cfg.tmpl index b44cbce7..2beb9b0c 100644 --- a/config/cloud.cfg.tmpl +++ b/config/cloud.cfg.tmpl @@ -21,7 +21,7 @@ disable_root: false disable_root: true {% endif %} -{% if variant in ["amazon", "centos", "fedora", "rhel"] %} +{% if variant in ["alpine", "amazon", "centos", "fedora", "rhel"] %} mount_default_fields: [~, ~, 'auto', 'defaults,nofail', '0', '2'] {% if variant == "amazon" %} resize_rootfs: noblock @@ -71,6 +71,9 @@ cloud_init_modules: - set_hostname - update_hostname - update_etc_hosts +{% if variant in ["alpine"] %} + - resolv_conf +{% endif %} {% if not variant.endswith("bsd") %} - ca-certs - rsyslog @@ -104,6 +107,9 @@ cloud_config_modules: {% if variant in ["suse"] %} - zypper-add-repo {% endif %} +{% if variant in ["alpine"] %} + - apk-configure +{% endif %} {% if variant not in ["freebsd", "netbsd"] %} - ntp {% endif %} @@ -145,7 +151,9 @@ cloud_final_modules: # (not accessible to handlers/transforms) system_info: # This will affect which distro class gets used -{% if variant in ["amazon", "arch", "centos", "debian", "fedora", "freebsd", "netbsd", "openbsd", "rhel", "suse", "ubuntu"] %} +{% if variant in ["alpine", "amazon", "arch", "centos", "debian", + "fedora", "freebsd", "netbsd", "openbsd", "rhel", + "suse", "ubuntu"] %} distro: {{ variant }} {% else %} # Unknown/fallback distro. @@ -196,7 +204,8 @@ system_info: primary: http://ports.ubuntu.com/ubuntu-ports security: http://ports.ubuntu.com/ubuntu-ports ssh_svcname: ssh -{% elif variant in ["amazon", "arch", "centos", "fedora", "rhel", "suse"] %} +{% elif variant in ["alpine", "amazon", "arch", "centos", "fedora", + "rhel", "suse"] %} # Default user name + that default users groups (if added/used) default_user: {% if variant == "amazon" %} @@ -210,13 +219,19 @@ system_info: {% endif %} {% if variant == "suse" %} groups: [cdrom, users] +{% elif variant == "alpine" %} + groups: [adm, sudo] {% elif variant == "arch" %} groups: [wheel, users] {% else %} groups: [wheel, adm, systemd-journal] {% endif %} sudo: ["ALL=(ALL) NOPASSWD:ALL"] +{% if variant == "alpine" %} + shell: /bin/ash +{% else %} shell: /bin/bash +{% endif %} # Other config here will be given to the distro class and/or path classes paths: cloud_dir: /var/lib/cloud/ diff --git a/doc/rtd/topics/availability.rst b/doc/rtd/topics/availability.rst index 84490460..8f56a7d2 100644 --- a/doc/rtd/topics/availability.rst +++ b/doc/rtd/topics/availability.rst @@ -17,16 +17,17 @@ Distributions Cloud-init has support across all major Linux distributions, FreeBSD, NetBSD and OpenBSD: -- Ubuntu -- SLES/openSUSE -- RHEL/CentOS -- Fedora -- Gentoo Linux -- Debian +- Alpine Linux - ArchLinux +- Debian +- Fedora - FreeBSD +- Gentoo Linux - NetBSD - OpenBSD +- RHEL/CentOS +- SLES/openSUSE +- Ubuntu Clouds ====== diff --git a/doc/rtd/topics/instancedata.rst b/doc/rtd/topics/instancedata.rst index 845098bb..255245a4 100644 --- a/doc/rtd/topics/instancedata.rst +++ b/doc/rtd/topics/instancedata.rst @@ -132,6 +132,7 @@ This shall be the distro name, version and release as determined by Example output: +- alpine, 3.12.0, '' - centos, 7.5, core - debian, 9, stretch - freebsd, 12.0-release-p10, diff --git a/doc/rtd/topics/modules.rst b/doc/rtd/topics/modules.rst index 9c9be804..e30fe0fe 100644 --- a/doc/rtd/topics/modules.rst +++ b/doc/rtd/topics/modules.rst @@ -6,6 +6,7 @@ Modules ******* .. contents:: Table of Contents +.. automodule:: cloudinit.config.cc_apk_configure .. automodule:: cloudinit.config.cc_apt_configure .. automodule:: cloudinit.config.cc_apt_pipelining .. automodule:: cloudinit.config.cc_bootcmd diff --git a/doc/rtd/topics/network-config.rst b/doc/rtd/topics/network-config.rst index 8eeadebf..08db04d8 100644 --- a/doc/rtd/topics/network-config.rst +++ b/doc/rtd/topics/network-config.rst @@ -165,7 +165,7 @@ supported formats. The following ``renderers`` are supported in cloud-init: - **ENI** /etc/network/interfaces or ``ENI`` is supported by the ``ifupdown`` package -found in Ubuntu and Debian. +found in Alpine Linux, Debian and Ubuntu. - **Netplan** diff --git a/templates/chrony.conf.alpine.tmpl b/templates/chrony.conf.alpine.tmpl new file mode 100644 index 00000000..45efc18c --- /dev/null +++ b/templates/chrony.conf.alpine.tmpl @@ -0,0 +1,38 @@ +## template:jinja +# Welcome to the chrony configuration file. See chrony.conf(5) for more +# information about usable directives. +{% if pools %}# pools +{% endif %} +{% for pool in pools -%} +pool {{pool}} iburst +{% endfor %} +{%- if servers %}# servers +{% endif %} +{% for server in servers -%} +server {{server}} iburst +{% endfor %} + +# This directive specifies the location of the file containing ID/key pairs for +# NTP authentication. +keyfile /etc/chrony/chrony.keys + +# This directive specifies the file into which chronyd will store the rate +# information. +driftfile /var/lib/chrony/chrony.drift + +# Uncomment the following line to turn logging on. +#log tracking measurements statistics + +# Log files location. +logdir /var/log/chrony + +# Stop bad estimates upsetting machine clock. +maxupdateskew 100.0 + +# This directive enables kernel synchronisation (every 11 minutes) of the +# real-time clock. Note that it can’t be used along with the 'rtcfile' directive. +rtcsync + +# Step the system clock instead of slewing it if the adjustment is larger than +# one second, but only in the first three clock updates. +makestep 1 3 diff --git a/templates/hosts.alpine.tmpl b/templates/hosts.alpine.tmpl new file mode 100644 index 00000000..33c1a941 --- /dev/null +++ b/templates/hosts.alpine.tmpl @@ -0,0 +1,28 @@ +## template:jinja +{# +This file /etc/cloud/templates/hosts.alpine.tmpl is only utilized +if enabled in cloud-config. Specifically, in order to enable it +you need to add the following to config: + manage_etc_hosts: True +-#} +# Your system has configured 'manage_etc_hosts' as True. +# As a result, if you wish for changes to this file to persist +# then you will need to either +# a.) make changes to the master file in /etc/cloud/templates/hosts.alpine.tmpl +# b.) change or remove the value of 'manage_etc_hosts' in +# /etc/cloud/cloud.cfg or cloud-config from user-data +# +# The following lines are desirable for IPv4 capable hosts +127.0.1.1 {{fqdn}} {{hostname}} +127.0.0.1 localhost.localdomain localhost +127.0.0.1 localhost4.localdomain4 localhost4 + +# The following lines are desirable for IPv6 capable hosts +::1 {{fqdn}} {{hostname}} +::1 localhost6.localdomain6 localhost6 + +fe00::0 ip6-localnet +ff00::0 ip6-mcastprefix +ff02::1 ip6-allnodes +ff02::2 ip6-allrouters +ff02::3 ip6-allhosts diff --git a/templates/ntp.conf.alpine.tmpl b/templates/ntp.conf.alpine.tmpl new file mode 100644 index 00000000..59ca8fc1 --- /dev/null +++ b/templates/ntp.conf.alpine.tmpl @@ -0,0 +1,10 @@ +## template:jinja +# /etc/ntp.conf +# +# Configuration for Busybox ntpd - it only supports "server" lines. + +{% if servers %}# Servers +{% endif %} +{% for server in servers -%} +server {{server}} +{% endfor %} diff --git a/tests/unittests/test_cli.py b/tests/unittests/test_cli.py index 43d996b9..dcf0fe5a 100644 --- a/tests/unittests/test_cli.py +++ b/tests/unittests/test_cli.py @@ -224,7 +224,7 @@ class TestCLI(test_helpers.FilesystemMockingTestCase): self._call_main(['cloud-init', 'devel', 'schema', '--docs', 'all']) expected_doc_sections = [ '**Supported distros:** all', - '**Supported distros:** centos, debian, fedora', + '**Supported distros:** alpine, centos, debian, fedora', '**Config schema**:\n **resize_rootfs:** (true/false/noblock)', '**Examples**::\n\n runcmd:\n - [ ls, -l, / ]\n' ] diff --git a/tests/unittests/test_handler/test_handler_apk_configure.py b/tests/unittests/test_handler/test_handler_apk_configure.py new file mode 100644 index 00000000..8acc0b33 --- /dev/null +++ b/tests/unittests/test_handler/test_handler_apk_configure.py @@ -0,0 +1,299 @@ +# This file is part of cloud-init. See LICENSE file for license information. + +""" test_apk_configure +Test creation of repositories file +""" + +import logging +import os +import textwrap + +from cloudinit import (cloud, helpers, util) + +from cloudinit.config import cc_apk_configure +from cloudinit.tests.helpers import (FilesystemMockingTestCase, mock) + +REPO_FILE = "/etc/apk/repositories" +DEFAULT_MIRROR_URL = "https://alpine.global.ssl.fastly.net/alpine" +CC_APK = 'cloudinit.config.cc_apk_configure' + + +class TestNoConfig(FilesystemMockingTestCase): + def setUp(self): + super(TestNoConfig, self).setUp() + self.add_patch(CC_APK + '._write_repositories_file', 'm_write_repos') + self.name = "apk-configure" + self.cloud_init = None + self.log = logging.getLogger("TestNoConfig") + self.args = [] + + def test_no_config(self): + """ + Test that nothing is done if no apk-configure + configuration is provided. + """ + config = util.get_builtin_cfg() + + cc_apk_configure.handle(self.name, config, self.cloud_init, + self.log, self.args) + + self.assertEqual(0, self.m_write_repos.call_count) + + +class TestConfig(FilesystemMockingTestCase): + def setUp(self): + super(TestConfig, self).setUp() + self.new_root = self.tmp_dir() + self.new_root = self.reRoot(root=self.new_root) + for dirname in ['tmp', 'etc/apk']: + util.ensure_dir(os.path.join(self.new_root, dirname)) + self.paths = helpers.Paths({'templates_dir': self.new_root}) + self.name = "apk-configure" + self.cloud = cloud.Cloud(None, self.paths, None, None, None) + self.log = logging.getLogger("TestNoConfig") + self.args = [] + + @mock.patch(CC_APK + '._write_repositories_file') + def test_no_repo_settings(self, m_write_repos): + """ + Test that nothing is written if the 'alpine-repo' key + is not present. + """ + config = {"apk_repos": {}} + + cc_apk_configure.handle(self.name, config, self.cloud, self.log, + self.args) + + self.assertEqual(0, m_write_repos.call_count) + + @mock.patch(CC_APK + '._write_repositories_file') + def test_empty_repo_settings(self, m_write_repos): + """ + Test that nothing is written if 'alpine_repo' list is empty. + """ + config = {"apk_repos": {"alpine_repo": []}} + + cc_apk_configure.handle(self.name, config, self.cloud, self.log, + self.args) + + self.assertEqual(0, m_write_repos.call_count) + + def test_only_main_repo(self): + """ + Test when only details of main repo is written to file. + """ + alpine_version = 'v3.12' + config = { + "apk_repos": { + "alpine_repo": { + "version": alpine_version + } + } + } + + cc_apk_configure.handle(self.name, config, self.cloud, self.log, + self.args) + + expected_content = textwrap.dedent("""\ + # + # Created by cloud-init + # + # This file is written on first boot of an instance + # + + {0}/{1}/main + + """.format(DEFAULT_MIRROR_URL, alpine_version)) + + self.assertEqual(expected_content, util.load_file(REPO_FILE)) + + def test_main_and_community_repos(self): + """ + Test when only details of main and community repos are + written to file. + """ + alpine_version = 'edge' + config = { + "apk_repos": { + "alpine_repo": { + "version": alpine_version, + "community_enabled": True + } + } + } + + cc_apk_configure.handle(self.name, config, self.cloud, self.log, + self.args) + + expected_content = textwrap.dedent("""\ + # + # Created by cloud-init + # + # This file is written on first boot of an instance + # + + {0}/{1}/main + {0}/{1}/community + + """.format(DEFAULT_MIRROR_URL, alpine_version)) + + self.assertEqual(expected_content, util.load_file(REPO_FILE)) + + def test_main_community_testing_repos(self): + """ + Test when details of main, community and testing repos + are written to file. + """ + alpine_version = 'v3.12' + config = { + "apk_repos": { + "alpine_repo": { + "version": alpine_version, + "community_enabled": True, + "testing_enabled": True + } + } + } + + cc_apk_configure.handle(self.name, config, self.cloud, self.log, + self.args) + + expected_content = textwrap.dedent("""\ + # + # Created by cloud-init + # + # This file is written on first boot of an instance + # + + {0}/{1}/main + {0}/{1}/community + # + # Testing - using with non-Edge installation may cause problems! + # + {0}/edge/testing + + """.format(DEFAULT_MIRROR_URL, alpine_version)) + + self.assertEqual(expected_content, util.load_file(REPO_FILE)) + + def test_edge_main_community_testing_repos(self): + """ + Test when details of main, community and testing repos + for Edge version of Alpine are written to file. + """ + alpine_version = 'edge' + config = { + "apk_repos": { + "alpine_repo": { + "version": alpine_version, + "community_enabled": True, + "testing_enabled": True + } + } + } + + cc_apk_configure.handle(self.name, config, self.cloud, self.log, + self.args) + + expected_content = textwrap.dedent("""\ + # + # Created by cloud-init + # + # This file is written on first boot of an instance + # + + {0}/{1}/main + {0}/{1}/community + {0}/{1}/testing + + """.format(DEFAULT_MIRROR_URL, alpine_version)) + + self.assertEqual(expected_content, util.load_file(REPO_FILE)) + + def test_main_community_testing_local_repos(self): + """ + Test when details of main, community, testing and + local repos are written to file. + """ + alpine_version = 'v3.12' + local_repo_url = 'http://some.mirror/whereever' + config = { + "apk_repos": { + "alpine_repo": { + "version": alpine_version, + "community_enabled": True, + "testing_enabled": True + }, + "local_repo_base_url": local_repo_url + } + } + + cc_apk_configure.handle(self.name, config, self.cloud, self.log, + self.args) + + expected_content = textwrap.dedent("""\ + # + # Created by cloud-init + # + # This file is written on first boot of an instance + # + + {0}/{1}/main + {0}/{1}/community + # + # Testing - using with non-Edge installation may cause problems! + # + {0}/edge/testing + + # + # Local repo + # + {2}/{1} + + """.format(DEFAULT_MIRROR_URL, alpine_version, local_repo_url)) + + self.assertEqual(expected_content, util.load_file(REPO_FILE)) + + def test_edge_main_community_testing_local_repos(self): + """ + Test when details of main, community, testing and local repos + for Edge version of Alpine are written to file. + """ + alpine_version = 'edge' + local_repo_url = 'http://some.mirror/whereever' + config = { + "apk_repos": { + "alpine_repo": { + "version": alpine_version, + "community_enabled": True, + "testing_enabled": True + }, + "local_repo_base_url": local_repo_url + } + } + + cc_apk_configure.handle(self.name, config, self.cloud, self.log, + self.args) + + expected_content = textwrap.dedent("""\ + # + # Created by cloud-init + # + # This file is written on first boot of an instance + # + + {0}/{1}/main + {0}/{1}/community + {0}/edge/testing + + # + # Local repo + # + {2}/{1} + + """.format(DEFAULT_MIRROR_URL, alpine_version, local_repo_url)) + + self.assertEqual(expected_content, util.load_file(REPO_FILE)) + + +# vi: ts=4 expandtab diff --git a/tests/unittests/test_handler/test_handler_ca_certs.py b/tests/unittests/test_handler/test_handler_ca_certs.py index c1aff181..e74a0a08 100644 --- a/tests/unittests/test_handler/test_handler_ca_certs.py +++ b/tests/unittests/test_handler/test_handler_ca_certs.py @@ -1,6 +1,7 @@ # This file is part of cloud-init. See LICENSE file for license information. from cloudinit import cloud +from cloudinit import distros from cloudinit.config import cc_ca_certs from cloudinit import helpers from cloudinit import subp @@ -46,8 +47,9 @@ class TestConfig(TestCase): def setUp(self): super(TestConfig, self).setUp() self.name = "ca-certs" + distro = self._fetch_distro('ubuntu') self.paths = None - self.cloud = cloud.Cloud(None, self.paths, None, None, None) + self.cloud = cloud.Cloud(None, self.paths, None, distro, None) self.log = logging.getLogger("TestNoConfig") self.args = [] @@ -62,6 +64,11 @@ class TestConfig(TestCase): self.mock_remove = self.mocks.enter_context( mock.patch.object(cc_ca_certs, 'remove_default_ca_certs')) + def _fetch_distro(self, kind): + cls = distros.fetch(kind) + paths = helpers.Paths({}) + return cls(kind, {}, paths) + def test_no_trusted_list(self): """ Test that no certificates are written if the 'trusted' key is not @@ -275,7 +282,7 @@ class TestRemoveDefaultCaCerts(TestCase): mock.patch.object(util, 'write_file')) mock_subp = mocks.enter_context(mock.patch.object(subp, 'subp')) - cc_ca_certs.remove_default_ca_certs() + cc_ca_certs.remove_default_ca_certs('ubuntu') mock_delete.assert_has_calls([ mock.call("/usr/share/ca-certificates/"), diff --git a/tests/unittests/test_handler/test_handler_ntp.py b/tests/unittests/test_handler/test_handler_ntp.py index 92a33ec1..6b9c8377 100644 --- a/tests/unittests/test_handler/test_handler_ntp.py +++ b/tests/unittests/test_handler/test_handler_ntp.py @@ -239,6 +239,35 @@ class TestNtp(FilesystemMockingTestCase): self.assertEqual(delta[distro][client][key], result[client][key]) + def _get_expected_pools(self, pools, distro, client): + if client in ['ntp', 'chrony']: + if client == 'ntp' and distro == 'alpine': + # NTP for Alpine Linux is Busybox's ntp which does not + # support 'pool' lines in its configuration file. + expected_pools = [] + else: + expected_pools = [ + 'pool {0} iburst'.format(pool) for pool in pools] + elif client == 'systemd-timesyncd': + expected_pools = " ".join(pools) + + return expected_pools + + def _get_expected_servers(self, servers, distro, client): + if client in ['ntp', 'chrony']: + if client == 'ntp' and distro == 'alpine': + # NTP for Alpine Linux is Busybox's ntp which only supports + # 'server' lines without iburst option. + expected_servers = [ + 'server {0}'.format(srv) for srv in servers] + else: + expected_servers = [ + 'server {0} iburst'.format(srv) for srv in servers] + elif client == 'systemd-timesyncd': + expected_servers = " ".join(servers) + + return expected_servers + def test_ntp_handler_real_distro_ntp_templates(self): """Test ntp handler renders the shipped distro ntp client templates.""" pools = ['0.mycompany.pool.ntp.org', '3.mycompany.pool.ntp.org'] @@ -269,27 +298,35 @@ class TestNtp(FilesystemMockingTestCase): content = util.load_file(confpath) if client in ['ntp', 'chrony']: content_lines = content.splitlines() - expected_servers = [ - 'server {0} iburst'.format(srv) for srv in servers] + expected_servers = self._get_expected_servers(servers, + distro, + client) print('distro=%s client=%s' % (distro, client)) for sline in expected_servers: self.assertIn(sline, content_lines, ('failed to render {0} conf' ' for distro:{1}'.format(client, distro))) - expected_pools = [ - 'pool {0} iburst'.format(pool) for pool in pools] - for pline in expected_pools: - self.assertIn(pline, content_lines, - ('failed to render {0} conf' - ' for distro:{1}'.format(client, - distro))) + expected_pools = self._get_expected_pools(pools, distro, + client) + if expected_pools != []: + for pline in expected_pools: + self.assertIn(pline, content_lines, + ('failed to render {0} conf' + ' for distro:{1}'.format(client, + distro))) elif client == 'systemd-timesyncd': + expected_servers = self._get_expected_servers(servers, + distro, + client) + expected_pools = self._get_expected_pools(pools, + distro, + client) expected_content = ( "# cloud-init generated file\n" + "# See timesyncd.conf(5) for details.\n\n" + - "[Time]\nNTP=%s %s \n" % (" ".join(servers), - " ".join(pools))) + "[Time]\nNTP=%s %s \n" % (expected_servers, + expected_pools)) self.assertEqual(expected_content, content) def test_no_ntpcfg_does_nothing(self): @@ -312,10 +349,20 @@ class TestNtp(FilesystemMockingTestCase): confpath = ntpconfig['confpath'] m_select.return_value = ntpconfig cc_ntp.handle('cc_ntp', valid_empty_config, mycloud, None, []) - pools = cc_ntp.generate_server_names(mycloud.distro.name) - self.assertEqual( - "servers []\npools {0}\n".format(pools), - util.load_file(confpath)) + if distro == 'alpine': + # _mock_ntp_client_config call above did not specify a + # client value and so it defaults to "ntp" which on + # Alpine Linux only supports servers and not pools. + + servers = cc_ntp.generate_server_names(mycloud.distro.name) + self.assertEqual( + "servers {0}\npools []\n".format(servers), + util.load_file(confpath)) + else: + pools = cc_ntp.generate_server_names(mycloud.distro.name) + self.assertEqual( + "servers []\npools {0}\n".format(pools), + util.load_file(confpath)) self.assertNotIn('Invalid config:', self.logs.getvalue()) @skipUnlessJsonSchema() @@ -374,18 +421,19 @@ class TestNtp(FilesystemMockingTestCase): invalid_config = { 'ntp': {'invalidkey': 1, 'pools': ['0.mycompany.pool.ntp.org']}} for distro in cc_ntp.distros: - mycloud = self._get_cloud(distro) - ntpconfig = self._mock_ntp_client_config(distro=distro) - confpath = ntpconfig['confpath'] - m_select.return_value = ntpconfig - cc_ntp.handle('cc_ntp', invalid_config, mycloud, None, []) - self.assertIn( - "Invalid config:\nntp: Additional properties are not allowed " - "('invalidkey' was unexpected)", - self.logs.getvalue()) - self.assertEqual( - "servers []\npools ['0.mycompany.pool.ntp.org']\n", - util.load_file(confpath)) + if distro != 'alpine': + mycloud = self._get_cloud(distro) + ntpconfig = self._mock_ntp_client_config(distro=distro) + confpath = ntpconfig['confpath'] + m_select.return_value = ntpconfig + cc_ntp.handle('cc_ntp', invalid_config, mycloud, None, []) + self.assertIn( + "Invalid config:\nntp: Additional properties are not " + "allowed ('invalidkey' was unexpected)", + self.logs.getvalue()) + self.assertEqual( + "servers []\npools ['0.mycompany.pool.ntp.org']\n", + util.load_file(confpath)) @skipUnlessJsonSchema() @mock.patch('cloudinit.config.cc_ntp.select_ntp_client') @@ -452,9 +500,22 @@ class TestNtp(FilesystemMockingTestCase): confpath = ntpconfig['confpath'] service_name = ntpconfig['service_name'] m_select.return_value = ntpconfig - pools = cc_ntp.generate_server_names(mycloud.distro.name) - # force uses systemd path - m_sysd.return_value = True + + hosts = cc_ntp.generate_server_names(mycloud.distro.name) + uses_systemd = True + expected_service_call = ['systemctl', 'reload-or-restart', + service_name] + expected_content = "servers []\npools {0}\n".format(hosts) + + if distro == 'alpine': + uses_systemd = False + expected_service_call = ['service', service_name, 'restart'] + # _mock_ntp_client_config call above did not specify a client + # value and so it defaults to "ntp" which on Alpine Linux only + # supports servers and not pools. + expected_content = "servers {0}\npools []\n".format(hosts) + + m_sysd.return_value = uses_systemd with mock.patch('cloudinit.config.cc_ntp.util') as m_util: # allow use of util.mergemanydict m_util.mergemanydict.side_effect = util.mergemanydict @@ -465,11 +526,9 @@ class TestNtp(FilesystemMockingTestCase): cfg['ntp']['enabled']) cc_ntp.handle('notimportant', cfg, mycloud, None, None) m_subp.subp.assert_called_with( - ['systemctl', 'reload-or-restart', - service_name], capture=True) - self.assertEqual( - "servers []\npools {0}\n".format(pools), - util.load_file(confpath)) + expected_service_call, capture=True) + + self.assertEqual(expected_content, util.load_file(confpath)) def test_opensuse_picks_chrony(self): """Test opensuse picks chrony or ntp on certain distro versions""" diff --git a/tests/unittests/test_handler/test_handler_power_state.py b/tests/unittests/test_handler/test_handler_power_state.py index 0d8d17b9..93b24fdc 100644 --- a/tests/unittests/test_handler/test_handler_power_state.py +++ b/tests/unittests/test_handler/test_handler_power_state.py @@ -11,62 +11,63 @@ from cloudinit.tests.helpers import mock class TestLoadPowerState(t_help.TestCase): def test_no_config(self): # completely empty config should mean do nothing - (cmd, _timeout, _condition) = psc.load_power_state({}) + (cmd, _timeout, _condition) = psc.load_power_state({}, 'ubuntu') self.assertIsNone(cmd) def test_irrelevant_config(self): # no power_state field in config should return None for cmd - (cmd, _timeout, _condition) = psc.load_power_state({'foo': 'bar'}) + (cmd, _timeout, _condition) = psc.load_power_state({'foo': 'bar'}, + 'ubuntu') self.assertIsNone(cmd) def test_invalid_mode(self): cfg = {'power_state': {'mode': 'gibberish'}} - self.assertRaises(TypeError, psc.load_power_state, cfg) + self.assertRaises(TypeError, psc.load_power_state, cfg, 'ubuntu') cfg = {'power_state': {'mode': ''}} - self.assertRaises(TypeError, psc.load_power_state, cfg) + self.assertRaises(TypeError, psc.load_power_state, cfg, 'ubuntu') def test_empty_mode(self): cfg = {'power_state': {'message': 'goodbye'}} - self.assertRaises(TypeError, psc.load_power_state, cfg) + self.assertRaises(TypeError, psc.load_power_state, cfg, 'ubuntu') def test_valid_modes(self): cfg = {'power_state': {}} for mode in ('halt', 'poweroff', 'reboot'): cfg['power_state']['mode'] = mode - check_lps_ret(psc.load_power_state(cfg), mode=mode) + check_lps_ret(psc.load_power_state(cfg, 'ubuntu'), mode=mode) def test_invalid_delay(self): cfg = {'power_state': {'mode': 'poweroff', 'delay': 'goodbye'}} - self.assertRaises(TypeError, psc.load_power_state, cfg) + self.assertRaises(TypeError, psc.load_power_state, cfg, 'ubuntu') def test_valid_delay(self): cfg = {'power_state': {'mode': 'poweroff', 'delay': ''}} for delay in ("now", "+1", "+30"): cfg['power_state']['delay'] = delay - check_lps_ret(psc.load_power_state(cfg)) + check_lps_ret(psc.load_power_state(cfg, 'ubuntu')) def test_message_present(self): cfg = {'power_state': {'mode': 'poweroff', 'message': 'GOODBYE'}} - ret = psc.load_power_state(cfg) - check_lps_ret(psc.load_power_state(cfg)) + ret = psc.load_power_state(cfg, 'ubuntu') + check_lps_ret(psc.load_power_state(cfg, 'ubuntu')) self.assertIn(cfg['power_state']['message'], ret[0]) def test_no_message(self): # if message is not present, then no argument should be passed for it cfg = {'power_state': {'mode': 'poweroff'}} - (cmd, _timeout, _condition) = psc.load_power_state(cfg) + (cmd, _timeout, _condition) = psc.load_power_state(cfg, 'ubuntu') self.assertNotIn("", cmd) - check_lps_ret(psc.load_power_state(cfg)) + check_lps_ret(psc.load_power_state(cfg, 'ubuntu')) self.assertTrue(len(cmd) == 3) def test_condition_null_raises(self): cfg = {'power_state': {'mode': 'poweroff', 'condition': None}} - self.assertRaises(TypeError, psc.load_power_state, cfg) + self.assertRaises(TypeError, psc.load_power_state, cfg, 'ubuntu') def test_condition_default_is_true(self): cfg = {'power_state': {'mode': 'poweroff'}} - _cmd, _timeout, cond = psc.load_power_state(cfg) + _cmd, _timeout, cond = psc.load_power_state(cfg, 'ubuntu') self.assertEqual(cond, True) diff --git a/tests/unittests/test_handler/test_schema.py b/tests/unittests/test_handler/test_schema.py index 99f0b06c..44292571 100644 --- a/tests/unittests/test_handler/test_schema.py +++ b/tests/unittests/test_handler/test_schema.py @@ -24,6 +24,7 @@ class GetSchemaTest(CiTestCase): schema = get_schema() self.assertCountEqual( [ + 'cc_apk_configure', 'cc_apt_configure', 'cc_bootcmd', 'cc_locale', diff --git a/tools/render-cloudcfg b/tools/render-cloudcfg index 9322b2c3..ed454840 100755 --- a/tools/render-cloudcfg +++ b/tools/render-cloudcfg @@ -4,8 +4,9 @@ import argparse import os import sys -VARIANTS = ["amazon", "arch", "centos", "debian", "fedora", "freebsd", - "netbsd", "openbsd", "rhel", "suse", "ubuntu", "unknown"] +VARIANTS = ["alpine", "amazon", "arch", "centos", "debian", "fedora", + "freebsd", "netbsd", "openbsd", "rhel", "suse", "ubuntu", + "unknown"] if "avoid-pep8-E402-import-not-top-of-file": -- cgit v1.2.3