summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFabian Lichtenegger-Lukas <48928888+chifac08@users.noreply.github.com>2022-08-09 20:47:46 +0200
committerGitHub <noreply@github.com>2022-08-09 20:47:46 +0200
commitf20ea7d2a769d07d78ef54a403e5313a85ee9490 (patch)
treee4aef2ba6e8499f24956c5f0b32c6ea36c31dc11
parentdaa2b4266e19c045549d140ccf98340ef5fbd2d8 (diff)
downloadcloud-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.yml2
-rw-r--r--cloudinit/config/cc_wireguard.py295
-rw-r--r--cloudinit/config/schemas/schema-cloud-config-v1.json44
-rw-r--r--config/cloud.cfg.tmpl3
-rw-r--r--doc/examples/cloud-config-wireguard.txt29
-rw-r--r--doc/rtd/topics/modules.rst1
-rw-r--r--tests/integration_tests/modules/test_ca_certs.py1
-rw-r--r--tests/integration_tests/modules/test_wireguard.py117
-rw-r--r--tests/unittests/config/test_cc_wireguard.py266
-rw-r--r--tests/unittests/config/test_schema.py1
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"},