diff options
author | Fabian Lichtenegger-Lukas <48928888+chifac08@users.noreply.github.com> | 2022-08-09 20:47:46 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2022-08-09 20:47:46 +0200 |
commit | f20ea7d2a769d07d78ef54a403e5313a85ee9490 (patch) | |
tree | e4aef2ba6e8499f24956c5f0b32c6ea36c31dc11 | |
parent | daa2b4266e19c045549d140ccf98340ef5fbd2d8 (diff) | |
download | cloud-init-git-f20ea7d2a769d07d78ef54a403e5313a85ee9490.tar.gz |
config: Add wireguard config module (#1570)
Wireguard module provides a dynamic interface for
configuring Wireguard (as a peer or server).
This module takes care of:
- writing interface configuration files
- enabling and starting interfaces
- installing wireguard-tools package
- loading wireguard kernel module
- executing readiness probes
The idea behind readiness probes is to ensure
Wireguard connectivity before continuing the cloud-init
process. This could be useful if you need access to
specific services like an internal APT Repository Server
(e.g Landscape) to install/update packages.
-rw-r--r-- | .travis.yml | 2 | ||||
-rw-r--r-- | cloudinit/config/cc_wireguard.py | 295 | ||||
-rw-r--r-- | cloudinit/config/schemas/schema-cloud-config-v1.json | 44 | ||||
-rw-r--r-- | config/cloud.cfg.tmpl | 3 | ||||
-rw-r--r-- | doc/examples/cloud-config-wireguard.txt | 29 | ||||
-rw-r--r-- | doc/rtd/topics/modules.rst | 1 | ||||
-rw-r--r-- | tests/integration_tests/modules/test_ca_certs.py | 1 | ||||
-rw-r--r-- | tests/integration_tests/modules/test_wireguard.py | 117 | ||||
-rw-r--r-- | tests/unittests/config/test_cc_wireguard.py | 266 | ||||
-rw-r--r-- | tests/unittests/config/test_schema.py | 1 |
10 files changed, 758 insertions, 1 deletions
diff --git a/.travis.yml b/.travis.yml index fbb0b3ef..253295dd 100644 --- a/.travis.yml +++ b/.travis.yml @@ -61,7 +61,7 @@ matrix: sudo find /var/snap/lxd/common/lxd/images/ -name $latest_file* -print -exec cp {} "$TRAVIS_BUILD_DIR/lxd_images/" \; install: - git fetch --unshallow - - sudo apt-get install -y --install-recommends sbuild ubuntu-dev-tools fakeroot tox debhelper + - sudo apt-get install -y --install-recommends sbuild ubuntu-dev-tools fakeroot tox debhelper wireguard - pip install . - pip install tox # bionic has lxd from deb installed, remove it first to ensure diff --git a/cloudinit/config/cc_wireguard.py b/cloudinit/config/cc_wireguard.py new file mode 100644 index 00000000..8cfbf6f1 --- /dev/null +++ b/cloudinit/config/cc_wireguard.py @@ -0,0 +1,295 @@ +# Author: Fabian Lichtenegger-Lukas <fabian.lichtenegger-lukas@nts.eu> +# Author: Josef Tschiggerl <josef.tschiggerl@nts.eu> +# This file is part of cloud-init. See LICENSE file for license information. + +"""Wireguard""" +import re +from textwrap import dedent + +from cloudinit import log as logging +from cloudinit import subp, util +from cloudinit.cloud import Cloud +from cloudinit.config.schema import MetaSchema, get_meta_doc +from cloudinit.settings import PER_INSTANCE + +MODULE_DESCRIPTION = dedent( + """\ +Wireguard module provides a dynamic interface for configuring +Wireguard (as a peer or server) in an easy way. + +This module takes care of: + - writing interface configuration files + - enabling and starting interfaces + - installing wireguard-tools package + - loading wireguard kernel module + - executing readiness probes + +What's a readiness probe?\n +The idea behind readiness probes is to ensure Wireguard connectivity +before continuing the cloud-init process. This could be useful if you +need access to specific services like an internal APT Repository Server +(e.g Landscape) to install/update packages. + +Example:\n +An edge device can't access the internet but uses cloud-init modules which +will install packages (e.g landscape, packages, ubuntu_advantage). Those +modules will fail due to missing internet connection. The "wireguard" module +fixes that problem as it waits until all readinessprobes (which can be +arbitrary commands - e.g. checking if a proxy server is reachable over +Wireguard network) are finished before continuing the cloud-init +"config" stage. + +.. note:: + In order to use DNS with Wireguard you have to install ``resolvconf`` + package or symlink it to systemd's ``resolvectl``, otherwise ``wg-quick`` + commands will throw an error message that executable ``resolvconf`` is + missing which leads wireguard module to fail. +""" +) + +meta: MetaSchema = { + "id": "cc_wireguard", + "name": "Wireguard", + "title": "Module to configure Wireguard tunnel", + "description": MODULE_DESCRIPTION, + "distros": ["ubuntu"], + "frequency": PER_INSTANCE, + "activate_by_schema_keys": ["wireguard"], + "examples": [ + dedent( + """\ + # Configure one or more WG interfaces and provide optional readinessprobes + wireguard: + interfaces: + - name: wg0 + config_path: /etc/wireguard/wg0.conf + content: | + [Interface] + PrivateKey = <private_key> + Address = <address> + [Peer] + PublicKey = <public_key> + Endpoint = <endpoint_ip>:<endpoint_ip_port> + AllowedIPs = <allowedip1>, <allowedip2>, ... + - name: wg1 + config_path: /etc/wireguard/wg1.conf + content: | + [Interface] + PrivateKey = <private_key> + Address = <address> + [Peer] + PublicKey = <public_key> + Endpoint = <endpoint_ip>:<endpoint_ip_port> + AllowedIPs = <allowedip1> + readinessprobe: + - 'systemctl restart service' + - 'curl https://webhook.endpoint/example' + - 'nc -zv some-service-fqdn 443' + """ + ), + ], +} + +__doc__ = get_meta_doc(meta) + +LOG = logging.getLogger(__name__) + +REQUIRED_WG_INT_KEYS = frozenset(["name", "config_path", "content"]) +WG_CONFIG_FILE_MODE = 0o600 +NL = "\n" +MIN_KERNEL_VERSION = (5, 6) + + +def supplemental_schema_validation(wg_int: dict): + """Validate user-provided wg:interfaces option values. + + This function supplements flexible jsonschema validation with specific + value checks to aid in triage of invalid user-provided configuration. + + @param wg_int: Dict of configuration value under 'wg:interfaces'. + + @raises: ValueError describing invalid values provided. + """ + errors = [] + missing = REQUIRED_WG_INT_KEYS.difference(set(wg_int.keys())) + if missing: + keys = ", ".join(sorted(missing)) + errors.append(f"Missing required wg:interfaces keys: {keys}") + + for key, value in sorted(wg_int.items()): + if key == "name" or key == "config_path" or key == "content": + if not isinstance(value, str): + errors.append( + f"Expected a string for wg:interfaces:{key}. Found {value}" + ) + + if errors: + raise ValueError( + f"Invalid wireguard interface configuration:{NL}{NL.join(errors)}" + ) + + +def write_config(wg_int: dict): + """Writing user-provided configuration into Wireguard + interface configuration file. + + @param wg_int: Dict of configuration value under 'wg:interfaces'. + + @raises: RuntimeError for issues writing of configuration file. + """ + LOG.debug("Configuring Wireguard interface %s", wg_int["name"]) + try: + LOG.debug("Writing wireguard config to file %s", wg_int["config_path"]) + util.write_file( + wg_int["config_path"], wg_int["content"], mode=WG_CONFIG_FILE_MODE + ) + except Exception as e: + raise RuntimeError( + "Failure writing Wireguard configuration file" + f' {wg_int["config_path"]}:{NL}{str(e)}' + ) from e + + +def enable_wg(wg_int: dict, cloud: Cloud): + """Enable and start Wireguard interface + + @param wg_int: Dict of configuration value under 'wg:interfaces'. + + @raises: RuntimeError for issues enabling WG interface. + """ + try: + LOG.debug("Enabling wg-quick@%s at boot", wg_int["name"]) + cloud.distro.manage_service("enable", f'wg-quick@{wg_int["name"]}') + LOG.debug("Bringing up interface wg-quick@%s", wg_int["name"]) + cloud.distro.manage_service("start", f'wg-quick@{wg_int["name"]}') + except subp.ProcessExecutionError as e: + raise RuntimeError( + f"Failed enabling/starting Wireguard interface(s):{NL}{str(e)}" + ) from e + + +def readinessprobe_command_validation(wg_readinessprobes: list): + """Basic validation of user-provided probes + + @param wg_readinessprobes: List of readinessprobe probe(s). + + @raises: ValueError of wrong datatype provided for probes. + """ + errors = [] + pos = 0 + for c in wg_readinessprobes: + if not isinstance(c, str): + errors.append( + f"Expected a string for readinessprobe at {pos}. Found {c}" + ) + pos += 1 + + if errors: + raise ValueError( + f"Invalid readinessProbe commands:{NL}{NL.join(errors)}" + ) + + +def readinessprobe(wg_readinessprobes: list): + """Execute provided readiness probe(s) + + @param wg_readinessprobes: List of readinessprobe probe(s). + + @raises: ProcessExecutionError for issues during execution of probes. + """ + errors = [] + for c in wg_readinessprobes: + try: + LOG.debug("Running readinessprobe: '%s'", str(c)) + subp.subp(c, capture=True, shell=True) + except subp.ProcessExecutionError as e: + errors.append(f"{c}: {e}") + + if errors: + raise RuntimeError( + f"Failed running readinessprobe command:{NL}{NL.join(errors)}" + ) + + +def maybe_install_wireguard_packages(cloud: Cloud): + """Install wireguard packages and tools + + @param cloud: Cloud object + + @raises: Exception for issues during package + installation. + """ + + packages = ["wireguard-tools"] + + if subp.which("wg"): + return + + # Install DKMS when Kernel Verison lower 5.6 + if util.kernel_version() < MIN_KERNEL_VERSION: + packages.append("wireguard") + + try: + cloud.distro.update_package_sources() + except Exception: + util.logexc(LOG, "Package update failed") + raise + try: + cloud.distro.install_packages(packages) + except Exception: + util.logexc(LOG, "Failed to install wireguard-tools") + raise + + +def load_wireguard_kernel_module(): + """Load wireguard kernel module + + @raises: ProcessExecutionError for issues modprobe + """ + try: + out = subp.subp("lsmod", capture=True, shell=True) + if not re.search("wireguard", out.stdout.strip()): + LOG.debug("Loading wireguard kernel module") + subp.subp("modprobe wireguard", capture=True, shell=True) + except subp.ProcessExecutionError as e: + util.logexc(LOG, f"Could not load wireguard module:{NL}{str(e)}") + raise + + +def handle(name: str, cfg: dict, cloud: Cloud, log, args: list): + wg_section = None + + if "wireguard" in cfg: + LOG.debug("Found Wireguard section in config") + wg_section = cfg["wireguard"] + else: + LOG.debug( + "Skipping module named %s," " no 'wireguard' configuration found", + name, + ) + return + + # install wireguard tools, enable kernel module + maybe_install_wireguard_packages(cloud) + load_wireguard_kernel_module() + + for wg_int in wg_section["interfaces"]: + # check schema + supplemental_schema_validation(wg_int) + + # write wg config files + write_config(wg_int) + + # enable wg interfaces + enable_wg(wg_int, cloud) + + # parse and run readinessprobe parameters + if ( + "readinessprobe" in wg_section + and wg_section["readinessprobe"] is not None + ): + wg_readinessprobes = wg_section["readinessprobe"] + readinessprobe_command_validation(wg_readinessprobes) + readinessprobe(wg_readinessprobes) + else: + LOG.debug("Skipping readinessprobe - no checks defined") diff --git a/cloudinit/config/schemas/schema-cloud-config-v1.json b/cloudinit/config/schemas/schema-cloud-config-v1.json index ba9d2b65..b7124cb7 100644 --- a/cloudinit/config/schemas/schema-cloud-config-v1.json +++ b/cloudinit/config/schemas/schema-cloud-config-v1.json @@ -2352,6 +2352,49 @@ } } }, + "cc_wireguard": { + "type": "object", + "properties": { + "wireguard": { + "type": ["null", "object"], + "properties": { + "interfaces": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the interface. Typically wgx (example: wg0)" + }, + "config_path": { + "type": "string", + "description": "Path to configuration file of Wireguard interface" + }, + "content": { + "type": "string", + "description": "Wireguard interface configuration. Contains key, peer, ..." + } + }, + "additionalProperties": false + }, + "minItems": 1 + }, + "readinessprobe": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true, + "description": "List of shell commands to be executed as probes." + } + }, + "required": ["interfaces"], + "minProperties": 1, + "additionalProperties": false + } + } + }, "cc_write_files": { "type": "object", "properties": { @@ -2649,6 +2692,7 @@ { "$ref": "#/$defs/cc_update_etc_hosts"}, { "$ref": "#/$defs/cc_update_hostname"}, { "$ref": "#/$defs/cc_users_groups"}, + { "$ref": "#/$defs/cc_wireguard"}, { "$ref": "#/$defs/cc_write_files"}, { "$ref": "#/$defs/cc_yum_add_repo"}, { "$ref": "#/$defs/cc_zypper_add_repo"}, diff --git a/config/cloud.cfg.tmpl b/config/cloud.cfg.tmpl index 692a6268..a6096f47 100644 --- a/config/cloud.cfg.tmpl +++ b/config/cloud.cfg.tmpl @@ -111,6 +111,9 @@ cloud_init_modules: # The modules that run in the 'config' stage cloud_config_modules: +{% if variant in ["ubuntu"] %} + - wireguard +{% endif %} {% if variant in ["ubuntu", "unknown", "debian"] %} - snap {% endif %} diff --git a/doc/examples/cloud-config-wireguard.txt b/doc/examples/cloud-config-wireguard.txt new file mode 100644 index 00000000..11920f24 --- /dev/null +++ b/doc/examples/cloud-config-wireguard.txt @@ -0,0 +1,29 @@ +#cloud-config +# vim: syntax=yaml +# +# This is the configuration syntax that the wireguard module +# will know how to understand. +# +# +wireguard: + # All wireguard interfaces that should be created. Every interface will be named + # after `name` parameter and config will be written to a file under `config_path`. + # `content` parameter should be set with a valid Wireguard configuration. + interfaces: + - name: wg0 + config_path: /etc/wireguard/wg0.conf + content: | + [Interface] + PrivateKey = <private_key> + Address = <address> + [Peer] + PublicKey = <public_key> + Endpoint = <endpoint_ip>:<endpoint_ip_port> + AllowedIPs = <allowedip1>, <allowedip2>, ... + # The idea behind readiness probes is to ensure Wireguard connectivity before continuing + # the cloud-init process. This could be useful if you need access to specific services like + # an internal APT Repository Server (e.g Landscape) to install/update packages. + readinessprobe: + - 'systemctl restart service' + - 'curl https://webhook.endpoint/example' + - 'nc -zv apt-server-fqdn 443' diff --git a/doc/rtd/topics/modules.rst b/doc/rtd/topics/modules.rst index 29ac5eaf..8ffb984d 100644 --- a/doc/rtd/topics/modules.rst +++ b/doc/rtd/topics/modules.rst @@ -59,6 +59,7 @@ Module Reference .. automodule:: cloudinit.config.cc_update_etc_hosts .. automodule:: cloudinit.config.cc_update_hostname .. automodule:: cloudinit.config.cc_users_groups +.. automodule:: cloudinit.config.cc_wireguard .. automodule:: cloudinit.config.cc_write_files .. automodule:: cloudinit.config.cc_yum_add_repo .. automodule:: cloudinit.config.cc_zypper_add_repo diff --git a/tests/integration_tests/modules/test_ca_certs.py b/tests/integration_tests/modules/test_ca_certs.py index 9e095e4d..8d18fb76 100644 --- a/tests/integration_tests/modules/test_ca_certs.py +++ b/tests/integration_tests/modules/test_ca_certs.py @@ -125,6 +125,7 @@ class TestCaCerts: "ubuntu-advantage", "ubuntu-drivers", "update_etc_hosts", + "wireguard", "write-files", "write-files-deferred", } diff --git a/tests/integration_tests/modules/test_wireguard.py b/tests/integration_tests/modules/test_wireguard.py new file mode 100644 index 00000000..2e97c1fb --- /dev/null +++ b/tests/integration_tests/modules/test_wireguard.py @@ -0,0 +1,117 @@ +"""Integration test for the wireguard module.""" +import pytest +from pycloudlib.lxd.instance import LXDInstance + +from cloudinit.subp import subp +from tests.integration_tests.instances import IntegrationInstance + +ASCII_TEXT = "ASCII text" + +USER_DATA = """\ +#cloud-config +wireguard: + interfaces: + - name: wg0 + config_path: /etc/wireguard/wg0.conf + content: | + [Interface] + Address = 192.168.254.1/32 + ListenPort = 51820 + PrivateKey = iNlmgtGo6yiFhD9TuVnx/qJSp+C5Cwg4wwPmOJwlZXI= + + [Peer] + PublicKey = 6PewunPjxlUq/0xvbVxklN2p73YIytfjxpoIEohCukY= + AllowedIPs = 192.168.254.2/32 + - name: wg1 + config_path: /etc/wireguard/wg1.conf + content: | + [Interface] + PrivateKey = GGLU4+5vIcK9lGyfz4AJn9fR5/FN/6sf4Fd5chZ16Vc= + Address = 192.168.254.2/24 + + [Peer] + PublicKey = 2as8z3EDjSsfFEkvOQGVnJ1Hv+h1jRAh2BKJg+DHvGk= + Endpoint = 127.0.0.1:51820 + AllowedIPs = 0.0.0.0/0 + readinessprobe: + - ping -qc 5 192.168.254.1 2>&1 > /dev/null + - echo $? > /tmp/ping +""" + + +def load_wireguard_kernel_module_lxd(instance: LXDInstance): + subp( + "lxc config set {} linux.kernel_modules wireguard".format( + instance.name + ).split() + ) + + +@pytest.mark.ci +@pytest.mark.user_data(USER_DATA) +@pytest.mark.lxd_vm +@pytest.mark.gce +@pytest.mark.ec2 +@pytest.mark.azure +@pytest.mark.openstack +@pytest.mark.oci +@pytest.mark.ubuntu +class TestWireguard: + @pytest.mark.parametrize( + "cmd,expected_out", + ( + # check if wireguard module is loaded + ("lsmod | grep '^wireguard' | awk '{print $1}'", "wireguard"), + # test if file was written for wg0 + ( + "stat -c '%N' /etc/wireguard/wg0.conf", + r"'/etc/wireguard/wg0.conf'", + ), + # check permissions for wg0 + ("stat -c '%U %a' /etc/wireguard/wg0.conf", r"root 600"), + # ASCII check wg1 + ("file /etc/wireguard/wg1.conf", ASCII_TEXT), + # md5sum check wg1 + ( + "md5sum </etc/wireguard/wg1.conf", + "cff31c9879da0967313d3f561aed766b", + ), + # sha256sum check + ( + "sha256sum </etc/wireguard/wg1.conf", + "8443055d1442d051588beb03f7895b58" + "269196eb9916617969dc5220c1a90d54", + ), + # check if systemd started wg0 + ("systemctl is-active wg-quick@wg0", "active"), + # check if systemd started wg1 + ("systemctl is-active wg-quick@wg1", "active"), + # check readiness probe (ping wg0) + ("cat /tmp/ping", "0"), + ), + ) + def test_wireguard( + self, cmd, expected_out, class_client: IntegrationInstance + ): + result = class_client.execute(cmd) + assert result.ok + assert expected_out in result.stdout + + def test_wireguard_tools_installed( + self, class_client: IntegrationInstance + ): + """Test that 'wg version' succeeds, indicating installation.""" + assert class_client.execute("wg version").ok + + +@pytest.mark.ci +@pytest.mark.user_data(USER_DATA) +@pytest.mark.lxd_setup.with_args(load_wireguard_kernel_module_lxd) +@pytest.mark.lxd_container +@pytest.mark.ubuntu +class TestWireguardWithoutKmod: + def test_wireguard_tools_installed( + self, class_client: IntegrationInstance + ): + """Test that 'wg version' succeeds, indicating installation.""" + assert class_client.execute("wg version").ok diff --git a/tests/unittests/config/test_cc_wireguard.py b/tests/unittests/config/test_cc_wireguard.py new file mode 100644 index 00000000..59a5223b --- /dev/null +++ b/tests/unittests/config/test_cc_wireguard.py @@ -0,0 +1,266 @@ +# This file is part of cloud-init. See LICENSE file for license information. +import pytest + +from cloudinit import subp, util +from cloudinit.config import cc_wireguard +from cloudinit.config.schema import ( + SchemaValidationError, + get_schema, + validate_cloudconfig_schema, +) +from tests.unittests.helpers import CiTestCase, mock, skipUnlessJsonSchema + +NL = "\n" +# Module path used in mocks +MPATH = "cloudinit.config.cc_wireguard" +MIN_KERNEL_VERSION = (5, 6) + + +class FakeCloud(object): + def __init__(self, distro): + self.distro = distro + + +class TestWireGuard(CiTestCase): + + with_logs = True + allowed_subp = [CiTestCase.SUBP_SHELL_TRUE] + + def setUp(self): + super(TestWireGuard, self).setUp() + self.tmp = self.tmp_dir() + + def test_readiness_probe_schema_non_string_values(self): + """ValueError raised for any values expected as string type.""" + wg_readinessprobes = [1, ["not-a-valid-command"]] + errors = [ + "Expected a string for readinessprobe at 0. Found 1", + "Expected a string for readinessprobe at 1." + " Found ['not-a-valid-command']", + ] + with self.assertRaises(ValueError) as context_mgr: + cc_wireguard.readinessprobe_command_validation(wg_readinessprobes) + error_msg = str(context_mgr.exception) + for error in errors: + self.assertIn(error, error_msg) + + def test_suppl_schema_error_on_missing_keys(self): + """ValueError raised reporting any missing required keys""" + cfg = {} + match = ( + f"Invalid wireguard interface configuration:{NL}" + "Missing required wg:interfaces keys: config_path, content, name" + ) + with self.assertRaisesRegex(ValueError, match): + cc_wireguard.supplemental_schema_validation(cfg) + + def test_suppl_schema_error_on_non_string_values(self): + """ValueError raised for any values expected as string type.""" + cfg = {"name": 1, "config_path": 2, "content": 3} + errors = [ + "Expected a string for wg:interfaces:config_path. Found 2", + "Expected a string for wg:interfaces:content. Found 3", + "Expected a string for wg:interfaces:name. Found 1", + ] + with self.assertRaises(ValueError) as context_mgr: + cc_wireguard.supplemental_schema_validation(cfg) + error_msg = str(context_mgr.exception) + for error in errors: + self.assertIn(error, error_msg) + + def test_write_config_failed(self): + """Errors when writing config are raised.""" + wg_int = {"name": "wg0", "config_path": "/no/valid/path"} + + with self.assertRaises(RuntimeError) as context_mgr: + cc_wireguard.write_config(wg_int) + self.assertIn( + "Failure writing Wireguard configuration file /no/valid/path:\n", + str(context_mgr.exception), + ) + + @mock.patch("%s.subp.subp" % MPATH) + def test_readiness_probe_invalid_command(self, m_subp): + """Errors when executing readinessprobes are raised.""" + wg_readinessprobes = ["not-a-valid-command"] + + def fake_subp(cmd, capture=None, shell=None): + fail_cmds = ["not-a-valid-command"] + if cmd in fail_cmds and capture and shell: + raise subp.ProcessExecutionError( + "not-a-valid-command: command not found" + ) + + m_subp.side_effect = fake_subp + + with self.assertRaises(RuntimeError) as context_mgr: + cc_wireguard.readinessprobe(wg_readinessprobes) + self.assertIn( + "Failed running readinessprobe command:\n" + "not-a-valid-command: Unexpected error while" + " running command.\n" + "Command: -\nExit code: -\nReason: -\n" + "Stdout: not-a-valid-command: command not found\nStderr: -", + str(context_mgr.exception), + ) + + @mock.patch("%s.subp.subp" % MPATH) + def test_enable_wg_on_error(self, m_subp): + """Errors when enabling wireguard interfaces are raised.""" + wg_int = {"name": "wg0"} + distro = mock.MagicMock() # No errors raised + distro.manage_service.side_effect = subp.ProcessExecutionError( + "systemctl start wg-quik@wg0 failed: exit code 1" + ) + mycloud = FakeCloud(distro) + with self.assertRaises(RuntimeError) as context_mgr: + cc_wireguard.enable_wg(wg_int, mycloud) + self.assertEqual( + "Failed enabling/starting Wireguard interface(s):\n" + "Unexpected error while running command.\n" + "Command: -\nExit code: -\nReason: -\n" + "Stdout: systemctl start wg-quik@wg0 failed: exit code 1\n" + "Stderr: -", + str(context_mgr.exception), + ) + + @mock.patch("%s.subp.which" % MPATH) + def test_maybe_install_wg_packages_noop_when_wg_tools_present( + self, m_which + ): + """Do nothing if wireguard-tools already exists.""" + m_which.return_value = "/usr/bin/wg" # already installed + distro = mock.MagicMock() + distro.update_package_sources.side_effect = RuntimeError( + "Some apt error" + ) + cc_wireguard.maybe_install_wireguard_packages(cloud=FakeCloud(distro)) + + @mock.patch("%s.subp.which" % MPATH) + def test_maybe_install_wf_tools_raises_update_errors(self, m_which): + """maybe_install_wireguard_packages logs and raises + apt update errors.""" + m_which.return_value = None + distro = mock.MagicMock() + distro.update_package_sources.side_effect = RuntimeError( + "Some apt error" + ) + with self.assertRaises(RuntimeError) as context_manager: + cc_wireguard.maybe_install_wireguard_packages( + cloud=FakeCloud(distro) + ) + self.assertEqual("Some apt error", str(context_manager.exception)) + self.assertIn("Package update failed\nTraceback", self.logs.getvalue()) + + @mock.patch("%s.subp.which" % MPATH) + def test_maybe_install_wg_raises_install_errors(self, m_which): + """maybe_install_wireguard_packages logs and raises package + install errors.""" + m_which.return_value = None + distro = mock.MagicMock() + distro.update_package_sources.return_value = None + distro.install_packages.side_effect = RuntimeError( + "Some install error" + ) + with self.assertRaises(RuntimeError) as context_manager: + cc_wireguard.maybe_install_wireguard_packages( + cloud=FakeCloud(distro) + ) + self.assertEqual("Some install error", str(context_manager.exception)) + self.assertIn( + "Failed to install wireguard-tools\n", self.logs.getvalue() + ) + + @mock.patch("%s.subp.subp" % MPATH) + def test_load_wg_module_failed(self, m_subp): + """load_wireguard_kernel_module logs and raises + kernel modules loading error.""" + m_subp.side_effect = subp.ProcessExecutionError( + "Some kernel module load error" + ) + with self.assertRaises(subp.ProcessExecutionError) as context_manager: + cc_wireguard.load_wireguard_kernel_module() + self.assertEqual( + "Unexpected error while running command.\n" + "Command: -\nExit code: -\nReason: -\n" + "Stdout: Some kernel module load error\n" + "Stderr: -", + str(context_manager.exception), + ) + self.assertIn( + "WARNING: Could not load wireguard module:\n", self.logs.getvalue() + ) + + @mock.patch("%s.subp.which" % MPATH) + def test_maybe_install_wg_packages_happy_path(self, m_which): + """maybe_install_wireguard_packages installs wireguard-tools.""" + packages = ["wireguard-tools"] + + if util.kernel_version() < MIN_KERNEL_VERSION: + packages.append("wireguard") + + m_which.return_value = None + distro = mock.MagicMock() # No errors raised + cc_wireguard.maybe_install_wireguard_packages(cloud=FakeCloud(distro)) + distro.update_package_sources.assert_called_once_with() + distro.install_packages.assert_called_once_with(packages) + + @mock.patch("%s.maybe_install_wireguard_packages" % MPATH) + def test_handle_no_config(self, m_maybe_install_wireguard_packages): + """When no wireguard configuration is provided, nothing happens.""" + cfg = {} + cc_wireguard.handle( + "wg", cfg=cfg, cloud=None, log=self.logger, args=None + ) + self.assertIn( + "DEBUG: Skipping module named wg, no 'wireguard'" + " configuration found", + self.logs.getvalue(), + ) + self.assertEqual(m_maybe_install_wireguard_packages.call_count, 0) + + def test_readiness_probe_with_non_string_values(self): + """ValueError raised for any values expected as string type.""" + cfg = [1, 2] + errors = [ + "Expected a string for readinessprobe at 0. Found 1", + "Expected a string for readinessprobe at 1. Found 2", + ] + with self.assertRaises(ValueError) as context_manager: + cc_wireguard.readinessprobe_command_validation(cfg) + error_msg = str(context_manager.exception) + for error in errors: + self.assertIn(error, error_msg) + + +class TestWireguardSchema: + @pytest.mark.parametrize( + "config, error_msg", + [ + # Valid schemas + ( + { + "wireguard": { + "interfaces": [ + { + "name": "wg0", + "config_path": "/etc/wireguard/wg0.conf", + "content": "test", + } + ] + } + }, + None, + ), + ], + ) + @skipUnlessJsonSchema() + def test_schema_validation(self, config, error_msg): + if error_msg is not None: + with pytest.raises(SchemaValidationError, match=error_msg): + validate_cloudconfig_schema(config, get_schema(), strict=True) + else: + validate_cloudconfig_schema(config, get_schema(), strict=True) + + +# vi: ts=4 expandtab diff --git a/tests/unittests/config/test_schema.py b/tests/unittests/config/test_schema.py index 317a8687..b556c4b5 100644 --- a/tests/unittests/config/test_schema.py +++ b/tests/unittests/config/test_schema.py @@ -212,6 +212,7 @@ class TestGetSchema: {"$ref": "#/$defs/cc_update_etc_hosts"}, {"$ref": "#/$defs/cc_update_hostname"}, {"$ref": "#/$defs/cc_users_groups"}, + {"$ref": "#/$defs/cc_wireguard"}, {"$ref": "#/$defs/cc_write_files"}, {"$ref": "#/$defs/cc_yum_add_repo"}, {"$ref": "#/$defs/cc_zypper_add_repo"}, |