summaryrefslogtreecommitdiff
path: root/tests/integration_tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests/integration_tests')
-rw-r--r--tests/integration_tests/bugs/test_lp1886531.py27
-rw-r--r--tests/integration_tests/bugs/test_lp1897099.py31
-rw-r--r--tests/integration_tests/bugs/test_lp1900837.py28
-rw-r--r--tests/integration_tests/clouds.py215
-rw-r--r--tests/integration_tests/conftest.py182
-rw-r--r--tests/integration_tests/instances.py154
-rw-r--r--tests/integration_tests/integration_settings.py96
-rw-r--r--tests/integration_tests/modules/test_apt_configure_sources_list.py51
-rw-r--r--tests/integration_tests/modules/test_ntp_servers.py58
-rw-r--r--tests/integration_tests/modules/test_package_update_upgrade_install.py74
-rw-r--r--tests/integration_tests/modules/test_runcmd.py25
-rw-r--r--tests/integration_tests/modules/test_seed_random_data.py28
-rw-r--r--tests/integration_tests/modules/test_set_hostname.py47
-rw-r--r--tests/integration_tests/modules/test_set_password.py151
-rw-r--r--tests/integration_tests/modules/test_snap.py29
-rw-r--r--tests/integration_tests/modules/test_ssh_auth_key_fingerprints.py48
-rw-r--r--tests/integration_tests/modules/test_ssh_generate.py51
-rw-r--r--tests/integration_tests/modules/test_ssh_import_id.py29
-rw-r--r--tests/integration_tests/modules/test_ssh_keys_provided.py148
-rw-r--r--tests/integration_tests/modules/test_timezone.py25
-rw-r--r--tests/integration_tests/modules/test_users_groups.py83
-rw-r--r--tests/integration_tests/modules/test_write_files.py66
22 files changed, 1646 insertions, 0 deletions
diff --git a/tests/integration_tests/bugs/test_lp1886531.py b/tests/integration_tests/bugs/test_lp1886531.py
new file mode 100644
index 00000000..058ea8bb
--- /dev/null
+++ b/tests/integration_tests/bugs/test_lp1886531.py
@@ -0,0 +1,27 @@
+"""Integration test for LP: #1886531
+
+This test replicates the failure condition (absent /etc/fstab) on all releases
+by removing it in a bootcmd; this runs well before the part of cloud-init which
+causes the failure.
+
+The only required assertion is that cloud-init does not emit a WARNING to the
+log: this indicates that the fstab parsing code has not failed.
+
+https://bugs.launchpad.net/ubuntu/+source/cloud-init/+bug/1886531
+"""
+import pytest
+
+
+USER_DATA = """\
+#cloud-config
+bootcmd:
+- rm -f /etc/fstab
+"""
+
+
+class TestLp1886531:
+
+ @pytest.mark.user_data(USER_DATA)
+ def test_lp1886531(self, client):
+ log_content = client.read_from_file("/var/log/cloud-init.log")
+ assert "WARNING" not in log_content
diff --git a/tests/integration_tests/bugs/test_lp1897099.py b/tests/integration_tests/bugs/test_lp1897099.py
new file mode 100644
index 00000000..27c8927f
--- /dev/null
+++ b/tests/integration_tests/bugs/test_lp1897099.py
@@ -0,0 +1,31 @@
+""" Integration test for LP #187099
+
+Ensure that if fallocate fails during mkswap that we fall back to using dd
+
+https://bugs.launchpad.net/cloud-init/+bug/1897099
+"""
+
+import pytest
+
+
+USER_DATA = """\
+#cloud-config
+bootcmd:
+ - echo 'whoops' > /usr/bin/fallocate
+swap:
+ filename: /swap.img
+ size: 10000000
+ maxsize: 10000000
+"""
+
+
+@pytest.mark.sru_2020_11
+@pytest.mark.user_data(USER_DATA)
+@pytest.mark.no_container('Containers cannot configure swap')
+def test_fallocate_fallback(client):
+ log = client.read_from_file('/var/log/cloud-init.log')
+ assert '/swap.img' in client.execute('cat /proc/swaps')
+ assert '/swap.img' in client.execute('cat /etc/fstab')
+ assert 'fallocate swap creation failed, will attempt with dd' in log
+ assert "Running command ['dd', 'if=/dev/zero', 'of=/swap.img'" in log
+ assert 'SUCCESS: config-mounts ran successfully' in log
diff --git a/tests/integration_tests/bugs/test_lp1900837.py b/tests/integration_tests/bugs/test_lp1900837.py
new file mode 100644
index 00000000..3fe7d0d0
--- /dev/null
+++ b/tests/integration_tests/bugs/test_lp1900837.py
@@ -0,0 +1,28 @@
+"""Integration test for LP: #1900836.
+
+This test mirrors the reproducing steps from the reported bug: it changes the
+permissions on cloud-init.log to 600 and confirms that they remain 600 after a
+reboot.
+"""
+import pytest
+
+
+def _get_log_perms(client):
+ return client.execute("stat -c %a /var/log/cloud-init.log")
+
+
+@pytest.mark.sru_2020_11
+class TestLogPermissionsNotResetOnReboot:
+ def test_permissions_unchanged(self, client):
+ # Confirm that the current permissions aren't 600
+ assert "644" == _get_log_perms(client)
+
+ # Set permissions to 600 and confirm our assertion passes pre-reboot
+ client.execute("chmod 600 /var/log/cloud-init.log")
+ assert "600" == _get_log_perms(client)
+
+ # Reboot
+ client.instance.restart()
+
+ # Check that permissions are not reset on reboot
+ assert "600" == _get_log_perms(client)
diff --git a/tests/integration_tests/clouds.py b/tests/integration_tests/clouds.py
new file mode 100644
index 00000000..88ac4408
--- /dev/null
+++ b/tests/integration_tests/clouds.py
@@ -0,0 +1,215 @@
+# This file is part of cloud-init. See LICENSE file for license information.
+from abc import ABC, abstractmethod
+import logging
+
+from pycloudlib import EC2, GCE, Azure, OCI, LXDContainer, LXDVirtualMachine
+from pycloudlib.lxd.instance import LXDInstance
+
+import cloudinit
+from cloudinit.subp import subp
+from tests.integration_tests import integration_settings
+from tests.integration_tests.instances import (
+ IntegrationEc2Instance,
+ IntegrationGceInstance,
+ IntegrationAzureInstance, IntegrationInstance,
+ IntegrationOciInstance,
+ IntegrationLxdInstance,
+)
+
+try:
+ from typing import Optional
+except ImportError:
+ pass
+
+
+log = logging.getLogger('integration_testing')
+
+
+class IntegrationCloud(ABC):
+ datasource = None # type: Optional[str]
+ integration_instance_cls = IntegrationInstance
+
+ def __init__(self, settings=integration_settings):
+ self.settings = settings
+ self.cloud_instance = self._get_cloud_instance()
+ self.image_id = self._get_initial_image()
+
+ def emit_settings_to_log(self) -> None:
+ log.info(
+ "\n".join(
+ ["Settings:"]
+ + [
+ "{}={}".format(key, getattr(self.settings, key))
+ for key in sorted(self.settings.current_settings)
+ ]
+ )
+ )
+
+ @abstractmethod
+ def _get_cloud_instance(self):
+ raise NotImplementedError
+
+ def _get_initial_image(self):
+ image_id = self.settings.OS_IMAGE
+ try:
+ image_id = self.cloud_instance.released_image(
+ self.settings.OS_IMAGE)
+ except (ValueError, IndexError):
+ pass
+ return image_id
+
+ def _perform_launch(self, launch_kwargs):
+ pycloudlib_instance = self.cloud_instance.launch(**launch_kwargs)
+ pycloudlib_instance.wait(raise_on_cloudinit_failure=False)
+ return pycloudlib_instance
+
+ def launch(self, user_data=None, launch_kwargs=None,
+ settings=integration_settings):
+ if self.settings.EXISTING_INSTANCE_ID:
+ log.info(
+ 'Not launching instance due to EXISTING_INSTANCE_ID. '
+ 'Instance id: %s', self.settings.EXISTING_INSTANCE_ID)
+ self.instance = self.cloud_instance.get_instance(
+ self.settings.EXISTING_INSTANCE_ID
+ )
+ return
+ kwargs = {
+ 'image_id': self.image_id,
+ 'user_data': user_data,
+ 'wait': False,
+ }
+ if launch_kwargs:
+ kwargs.update(launch_kwargs)
+ log.info(
+ "Launching instance with launch_kwargs:\n{}".format(
+ "\n".join("{}={}".format(*item) for item in kwargs.items())
+ )
+ )
+
+ pycloudlib_instance = self._perform_launch(kwargs)
+
+ log.info('Launched instance: %s', pycloudlib_instance)
+ return self.get_instance(pycloudlib_instance, settings)
+
+ def get_instance(self, cloud_instance, settings=integration_settings):
+ return self.integration_instance_cls(self, cloud_instance, settings)
+
+ def destroy(self):
+ pass
+
+ def snapshot(self, instance):
+ return self.cloud_instance.snapshot(instance, clean=True)
+
+
+class Ec2Cloud(IntegrationCloud):
+ datasource = 'ec2'
+ integration_instance_cls = IntegrationEc2Instance
+
+ def _get_cloud_instance(self):
+ return EC2(tag='ec2-integration-test')
+
+
+class GceCloud(IntegrationCloud):
+ datasource = 'gce'
+ integration_instance_cls = IntegrationGceInstance
+
+ def _get_cloud_instance(self):
+ return GCE(
+ tag='gce-integration-test',
+ project=self.settings.GCE_PROJECT,
+ region=self.settings.GCE_REGION,
+ zone=self.settings.GCE_ZONE,
+ )
+
+
+class AzureCloud(IntegrationCloud):
+ datasource = 'azure'
+ integration_instance_cls = IntegrationAzureInstance
+
+ def _get_cloud_instance(self):
+ return Azure(tag='azure-integration-test')
+
+ def destroy(self):
+ self.cloud_instance.delete_resource_group()
+
+
+class OciCloud(IntegrationCloud):
+ datasource = 'oci'
+ integration_instance_cls = IntegrationOciInstance
+
+ def _get_cloud_instance(self):
+ return OCI(
+ tag='oci-integration-test',
+ compartment_id=self.settings.OCI_COMPARTMENT_ID
+ )
+
+
+class _LxdIntegrationCloud(IntegrationCloud):
+ integration_instance_cls = IntegrationLxdInstance
+
+ def _get_cloud_instance(self):
+ return self.pycloudlib_instance_cls(tag=self.instance_tag)
+
+ @staticmethod
+ def _get_or_set_profile_list(release):
+ return None
+
+ @staticmethod
+ def _mount_source(instance: LXDInstance):
+ target_path = '/usr/lib/python3/dist-packages/cloudinit'
+ format_variables = {
+ 'name': instance.name,
+ 'source_path': cloudinit.__path__[0],
+ 'container_path': target_path,
+ }
+ log.info(
+ 'Mounting source {source_path} directly onto LXD container/vm '
+ 'named {name} at {container_path}'.format(**format_variables))
+ command = (
+ 'lxc config device add {name} host-cloud-init disk '
+ 'source={source_path} '
+ 'path={container_path}'
+ ).format(**format_variables)
+ subp(command.split())
+
+ def _perform_launch(self, launch_kwargs):
+ launch_kwargs['inst_type'] = launch_kwargs.pop('instance_type', None)
+ launch_kwargs.pop('wait')
+ release = launch_kwargs.pop('image_id')
+
+ try:
+ profile_list = launch_kwargs['profile_list']
+ except KeyError:
+ profile_list = self._get_or_set_profile_list(release)
+
+ pycloudlib_instance = self.cloud_instance.init(
+ launch_kwargs.pop('name', None),
+ release,
+ profile_list=profile_list,
+ **launch_kwargs
+ )
+ if self.settings.CLOUD_INIT_SOURCE == 'IN_PLACE':
+ self._mount_source(pycloudlib_instance)
+ pycloudlib_instance.start(wait=False)
+ pycloudlib_instance.wait(raise_on_cloudinit_failure=False)
+ return pycloudlib_instance
+
+
+class LxdContainerCloud(_LxdIntegrationCloud):
+ datasource = 'lxd_container'
+ pycloudlib_instance_cls = LXDContainer
+ instance_tag = 'lxd-container-integration-test'
+
+
+class LxdVmCloud(_LxdIntegrationCloud):
+ datasource = 'lxd_vm'
+ pycloudlib_instance_cls = LXDVirtualMachine
+ instance_tag = 'lxd-vm-integration-test'
+ _profile_list = None
+
+ def _get_or_set_profile_list(self, release):
+ if self._profile_list:
+ return self._profile_list
+ self._profile_list = self.cloud_instance.build_necessary_profiles(
+ release)
+ return self._profile_list
diff --git a/tests/integration_tests/conftest.py b/tests/integration_tests/conftest.py
new file mode 100644
index 00000000..73b44bfc
--- /dev/null
+++ b/tests/integration_tests/conftest.py
@@ -0,0 +1,182 @@
+# This file is part of cloud-init. See LICENSE file for license information.
+import logging
+import os
+import pytest
+import sys
+from contextlib import contextmanager
+
+from tests.integration_tests import integration_settings
+from tests.integration_tests.clouds import (
+ Ec2Cloud,
+ GceCloud,
+ AzureCloud,
+ OciCloud,
+ LxdContainerCloud,
+ LxdVmCloud,
+)
+
+
+log = logging.getLogger('integration_testing')
+log.addHandler(logging.StreamHandler(sys.stdout))
+log.setLevel(logging.INFO)
+
+platforms = {
+ 'ec2': Ec2Cloud,
+ 'gce': GceCloud,
+ 'azure': AzureCloud,
+ 'oci': OciCloud,
+ 'lxd_container': LxdContainerCloud,
+ 'lxd_vm': LxdVmCloud,
+}
+
+
+def pytest_runtest_setup(item):
+ """Skip tests on unsupported clouds.
+
+ A test can take any number of marks to specify the platforms it can
+ run on. If a platform(s) is specified and we're not running on that
+ platform, then skip the test. If platform specific marks are not
+ specified, then we assume the test can be run anywhere.
+ """
+ all_platforms = platforms.keys()
+ test_marks = [mark.name for mark in item.iter_markers()]
+ supported_platforms = set(all_platforms).intersection(test_marks)
+ current_platform = integration_settings.PLATFORM
+ unsupported_message = 'Cannot run on platform {}'.format(current_platform)
+ if 'no_container' in test_marks:
+ if 'lxd_container' in test_marks:
+ raise Exception(
+ 'lxd_container and no_container marks simultaneously set '
+ 'on test'
+ )
+ if current_platform == 'lxd_container':
+ pytest.skip(unsupported_message)
+ if supported_platforms and current_platform not in supported_platforms:
+ pytest.skip(unsupported_message)
+
+
+# disable_subp_usage is defined at a higher level, but we don't
+# want it applied here
+@pytest.fixture()
+def disable_subp_usage(request):
+ pass
+
+
+@pytest.yield_fixture(scope='session')
+def session_cloud():
+ if integration_settings.PLATFORM not in platforms.keys():
+ raise ValueError(
+ "{} is an invalid PLATFORM specified in settings. "
+ "Must be one of {}".format(
+ integration_settings.PLATFORM, list(platforms.keys())
+ )
+ )
+
+ cloud = platforms[integration_settings.PLATFORM]()
+ cloud.emit_settings_to_log()
+ yield cloud
+ cloud.destroy()
+
+
+@pytest.fixture(scope='session', autouse=True)
+def setup_image(session_cloud):
+ """Setup the target environment with the correct version of cloud-init.
+
+ So we can launch instances / run tests with the correct image
+ """
+ client = None
+ log.info('Setting up environment for %s', session_cloud.datasource)
+ if integration_settings.CLOUD_INIT_SOURCE == 'NONE':
+ pass # that was easy
+ elif integration_settings.CLOUD_INIT_SOURCE == 'IN_PLACE':
+ if session_cloud.datasource not in ['lxd_container', 'lxd_vm']:
+ raise ValueError(
+ 'IN_PLACE as CLOUD_INIT_SOURCE only works for LXD')
+ # The mount needs to happen after the instance is created, so
+ # no further action needed here
+ elif integration_settings.CLOUD_INIT_SOURCE == 'PROPOSED':
+ client = session_cloud.launch()
+ client.install_proposed_image()
+ elif integration_settings.CLOUD_INIT_SOURCE.startswith('ppa:'):
+ client = session_cloud.launch()
+ client.install_ppa(integration_settings.CLOUD_INIT_SOURCE)
+ elif os.path.isfile(str(integration_settings.CLOUD_INIT_SOURCE)):
+ client = session_cloud.launch()
+ client.install_deb()
+ else:
+ raise ValueError(
+ 'Invalid value for CLOUD_INIT_SOURCE setting: {}'.format(
+ integration_settings.CLOUD_INIT_SOURCE))
+ if client:
+ # Even if we're keeping instances, we don't want to keep this
+ # one around as it was just for image creation
+ client.destroy()
+ log.info('Done with environment setup')
+
+
+@contextmanager
+def _client(request, fixture_utils, session_cloud):
+ """Fixture implementation for the client fixtures.
+
+ Launch the dynamic IntegrationClient instance using any provided
+ userdata, yield to the test, then cleanup
+ """
+ user_data = fixture_utils.closest_marker_first_arg_or(
+ request, 'user_data', None)
+ name = fixture_utils.closest_marker_first_arg_or(
+ request, 'instance_name', None
+ )
+ launch_kwargs = {}
+ if name is not None:
+ launch_kwargs = {"name": name}
+ with session_cloud.launch(
+ user_data=user_data, launch_kwargs=launch_kwargs
+ ) as instance:
+ yield instance
+
+
+@pytest.yield_fixture
+def client(request, fixture_utils, session_cloud):
+ """Provide a client that runs for every test."""
+ with _client(request, fixture_utils, session_cloud) as client:
+ yield client
+
+
+@pytest.yield_fixture(scope='module')
+def module_client(request, fixture_utils, session_cloud):
+ """Provide a client that runs once per module."""
+ with _client(request, fixture_utils, session_cloud) as client:
+ yield client
+
+
+@pytest.yield_fixture(scope='class')
+def class_client(request, fixture_utils, session_cloud):
+ """Provide a client that runs once per class."""
+ with _client(request, fixture_utils, session_cloud) as client:
+ yield client
+
+
+def pytest_assertrepr_compare(op, left, right):
+ """Custom integration test assertion explanations.
+
+ See
+ https://docs.pytest.org/en/stable/assert.html#defining-your-own-explanation-for-failed-assertions
+ for pytest's documentation.
+ """
+ if op == "not in" and isinstance(left, str) and isinstance(right, str):
+ # This stanza emits an improved assertion message if we're testing for
+ # the presence of a string within a cloud-init log: it will report only
+ # the specific lines containing the string (instead of the full log,
+ # the default behaviour).
+ potential_log_lines = right.splitlines()
+ first_line = potential_log_lines[0]
+ if "DEBUG" in first_line and "Cloud-init" in first_line:
+ # We are looking at a cloud-init log, so just pick out the relevant
+ # lines
+ found_lines = [
+ line for line in potential_log_lines if left in line
+ ]
+ return [
+ '"{}" not in cloud-init.log string; unexpectedly found on'
+ " these lines:".format(left)
+ ] + found_lines
diff --git a/tests/integration_tests/instances.py b/tests/integration_tests/instances.py
new file mode 100644
index 00000000..9b13288c
--- /dev/null
+++ b/tests/integration_tests/instances.py
@@ -0,0 +1,154 @@
+# This file is part of cloud-init. See LICENSE file for license information.
+import logging
+import os
+import uuid
+from tempfile import NamedTemporaryFile
+
+from pycloudlib.instance import BaseInstance
+from pycloudlib.result import Result
+
+from tests.integration_tests import integration_settings
+
+try:
+ from typing import TYPE_CHECKING
+ if TYPE_CHECKING:
+ from tests.integration_tests.clouds import IntegrationCloud
+except ImportError:
+ pass
+
+
+log = logging.getLogger('integration_testing')
+
+
+def _get_tmp_path():
+ tmp_filename = str(uuid.uuid4())
+ return '/var/tmp/{}.tmp'.format(tmp_filename)
+
+
+class IntegrationInstance:
+ use_sudo = True
+
+ def __init__(self, cloud: 'IntegrationCloud', instance: BaseInstance,
+ settings=integration_settings):
+ self.cloud = cloud
+ self.instance = instance
+ self.settings = settings
+
+ def destroy(self):
+ self.instance.delete()
+
+ def execute(self, command, *, use_sudo=None) -> Result:
+ if self.instance.username == 'root' and use_sudo is False:
+ raise Exception('Root user cannot run unprivileged')
+ if use_sudo is None:
+ use_sudo = self.use_sudo
+ return self.instance.execute(command, use_sudo=use_sudo)
+
+ def pull_file(self, remote_path, local_path):
+ # First copy to a temporary directory because of permissions issues
+ tmp_path = _get_tmp_path()
+ self.instance.execute('cp {} {}'.format(remote_path, tmp_path))
+ self.instance.pull_file(tmp_path, local_path)
+
+ def push_file(self, local_path, remote_path):
+ # First push to a temporary directory because of permissions issues
+ tmp_path = _get_tmp_path()
+ self.instance.push_file(local_path, tmp_path)
+ self.execute('mv {} {}'.format(tmp_path, remote_path))
+
+ def read_from_file(self, remote_path) -> str:
+ result = self.execute('cat {}'.format(remote_path))
+ if result.failed:
+ # TODO: Raise here whatever pycloudlib raises when it has
+ # a consistent error response
+ raise IOError(
+ 'Failed reading remote file via cat: {}\n'
+ 'Return code: {}\n'
+ 'Stderr: {}\n'
+ 'Stdout: {}'.format(
+ remote_path, result.return_code,
+ result.stderr, result.stdout)
+ )
+ return result.stdout
+
+ def write_to_file(self, remote_path, contents: str):
+ # Writes file locally and then pushes it rather
+ # than writing the file directly on the instance
+ with NamedTemporaryFile('w', delete=False) as tmp_file:
+ tmp_file.write(contents)
+
+ try:
+ self.push_file(tmp_file.name, remote_path)
+ finally:
+ os.unlink(tmp_file.name)
+
+ def snapshot(self):
+ return self.cloud.snapshot(self.instance)
+
+ def _install_new_cloud_init(self, remote_script):
+ self.execute(remote_script)
+ version = self.execute('cloud-init -v').split()[-1]
+ log.info('Installed cloud-init version: %s', version)
+ self.instance.clean()
+ image_id = self.snapshot()
+ log.info('Created new image: %s', image_id)
+ self.cloud.image_id = image_id
+
+ def install_proposed_image(self):
+ log.info('Installing proposed image')
+ remote_script = (
+ '{sudo} echo deb "http://archive.ubuntu.com/ubuntu '
+ '$(lsb_release -sc)-proposed main" | '
+ '{sudo} tee /etc/apt/sources.list.d/proposed.list\n'
+ '{sudo} apt-get update -q\n'
+ '{sudo} apt-get install -qy cloud-init'
+ ).format(sudo='sudo' if self.use_sudo else '')
+ self._install_new_cloud_init(remote_script)
+
+ def install_ppa(self, repo):
+ log.info('Installing PPA')
+ remote_script = (
+ '{sudo} add-apt-repository {repo} -y && '
+ '{sudo} apt-get update -q && '
+ '{sudo} apt-get install -qy cloud-init'
+ ).format(sudo='sudo' if self.use_sudo else '', repo=repo)
+ self._install_new_cloud_init(remote_script)
+
+ def install_deb(self):
+ log.info('Installing deb package')
+ deb_path = integration_settings.CLOUD_INIT_SOURCE
+ deb_name = os.path.basename(deb_path)
+ remote_path = '/var/tmp/{}'.format(deb_name)
+ self.push_file(
+ local_path=integration_settings.CLOUD_INIT_SOURCE,
+ remote_path=remote_path)
+ remote_script = '{sudo} dpkg -i {path}'.format(
+ sudo='sudo' if self.use_sudo else '', path=remote_path)
+ self._install_new_cloud_init(remote_script)
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ if not self.settings.KEEP_INSTANCE:
+ self.destroy()
+
+
+class IntegrationEc2Instance(IntegrationInstance):
+ pass
+
+
+class IntegrationGceInstance(IntegrationInstance):
+ pass
+
+
+class IntegrationAzureInstance(IntegrationInstance):
+ pass
+
+
+class IntegrationOciInstance(IntegrationInstance):
+ pass
+
+
+class IntegrationLxdInstance(IntegrationInstance):
+ use_sudo = False
diff --git a/tests/integration_tests/integration_settings.py b/tests/integration_tests/integration_settings.py
new file mode 100644
index 00000000..a0609f7e
--- /dev/null
+++ b/tests/integration_tests/integration_settings.py
@@ -0,0 +1,96 @@
+# This file is part of cloud-init. See LICENSE file for license information.
+import os
+
+##################################################################
+# LAUNCH SETTINGS
+##################################################################
+
+# Keep instance (mostly for debugging) when test is finished
+KEEP_INSTANCE = False
+
+# One of:
+# lxd_container
+# azure
+# ec2
+# gce
+# oci
+PLATFORM = 'lxd_container'
+
+# The cloud-specific instance type to run. E.g., a1.medium on AWS
+# If the pycloudlib instance provides a default, this can be left None
+INSTANCE_TYPE = None
+
+# Determines the base image to use or generate new images from.
+# Can be the name of the OS if running a stock image,
+# otherwise the id of the image being used if using a custom image
+OS_IMAGE = 'focal'
+
+# Populate if you want to use a pre-launched instance instead of
+# creating a new one. The exact contents will be platform dependent
+EXISTING_INSTANCE_ID = None
+
+##################################################################
+# IMAGE GENERATION SETTINGS
+##################################################################
+
+# Depending on where we are in the development / test / SRU cycle, we'll want
+# different methods of getting the source code to our SUT. Because of
+# this there are a number of different ways to initialize
+# the target environment.
+
+# Can be any of the following:
+# NONE
+# Don't modify the target environment at all. This will run
+# cloud-init with whatever code was baked into the image
+# IN_PLACE
+# LXD CONTAINER only. Mount the source code as-is directly into
+# the container to override the pre-existing cloudinit module. This
+# won't work for non-local LXD remotes and won't run any installation
+# code.
+# PROPOSED
+# Install from the Ubuntu proposed repo
+# <ppa repo>, e.g., ppa:cloud-init-dev/proposed
+# Install from a PPA. It MUST start with 'ppa:'
+# <file path>
+# A path to a valid package to be uploaded and installed
+CLOUD_INIT_SOURCE = 'NONE'
+
+##################################################################
+# GCE SPECIFIC SETTINGS
+##################################################################
+# Required for GCE
+GCE_PROJECT = None
+
+# You probably want to override these
+GCE_REGION = 'us-central1'
+GCE_ZONE = 'a'
+
+##################################################################
+# OCI SPECIFIC SETTINGS
+##################################################################
+# Compartment-id found at
+# https://console.us-phoenix-1.oraclecloud.com/a/identity/compartments
+# Required for Oracle
+OCI_COMPARTMENT_ID = None
+
+##################################################################
+# USER SETTINGS OVERRIDES
+##################################################################
+# Bring in any user-file defined settings
+try:
+ from tests.integration_tests.user_settings import * # noqa
+except ImportError:
+ pass
+
+##################################################################
+# ENVIRONMENT SETTINGS OVERRIDES
+##################################################################
+# Any of the settings in this file can be overridden with an
+# environment variable of the same name prepended with CLOUD_INIT_
+# E.g., CLOUD_INIT_PLATFORM
+# Perhaps a bit too hacky, but it works :)
+current_settings = [var for var in locals() if var.isupper()]
+for setting in current_settings:
+ globals()[setting] = os.getenv(
+ 'CLOUD_INIT_{}'.format(setting), globals()[setting]
+ )
diff --git a/tests/integration_tests/modules/test_apt_configure_sources_list.py b/tests/integration_tests/modules/test_apt_configure_sources_list.py
new file mode 100644
index 00000000..d2bcc61a
--- /dev/null
+++ b/tests/integration_tests/modules/test_apt_configure_sources_list.py
@@ -0,0 +1,51 @@
+"""Integration test for the apt module's ``sources_list`` functionality.
+
+This test specifies a ``sources_list`` and then checks that (a) the expected
+number of sources.list entries is present, and (b) that each expected line
+appears in the file.
+
+(This is ported from
+``tests/cloud_tests/testcases/modules/apt_configure_sources_list.yaml``.)"""
+import re
+
+import pytest
+
+
+USER_DATA = """\
+#cloud-config
+apt:
+ primary:
+ - arches: [default]
+ uri: http://archive.ubuntu.com/ubuntu
+ security:
+ - arches: [default]
+ uri: http://security.ubuntu.com/ubuntu
+ sources_list: |
+ deb $MIRROR $RELEASE main restricted
+ deb-src $MIRROR $RELEASE main restricted
+ deb $PRIMARY $RELEASE universe restricted
+ deb-src $PRIMARY $RELEASE universe restricted
+ deb $SECURITY $RELEASE-security multiverse
+ deb-src $SECURITY $RELEASE-security multiverse
+"""
+
+EXPECTED_REGEXES = [
+ r"deb http://archive.ubuntu.com/ubuntu [a-z].* main restricted",
+ r"deb-src http://archive.ubuntu.com/ubuntu [a-z].* main restricted",
+ r"deb http://archive.ubuntu.com/ubuntu [a-z].* universe restricted",
+ r"deb-src http://archive.ubuntu.com/ubuntu [a-z].* universe restricted",
+ r"deb http://security.ubuntu.com/ubuntu [a-z].*security multiverse",
+ r"deb-src http://security.ubuntu.com/ubuntu [a-z].*security multiverse",
+]
+
+
+@pytest.mark.ci
+class TestAptConfigureSourcesList:
+
+ @pytest.mark.user_data(USER_DATA)
+ def test_sources_list(self, client):
+ sources_list = client.read_from_file("/etc/apt/sources.list")
+ assert 6 == len(sources_list.rstrip().split('\n'))
+
+ for expected_re in EXPECTED_REGEXES:
+ assert re.search(expected_re, sources_list) is not None
diff --git a/tests/integration_tests/modules/test_ntp_servers.py b/tests/integration_tests/modules/test_ntp_servers.py
new file mode 100644
index 00000000..e72389c1
--- /dev/null
+++ b/tests/integration_tests/modules/test_ntp_servers.py
@@ -0,0 +1,58 @@
+"""Integration test for the ntp module's ``servers`` functionality with ntp.
+
+This test specifies the use of the `ntp` NTP client, and ensures that the given
+NTP servers are configured as expected.
+
+(This is ported from ``tests/cloud_tests/testcases/modules/ntp_servers.yaml``.)
+"""
+import re
+
+import yaml
+import pytest
+
+USER_DATA = """\
+#cloud-config
+ntp:
+ ntp_client: ntp
+ servers:
+ - 172.16.15.14
+ - 172.16.17.18
+"""
+
+EXPECTED_SERVERS = yaml.safe_load(USER_DATA)["ntp"]["servers"]
+
+
+@pytest.mark.ci
+@pytest.mark.user_data(USER_DATA)
+class TestNtpServers:
+
+ def test_ntp_installed(self, class_client):
+ """Test that `ntpd --version` succeeds, indicating installation."""
+ result = class_client.execute("ntpd --version")
+ assert 0 == result.return_code
+
+ def test_dist_config_file_is_empty(self, class_client):
+ """Test that the distributed config file is empty.
+
+ (This test is skipped on all currently supported Ubuntu releases, so
+ may not actually be needed any longer.)
+ """
+ if class_client.execute("test -e /etc/ntp.conf.dist").failed:
+ pytest.skip("/etc/ntp.conf.dist does not exist")
+ dist_file = class_client.read_from_file("/etc/ntp.conf.dist")
+ assert 0 == len(dist_file.strip().splitlines())
+
+ def test_ntp_entries(self, class_client):
+ ntp_conf = class_client.read_from_file("/etc/ntp.conf")
+ for expected_server in EXPECTED_SERVERS:
+ assert re.search(
+ r"^server {} iburst".format(expected_server),
+ ntp_conf,
+ re.MULTILINE
+ )
+
+ def test_ntpq_servers(self, class_client):
+ result = class_client.execute("ntpq -p -w -n")
+ assert result.ok
+ for expected_server in EXPECTED_SERVERS:
+ assert expected_server in result.stdout
diff --git a/tests/integration_tests/modules/test_package_update_upgrade_install.py b/tests/integration_tests/modules/test_package_update_upgrade_install.py
new file mode 100644
index 00000000..8a38ad84
--- /dev/null
+++ b/tests/integration_tests/modules/test_package_update_upgrade_install.py
@@ -0,0 +1,74 @@
+"""Integration test for the package update upgrade install module.
+
+This test module asserts that packages are upgraded/updated during boot
+with the ``package_update_upgrade_install`` module. We are also testing
+if we can install new packages during boot too.
+
+(This is ported from
+``tests/cloud_tests/testcases/modules/package_update_upgrade_install.yaml``.)
+
+NOTE: the testcase for this looks for the command in history.log as
+ /usr/bin/apt-get..., which is not how it always appears. it should
+ instead look for just apt-get...
+"""
+
+import re
+import pytest
+
+
+USER_DATA = """\
+#cloud-config
+packages:
+ - sl
+ - tree
+package_update: true
+package_upgrade: true
+"""
+
+
+@pytest.mark.user_data(USER_DATA)
+class TestPackageUpdateUpgradeInstall:
+
+ def assert_package_installed(self, pkg_out, name, version=None):
+ """Check dpkg-query --show output for matching package name.
+
+ @param name: package base name
+ @param version: string representing a package version or part of a
+ version.
+ """
+ pkg_match = re.search(
+ "^%s\t(?P<version>.*)$" % name, pkg_out, re.MULTILINE)
+ if pkg_match:
+ installed_version = pkg_match.group("version")
+ if not version:
+ return # Success
+ if installed_version.startswith(version):
+ return # Success
+ raise AssertionError(
+ "Expected package version %s-%s not found. Found %s" %
+ name, version, installed_version)
+ raise AssertionError("Package not installed: %s" % name)
+
+ def test_new_packages_are_installed(self, class_client):
+ pkg_out = class_client.execute("dpkg-query --show")
+
+ self.assert_package_installed(pkg_out, "sl")
+ self.assert_package_installed(pkg_out, "tree")
+
+ def test_packages_were_updated(self, class_client):
+ out = class_client.execute(
+ "grep ^Commandline: /var/log/apt/history.log")
+ assert (
+ "Commandline: /usr/bin/apt-get --option=Dpkg::Options"
+ "::=--force-confold --option=Dpkg::options::=--force-unsafe-io "
+ "--assume-yes --quiet install sl tree") in out
+
+ def test_packages_were_upgraded(self, class_client):
+ """Test cloud-init-output for install & upgrade stuff."""
+ out = class_client.read_from_file("/var/log/cloud-init-output.log")
+ assert "Setting up tree (" in out
+ assert "Setting up sl (" in out
+ assert "Reading package lists..." in out
+ assert "Building dependency tree..." in out
+ assert "Reading state information..." in out
+ assert "Calculating upgrade..." in out
diff --git a/tests/integration_tests/modules/test_runcmd.py b/tests/integration_tests/modules/test_runcmd.py
new file mode 100644
index 00000000..50d1851e
--- /dev/null
+++ b/tests/integration_tests/modules/test_runcmd.py
@@ -0,0 +1,25 @@
+"""Integration test for the runcmd module.
+
+This test specifies a command to be executed by the ``runcmd`` module
+and then checks if that command was executed during boot.
+
+(This is ported from
+``tests/cloud_tests/testcases/modules/runcmd.yaml``.)"""
+
+import pytest
+
+
+USER_DATA = """\
+#cloud-config
+runcmd:
+ - echo cloud-init run cmd test > /var/tmp/run_cmd
+"""
+
+
+@pytest.mark.ci
+class TestRuncmd:
+
+ @pytest.mark.user_data(USER_DATA)
+ def test_runcmd(self, client):
+ runcmd_output = client.read_from_file("/var/tmp/run_cmd")
+ assert runcmd_output.strip() == "cloud-init run cmd test"
diff --git a/tests/integration_tests/modules/test_seed_random_data.py b/tests/integration_tests/modules/test_seed_random_data.py
new file mode 100644
index 00000000..b365fa98
--- /dev/null
+++ b/tests/integration_tests/modules/test_seed_random_data.py
@@ -0,0 +1,28 @@
+"""Integration test for the random seed module.
+
+This test specifies a command to be executed by the ``seed_random`` module, by
+providing a different data to be used as seed data. We will then check
+if that seed data was actually used.
+
+(This is ported from
+``tests/cloud_tests/testcases/modules/seed_random_data.yaml``.)"""
+
+import pytest
+
+
+USER_DATA = """\
+#cloud-config
+random_seed:
+ data: 'MYUb34023nD:LFDK10913jk;dfnk:Df'
+ encoding: raw
+ file: /root/seed
+"""
+
+
+@pytest.mark.ci
+class TestSeedRandomData:
+
+ @pytest.mark.user_data(USER_DATA)
+ def test_seed_random_data(self, client):
+ seed_output = client.read_from_file("/root/seed")
+ assert seed_output.strip() == "MYUb34023nD:LFDK10913jk;dfnk:Df"
diff --git a/tests/integration_tests/modules/test_set_hostname.py b/tests/integration_tests/modules/test_set_hostname.py
new file mode 100644
index 00000000..2bfa403d
--- /dev/null
+++ b/tests/integration_tests/modules/test_set_hostname.py
@@ -0,0 +1,47 @@
+"""Integration test for the set_hostname module.
+
+This module specify two tests: One updates only the hostname and the other
+one updates the hostname and fqdn of the system. For both of these tests
+we will check is the changes requested by the user data are being respected
+after the system is boot.
+
+(This is ported from
+``tests/cloud_tests/testcases/modules/set_hostname.yaml`` and
+``tests/cloud_tests/testcases/modules/set_hostname_fqdn.yaml``.)"""
+
+import pytest
+
+
+USER_DATA_HOSTNAME = """\
+#cloud-config
+hostname: cloudinit2
+"""
+
+USER_DATA_FQDN = """\
+#cloud-config
+manage_etc_hosts: true
+hostname: cloudinit1
+fqdn: cloudinit2.i9n.cloud-init.io
+"""
+
+
+@pytest.mark.ci
+class TestHostname:
+
+ @pytest.mark.user_data(USER_DATA_HOSTNAME)
+ def test_hostname(self, client):
+ hostname_output = client.execute("hostname")
+ assert "cloudinit2" in hostname_output.strip()
+
+ @pytest.mark.user_data(USER_DATA_FQDN)
+ def test_hostname_and_fqdn(self, client):
+ hostname_output = client.execute("hostname")
+ assert "cloudinit1" in hostname_output.strip()
+
+ fqdn_output = client.execute("hostname --fqdn")
+ assert "cloudinit2.i9n.cloud-init.io" in fqdn_output.strip()
+
+ host_output = client.execute("grep ^127 /etc/hosts")
+ assert '127.0.1.1 {} {}'.format(
+ fqdn_output, hostname_output) in host_output
+ assert '127.0.0.1 localhost' in host_output
diff --git a/tests/integration_tests/modules/test_set_password.py b/tests/integration_tests/modules/test_set_password.py
new file mode 100644
index 00000000..b13f76fb
--- /dev/null
+++ b/tests/integration_tests/modules/test_set_password.py
@@ -0,0 +1,151 @@
+"""Integration test for the set_password module.
+
+This test specifies a combination of user/password pairs, and ensures that the
+system has the correct passwords set.
+
+There are two tests run here: one tests chpasswd's list being a YAML list, the
+other tests chpasswd's list being a string. Both expect the same results, so
+they use a mixin to share their test definitions, because we can (of course)
+only specify one user-data per instance.
+"""
+import crypt
+
+import pytest
+import yaml
+
+
+COMMON_USER_DATA = """\
+#cloud-config
+ssh_pwauth: yes
+users:
+ - default
+ - name: tom
+ # md5 gotomgo
+ passwd: "$1$S7$tT1BEDIYrczeryDQJfdPe0"
+ lock_passwd: false
+ - name: dick
+ # md5 gocubsgo
+ passwd: "$1$ssisyfpf$YqvuJLfrrW6Cg/l53Pi1n1"
+ lock_passwd: false
+ - name: harry
+ # sha512 goharrygo
+ passwd: "$6$LF$9Z2p6rWK6TNC1DC6393ec0As.18KRAvKDbfsGJEdWN3sRQRwpdfoh37EQ3y\
+Uh69tP4GSrGW5XKHxMLiKowJgm/"
+ lock_passwd: false
+ - name: jane
+ # sha256 gojanego
+ passwd: "$5$iW$XsxmWCdpwIW8Yhv.Jn/R3uk6A4UaicfW5Xp7C9p9pg."
+ lock_passwd: false
+ - name: "mikey"
+ lock_passwd: false
+"""
+
+LIST_USER_DATA = COMMON_USER_DATA + """
+chpasswd:
+ list:
+ - tom:mypassword123!
+ - dick:RANDOM
+ - harry:RANDOM
+ - mikey:$5$xZ$B2YGGEx2AOf4PeW48KC6.QyT1W2B4rZ9Qbltudtha89
+"""
+
+STRING_USER_DATA = COMMON_USER_DATA + """
+chpasswd:
+ list: |
+ tom:mypassword123!
+ dick:RANDOM
+ harry:RANDOM
+ mikey:$5$xZ$B2YGGEx2AOf4PeW48KC6.QyT1W2B4rZ9Qbltudtha89
+"""
+
+USERS_DICTS = yaml.safe_load(COMMON_USER_DATA)["users"]
+USERS_PASSWD_VALUES = {
+ user_dict["name"]: user_dict["passwd"]
+ for user_dict in USERS_DICTS
+ if "name" in user_dict and "passwd" in user_dict
+}
+
+
+class Mixin:
+ """Shared test definitions."""
+
+ def _fetch_and_parse_etc_shadow(self, class_client):
+ """Fetch /etc/shadow and parse it into Python data structures
+
+ Returns: ({user: password}, [duplicate, users])
+ """
+ shadow_content = class_client.read_from_file("/etc/shadow")
+ users = {}
+ dupes = []
+ for line in shadow_content.splitlines():
+ user, encpw = line.split(":")[0:2]
+ if user in users:
+ dupes.append(user)
+ users[user] = encpw
+ return users, dupes
+
+ def test_no_duplicate_users_in_shadow(self, class_client):
+ """Confirm that set_passwords has not added duplicate shadow entries"""
+ _, dupes = self._fetch_and_parse_etc_shadow(class_client)
+
+ assert [] == dupes
+
+ def test_password_in_users_dict_set_correctly(self, class_client):
+ """Test that the password specified in the users dict is set."""
+ shadow_users, _ = self._fetch_and_parse_etc_shadow(class_client)
+ assert USERS_PASSWD_VALUES["jane"] == shadow_users["jane"]
+
+ def test_password_in_chpasswd_list_set_correctly(self, class_client):
+ """Test that a chpasswd password overrides one in the users dict."""
+ shadow_users, _ = self._fetch_and_parse_etc_shadow(class_client)
+ mikey_hash = "$5$xZ$B2YGGEx2AOf4PeW48KC6.QyT1W2B4rZ9Qbltudtha89"
+ assert mikey_hash == shadow_users["mikey"]
+
+ def test_random_passwords_set_correctly(self, class_client):
+ """Test that RANDOM chpasswd entries replace users dict passwords."""
+ shadow_users, _ = self._fetch_and_parse_etc_shadow(class_client)
+
+ # These should have been changed
+ assert shadow_users["harry"] != USERS_PASSWD_VALUES["harry"]
+ assert shadow_users["dick"] != USERS_PASSWD_VALUES["dick"]
+
+ # To random passwords
+ assert shadow_users["harry"].startswith("$")
+ assert shadow_users["dick"].startswith("$")
+
+ # Which are not the same
+ assert shadow_users["harry"] != shadow_users["dick"]
+
+ def test_explicit_password_set_correctly(self, class_client):
+ """Test that an explicitly-specified password is set correctly."""
+ shadow_users, _ = self._fetch_and_parse_etc_shadow(class_client)
+
+ fmt_and_salt = shadow_users["tom"].rsplit("$", 1)[0]
+ expected_value = crypt.crypt("mypassword123!", fmt_and_salt)
+
+ assert expected_value == shadow_users["tom"]
+
+ def test_shadow_expected_users(self, class_client):
+ """Test that the right set of users is in /etc/shadow."""
+ shadow = class_client.read_from_file("/etc/shadow")
+ for user_dict in USERS_DICTS:
+ if "name" in user_dict:
+ assert "{}:".format(user_dict["name"]) in shadow
+
+ def test_sshd_config(self, class_client):
+ """Test that SSH password auth is enabled."""
+ sshd_config = class_client.read_from_file("/etc/ssh/sshd_config")
+ # We look for the exact line match, to avoid a commented line matching
+ assert "PasswordAuthentication yes" in sshd_config.splitlines()
+
+
+@pytest.mark.ci
+@pytest.mark.user_data(LIST_USER_DATA)
+class TestPasswordList(Mixin):
+ """Launch an instance with LIST_USER_DATA, ensure Mixin tests pass."""
+
+
+@pytest.mark.ci
+@pytest.mark.user_data(STRING_USER_DATA)
+class TestPasswordListString(Mixin):
+ """Launch an instance with STRING_USER_DATA, ensure Mixin tests pass."""
diff --git a/tests/integration_tests/modules/test_snap.py b/tests/integration_tests/modules/test_snap.py
new file mode 100644
index 00000000..b626f6b0
--- /dev/null
+++ b/tests/integration_tests/modules/test_snap.py
@@ -0,0 +1,29 @@
+"""Integration test for the snap module.
+
+This test specifies a command to be executed by the ``snap`` module
+and then checks that if that command was executed during boot.
+
+(This is ported from
+``tests/cloud_tests/testcases/modules/runcmd.yaml``.)"""
+
+import pytest
+
+
+USER_DATA = """\
+#cloud-config
+package_update: true
+snap:
+ squashfuse_in_container: true
+ commands:
+ - snap install hello-world
+"""
+
+
+@pytest.mark.ci
+class TestSnap:
+
+ @pytest.mark.user_data(USER_DATA)
+ def test_snap(self, client):
+ snap_output = client.execute("snap list")
+ assert "core " in snap_output
+ assert "hello-world " in snap_output
diff --git a/tests/integration_tests/modules/test_ssh_auth_key_fingerprints.py b/tests/integration_tests/modules/test_ssh_auth_key_fingerprints.py
new file mode 100644
index 00000000..b9b0d85e
--- /dev/null
+++ b/tests/integration_tests/modules/test_ssh_auth_key_fingerprints.py
@@ -0,0 +1,48 @@
+"""Integration test for the ssh_authkey_fingerprints module.
+
+This modules specifies two tests regarding the ``ssh_authkey_fingerprints``
+module. The first one verifies that we can disable the module behavior while
+the second one verifies if the module is working as expected if enabled.
+
+(This is ported from
+``tests/cloud_tests/testcases/modules/ssh_auth_key_fingerprints_disable.yaml``,
+``tests/cloud_tests/testcases/modules/ssh_auth_key_fingerprints_enable.yaml``.
+)"""
+import re
+
+import pytest
+
+
+USER_DATA_SSH_AUTHKEY_DISABLE = """\
+#cloud-config
+no_ssh_fingerprints: true
+"""
+
+USER_DATA_SSH_AUTHKEY_ENABLE="""\
+#cloud-config
+ssh_genkeytypes:
+ - ecdsa
+ - ed25519
+ssh_authorized_keys:
+ - ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDXW9Gg5H7ehjdSc6qDzwNtgCy94XYHhEYlXZMO2+FJrH3wfHGiMfCwOHxcOMt2QiXItULthdeQWS9QjBSSjVRXf6731igFrqPFyS9qBlOQ5D29C4HBXFnQggGVpBNJ82IRJv7szbbe/vpgLBP4kttUza9Dr4e1YM1ln4PRnjfXea6T0m+m1ixNb5432pTXlqYOnNOxSIm1gHgMLxPuDrJvQERDKrSiKSjIdyC9Jd8t2e1tkNLY0stmckVRbhShmcJvlyofHWbc2Ca1mmtP7MlS1VQnfLkvU1IrFwkmaQmaggX6WR6coRJ6XFXdWcq/AI2K6GjSnl1dnnCxE8VCEXBlXgFzad+PMSG4yiL5j8Oo1ZVpkTdgBnw4okGqTYCXyZg6X00As9IBNQfZMFlQXlIo4FiWgj3CO5QHQOyOX6FuEumaU13GnERrSSdp9tCs1Qm3/DG2RSCQBWTfcgMcStIvKqvJ3IjFn0vGLvI3Ampnq9q1SHwmmzAPSdzcMA76HyMUA5VWaBvWHlUxzIM6unxZASnwvuCzpywSEB5J2OF+p6H+cStJwQ32XwmOG8pLp1srlVWpqZI58Du/lzrkPqONphoZx0LDV86w7RUz1ksDzAdcm0tvmNRFMN1a0frDs506oA3aWK0oDk4Nmvk8sXGTYYw3iQSkOvDUUlIsqdaO+w==
+""" # noqa
+
+
+@pytest.mark.ci
+class TestSshAuthkeyFingerprints:
+
+ @pytest.mark.user_data(USER_DATA_SSH_AUTHKEY_DISABLE)
+ def test_ssh_authkey_fingerprints_disable(self, client):
+ cloudinit_output = client.read_from_file("/var/log/cloud-init.log")
+ assert (
+ "Skipping module named ssh-authkey-fingerprints, "
+ "logging of SSH fingerprints disabled") in cloudinit_output
+
+ @pytest.mark.user_data(USER_DATA_SSH_AUTHKEY_ENABLE)
+ def test_ssh_authkey_fingerprints_enable(self, client):
+ syslog_output = client.read_from_file("/var/log/syslog")
+
+ assert re.search(r'256 SHA256:.*(ECDSA)', syslog_output) is not None
+ assert re.search(r'256 SHA256:.*(ED25519)', syslog_output) is not None
+ assert re.search(r'1024 SHA256:.*(DSA)', syslog_output) is None
+ assert re.search(r'2048 SHA256:.*(RSA)', syslog_output) is None
diff --git a/tests/integration_tests/modules/test_ssh_generate.py b/tests/integration_tests/modules/test_ssh_generate.py
new file mode 100644
index 00000000..60c36982
--- /dev/null
+++ b/tests/integration_tests/modules/test_ssh_generate.py
@@ -0,0 +1,51 @@
+"""Integration test for the ssh module.
+
+This module has two tests to verify if we can create ssh keys
+through the ``ssh`` module. The first test asserts that some keys
+were not created while the second one verifies if the expected
+keys were created.
+
+(This is ported from
+``tests/cloud_tests/testcases/modules/ssh_keys_generate.yaml``.)"""
+
+import pytest
+
+
+USER_DATA = """\
+#cloud-config
+ssh_genkeytypes:
+ - ecdsa
+ - ed25519
+authkey_hash: sha512
+"""
+
+
+@pytest.mark.ci
+@pytest.mark.user_data(USER_DATA)
+class TestSshKeysGenerate:
+
+ @pytest.mark.parametrize(
+ "ssh_key_path", (
+ "/etc/ssh/ssh_host_dsa_key.pub",
+ "/etc/ssh/ssh_host_dsa_key",
+ "/etc/ssh/ssh_host_rsa_key.pub",
+ "/etc/ssh/ssh_host_rsa_key",
+ )
+ )
+ def test_ssh_keys_not_generated(self, ssh_key_path, class_client):
+ out = class_client.execute(
+ "test -e {}".format(ssh_key_path)
+ )
+ assert out.failed
+
+ @pytest.mark.parametrize(
+ "ssh_key_path", (
+ "/etc/ssh/ssh_host_ecdsa_key.pub",
+ "/etc/ssh/ssh_host_ecdsa_key",
+ "/etc/ssh/ssh_host_ed25519_key.pub",
+ "/etc/ssh/ssh_host_ed25519_key",
+ )
+ )
+ def test_ssh_keys_generated(self, ssh_key_path, class_client):
+ out = class_client.read_from_file(ssh_key_path)
+ assert "" != out.strip()
diff --git a/tests/integration_tests/modules/test_ssh_import_id.py b/tests/integration_tests/modules/test_ssh_import_id.py
new file mode 100644
index 00000000..45d37d6c
--- /dev/null
+++ b/tests/integration_tests/modules/test_ssh_import_id.py
@@ -0,0 +1,29 @@
+"""Integration test for the ssh_import_id module.
+
+This test specifies ssh keys to be imported by the ``ssh_import_id`` module
+and then checks that if the ssh keys were successfully imported.
+
+(This is ported from
+``tests/cloud_tests/testcases/modules/ssh_import_id.yaml``.)"""
+
+import pytest
+
+
+USER_DATA = """\
+#cloud-config
+ssh_import_id:
+ - gh:powersj
+ - lp:smoser
+"""
+
+
+@pytest.mark.ci
+class TestSshImportId:
+
+ @pytest.mark.user_data(USER_DATA)
+ def test_ssh_import_id(self, client):
+ ssh_output = client.read_from_file(
+ "/home/ubuntu/.ssh/authorized_keys")
+
+ assert '# ssh-import-id gh:powersj' in ssh_output
+ assert '# ssh-import-id lp:smoser' in ssh_output
diff --git a/tests/integration_tests/modules/test_ssh_keys_provided.py b/tests/integration_tests/modules/test_ssh_keys_provided.py
new file mode 100644
index 00000000..27d193c1
--- /dev/null
+++ b/tests/integration_tests/modules/test_ssh_keys_provided.py
@@ -0,0 +1,148 @@
+"""Integration test for the ssh module.
+
+This test specifies keys to be provided to the system through the ``ssh``
+module and then checks that if those keys were successfully added to the
+system.
+
+(This is ported from
+``tests/cloud_tests/testcases/modules/ssh_keys_provided.yaml''``.)"""
+
+import pytest
+
+
+USER_DATA = """\
+#cloud-config
+disable_root: false
+ssh_authorized_keys:
+ - ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDXW9Gg5H7ehjdSc6qDzwNtgCy94XYHhEYlXZMO2+FJrH3wfHGiMfCwOHxcOMt2QiXItULthdeQWS9QjBSSjVRXf6731igFrqPFyS9qBlOQ5D29C4HBXFnQggGVpBNJ82IRJv7szbbe/vpgLBP4kttUza9Dr4e1YM1ln4PRnjfXea6T0m+m1ixNb5432pTXlqYOnNOxSIm1gHgMLxPuDrJvQERDKrSiKSjIdyC9Jd8t2e1tkNLY0stmckVRbhShmcJvlyofHWbc2Ca1mmtP7MlS1VQnfLkvU1IrFwkmaQmaggX6WR6coRJ6XFXdWcq/AI2K6GjSnl1dnnCxE8VCEXBlXgFzad+PMSG4yiL5j8Oo1ZVpkTdgBnw4okGqTYCXyZg6X00As9IBNQfZMFlQXlIo4FiWgj3CO5QHQOyOX6FuEumaU13GnERrSSdp9tCs1Qm3/DG2RSCQBWTfcgMcStIvKqvJ3IjFn0vGLvI3Ampnq9q1SHwmmzAPSdzcMA76HyMUA5VWaBvWHlUxzIM6unxZASnwvuCzpywSEB5J2OF+p6H+cStJwQ32XwmOG8pLp1srlVWpqZI58Du/lzrkPqONphoZx0LDV86w7RUz1ksDzAdcm0tvmNRFMN1a0frDs506oA3aWK0oDk4Nmvk8sXGTYYw3iQSkOvDUUlIsqdaO+w==
+ssh_keys:
+ rsa_private: |
+ -----BEGIN RSA PRIVATE KEY-----
+ MIIEowIBAAKCAQEAtPx6PqN3iSEsnTtibyIEy52Tra8T5fn0ryXyg46Di2NBwdnj
+ o8trNv9jenfV/UhmePl58lXjT43wV8OCMl6KsYXyBdegM35NNtono4I4mLLKFMR9
+ 9TOtDn6iYcaNenVhF3ZCj9Z2nNOlTrdc0uchHqKMrxLjCRCUrL91Uf+xioTF901Y
+ RM+ZqC5lT92yAL76F4qPF+Lq1QtUfNfUIwwvOp5ccDZLPxij0YvyBzubYye9hJHu
+ yjbJv78R4JHV+L2WhzSoX3W/6WrxVzeXqFGqH894ccOaC/7tnqSP6V8lIQ6fE2+c
+ DurJcpM3CJRgkndGHjtU55Y71YkcdLksSMvezQIDAQABAoIBAQCrU4IJP8dNeaj5
+ IpkY6NQvR/jfZqfogYi+MKb1IHin/4rlDfUvPcY9pt8ttLlObjYK+OcWn3Vx/sRw
+ 4DOkqNiUGl80Zp1RgZNohHUXlJMtAbrIlAVEk+mTmg7vjfyp2unRQvLZpMRdywBm
+ lq95OrCghnG03aUsFJUZPpi5ydnwbA12ma+KHkG0EzaVlhA7X9N6z0K6U+zue2gl
+ goMLt/MH0rsYawkHrwiwXaIFQeyV4MJP0vmrZLbFk1bycu9X/xPtTYotWyWo4eKA
+ cb05uu04qwexkKHDM0KXtT0JecbTo2rOefFo8Uuab6uJY+fEHNocZ+v1vLA4aOxJ
+ ovp1JuXlAoGBAOWYNgKrlTfy5n0sKsNk+1RuL2jHJZJ3HMd0EIt7/fFQN3Fi08Hu
+ jtntqD30Wj+DJK8b8Lrt66FruxyEJm5VhVmwkukrLR5ige2f6ftZnoFCmdyy+0zP
+ dnPZSUe2H5ZPHa+qthJgHLn+al2P04tGh+1fGHC2PbP+e0Co+/ZRIOxrAoGBAMnN
+ IEen9/FRsqvnDd36I8XnJGskVRTZNjylxBmbKcuMWm+gNhOI7gsCAcqzD4BYZjjW
+ pLhrt/u9p+l4MOJy6OUUdM/okg12SnJEGryysOcVBcXyrvOfklWnANG4EAH5jt1N
+ ftTb1XTxzvWVuR/WJK0B5MZNYM71cumBdUDtPi+nAoGAYmoIXMSnxb+8xNL10aOr
+ h9ljQQp8NHgSQfyiSufvRk0YNuYh1vMnEIsqnsPrG2Zfhx/25GmvoxXGssaCorDN
+ 5FAn6QK06F1ZTD5L0Y3sv4OI6G1gAuC66ZWuL6sFhyyKkQ4f1WiVZ7SCa3CHQSAO
+ i9VDaKz1bf4bXvAQcNj9v9kCgYACSOZCqW4vN0OUmqsXhkt9ZB6Pb/veno70pNPR
+ jmYsvcwQU3oJQpWfXkhy6RAV3epaXmPDCsUsfns2M3wqNC7a2R5xdCqjKGGzZX4A
+ AO3rz9se4J6Gd5oKijeCKFlWDGNHsibrdgm2pz42nZlY+O21X74dWKbt8O16I1MW
+ hxkbJQKBgAXfuen/srVkJgPuqywUYag90VWCpHsuxdn+fZJa50SyZADr+RbiDfH2
+ vek8Uo8ap8AEsv4Rfs9opUcUZevLp3g2741eOaidHVLm0l4iLIVl03otGOqvSzs+
+ A3tFPEOxauXpzCt8f8eXsz0WQXAgIKW2h8zu5QHjomioU3i27mtE
+ -----END RSA PRIVATE KEY-----
+ rsa_public: ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC0/Ho+o3eJISydO2JvIgTLnZOtrxPl+fSvJfKDjoOLY0HB2eOjy2s2/2N6d9X9SGZ4+XnyVeNPjfBXw4IyXoqxhfIF16Azfk022iejgjiYssoUxH31M60OfqJhxo16dWEXdkKP1nac06VOt1zS5yEeooyvEuMJEJSsv3VR/7GKhMX3TVhEz5moLmVP3bIAvvoXio8X4urVC1R819QjDC86nlxwNks/GKPRi/IHO5tjJ72Eke7KNsm/vxHgkdX4vZaHNKhfdb/pavFXN5eoUaofz3hxw5oL/u2epI/pXyUhDp8Tb5wO6slykzcIlGCSd0YeO1TnljvViRx0uSxIy97N root@xenial-lxd
+ rsa_certificate: ssh-rsa-cert-v01@openssh.com AAAAHHNzaC1yc2EtY2VydC12MDFAb3BlbnNzaC5jb20AAAAgMpgBP4Phn3L8I7Vqh7lmHKcOfIokEvSEbHDw83Y3JloAAAADAQABAAABAQC0/Ho+o3eJISydO2JvIgTLnZOtrxPl+fSvJfKDjoOLY0HB2eOjy2s2/2N6d9X9SGZ4+XnyVeNPjfBXw4IyXoqxhfIF16Azfk022iejgjiYssoUxH31M60OfqJhxo16dWEXdkKP1nac06VOt1zS5yEeooyvEuMJEJSsv3VR/7GKhMX3TVhEz5moLmVP3bIAvvoXio8X4urVC1R819QjDC86nlxwNks/GKPRi/IHO5tjJ72Eke7KNsm/vxHgkdX4vZaHNKhfdb/pavFXN5eoUaofz3hxw5oL/u2epI/pXyUhDp8Tb5wO6slykzcIlGCSd0YeO1TnljvViRx0uSxIy97NAAAAAAAAAAAAAAACAAAACnhlbmlhbC1seGQAAAAAAAAAAF+vVEIAAAAAYY83bgAAAAAAAAAAAAAAAAAAADMAAAALc3NoLWVkMjU1MTkAAAAgz4SlDwbq53ZrRsnS6ISdwxgFDRpnEX44K8jFmLpI9NAAAABTAAAAC3NzaC1lZDI1NTE5AAAAQMWpiRWKNMFvRX0g6OQOELMqDhtNBpkIN92IyO25qiY2oDSd1NyVme6XnGDFt8CS7z5NufV04doP4aacLOBbQww= root@xenial-lxd
+ dsa_private: |
+ -----BEGIN DSA PRIVATE KEY-----
+ MIIBuwIBAAKBgQD5Fstc23IVSDe6k4DNP8smPKuEWUvHDTOGaXrhOVAfzZ6+jklP
+ 55mzvC7jO53PWWC31hq10xBoWdev0WtcNF9Tv+4bAa1263y51Rqo4GI7xx+xic1d
+ mLqqfYijBT9k48J/1tV0cs1Wjs6FP/IJTD/kYVC930JjYQMi722lBnUxsQIVAL7i
+ z3fTGKTvSzvW0wQlwnYpS2QFAoGANp+KdyS9V93HgxGQEN1rlj/TSv/a3EVdCKtE
+ nQf55aPHxDAVDVw5JtRh4pZbbRV4oGRPc9KOdjo5BU28vSM3Lmhkb+UaaDXwHkgI
+ nK193o74DKjADWZxuLyyiKHiMOhxozoxDfjWxs8nz6uqvSW0pr521EwIY6RajbED
+ nZ2a3GkCgYEAyoUomNRB6bmpsIfzt8zdtqLP5umIj2uhr9MVPL8/QdbxmJ72Z7pf
+ Q2z1B7QAdIBGOlqJXtlau7ABhWK29Efe+99ObyTSSdDc6RCDeAwUmBAiPRQhDH2E
+ wExw3doDSCUb28L1B50wBzQ8mC3KXp6C7IkBXWspb16DLHUHFSI8bkICFA5kVUcW
+ nCPOXEQsayANi8+Cb7BH
+ -----END DSA PRIVATE KEY-----
+ dsa_public: ssh-dss AAAAB3NzaC1kc3MAAACBAPkWy1zbchVIN7qTgM0/yyY8q4RZS8cNM4ZpeuE5UB/Nnr6OSU/nmbO8LuM7nc9ZYLfWGrXTEGhZ16/Ra1w0X1O/7hsBrXbrfLnVGqjgYjvHH7GJzV2Yuqp9iKMFP2Tjwn/W1XRyzVaOzoU/8glMP+RhUL3fQmNhAyLvbaUGdTGxAAAAFQC+4s930xik70s71tMEJcJ2KUtkBQAAAIA2n4p3JL1X3ceDEZAQ3WuWP9NK/9rcRV0Iq0SdB/nlo8fEMBUNXDkm1GHillttFXigZE9z0o52OjkFTby9IzcuaGRv5RpoNfAeSAicrX3ejvgMqMANZnG4vLKIoeIw6HGjOjEN+NbGzyfPq6q9JbSmvnbUTAhjpFqNsQOdnZrcaQAAAIEAyoUomNRB6bmpsIfzt8zdtqLP5umIj2uhr9MVPL8/QdbxmJ72Z7pfQ2z1B7QAdIBGOlqJXtlau7ABhWK29Efe+99ObyTSSdDc6RCDeAwUmBAiPRQhDH2EwExw3doDSCUb28L1B50wBzQ8mC3KXp6C7IkBXWspb16DLHUHFSI8bkI= root@xenial-lxd
+ ed25519_private: |
+ -----BEGIN OPENSSH PRIVATE KEY-----
+ b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
+ QyNTUxOQAAACDbnQGUruL42aVVsyHeaV5mYNTOhteXao0Nl5DVThJ2+QAAAJgwt+lcMLfp
+ XAAAAAtzc2gtZWQyNTUxOQAAACDbnQGUruL42aVVsyHeaV5mYNTOhteXao0Nl5DVThJ2+Q
+ AAAEDQlFZpz9q8+/YJHS9+jPAqy2ZT6cGEv8HTB6RZtTjd/dudAZSu4vjZpVWzId5pXmZg
+ 1M6G15dqjQ2XkNVOEnb5AAAAD3Jvb3RAeGVuaWFsLWx4ZAECAwQFBg==
+ -----END OPENSSH PRIVATE KEY-----
+ ed25519_public: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINudAZSu4vjZpVWzId5pXmZg1M6G15dqjQ2XkNVOEnb5 root@xenial-lxd
+ ecdsa_private: |
+ -----BEGIN EC PRIVATE KEY-----
+ MHcCAQEEIDuK+QFc1wmyJY8uDqQVa1qHte30Rk/fdLxGIBkwJAyOoAoGCCqGSM49
+ AwEHoUQDQgAEWxLlO+TL8gL91eET9p/HFQbqR1A691AkJgZk3jY5mpZqxgX4vcgb
+ 7f/CtXuM6s2svcDJqAeXr6Wk8OJJcMxylA==
+ -----END EC PRIVATE KEY-----
+ ecdsa_public: ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFsS5Tvky/IC/dXhE/afxxUG6kdQOvdQJCYGZN42OZqWasYF+L3IG+3/wrV7jOrNrL3AyagHl6+lpPDiSXDMcpQ= root@xenial-lxd
+""" # noqa
+
+
+@pytest.mark.ci
+@pytest.mark.user_data(USER_DATA)
+class TestSshKeysProvided:
+
+ def test_ssh_dsa_keys_provided(self, class_client):
+ """Test dsa public key was imported."""
+ out = class_client.read_from_file("/etc/ssh/ssh_host_dsa_key.pub")
+ assert (
+ "AAAAB3NzaC1kc3MAAACBAPkWy1zbchVIN7qTgM0/yyY8q4R"
+ "ZS8cNM4ZpeuE5UB/Nnr6OSU/nmbO8LuM") in out
+
+ """Test dsa private key was imported."""
+ out = class_client.read_from_file("/etc/ssh/ssh_host_dsa_key")
+ assert (
+ "MIIBuwIBAAKBgQD5Fstc23IVSDe6k4DNP8smPKuEWUvHDTOGaXr"
+ "hOVAfzZ6+jklP") in out
+
+ def test_ssh_rsa_keys_provided(self, class_client):
+ """Test rsa public key was imported."""
+ out = class_client.read_from_file("/etc/ssh/ssh_host_rsa_key.pub")
+ assert (
+ "AAAAB3NzaC1yc2EAAAADAQABAAABAQC0/Ho+o3eJISydO2JvIgT"
+ "LnZOtrxPl+fSvJfKDjoOLY0HB2eOjy2s2/2N6d9X9SGZ4") in out
+
+ """Test rsa private key was imported."""
+ out = class_client.read_from_file("/etc/ssh/ssh_host_rsa_key")
+ assert (
+ "4DOkqNiUGl80Zp1RgZNohHUXlJMtAbrIlAVEk+mTmg7vjfyp2un"
+ "RQvLZpMRdywBm") in out
+
+ def test_ssh_rsa_certificate_provided(self, class_client):
+ """Test rsa certificate was imported."""
+ out = class_client.read_from_file("/etc/ssh/ssh_host_rsa_key-cert.pub")
+ assert (
+ "AAAAHHNzaC1yc2EtY2VydC12MDFAb3BlbnNzaC5jb20AAAAgMpg"
+ "BP4Phn3L8I7Vqh7lmHKcOfIokEvSEbHDw83Y3JloAAAAD") in out
+
+ def test_ssh_certificate_updated_sshd_config(self, class_client):
+ """Test ssh certificate was added to /etc/ssh/sshd_config."""
+ out = class_client.read_from_file("/etc/ssh/sshd_config").strip()
+ assert "HostCertificate /etc/ssh/ssh_host_rsa_key-cert.pub" in out
+
+ def test_ssh_ecdsa_keys_provided(self, class_client):
+ """Test ecdsa public key was imported."""
+ out = class_client.read_from_file("/etc/ssh/ssh_host_ecdsa_key.pub")
+ assert (
+ "AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAAB"
+ "BBFsS5Tvky/IC/dXhE/afxxU") in out
+
+ """Test ecdsa private key generated."""
+ out = class_client.read_from_file("/etc/ssh/ssh_host_ecdsa_key")
+ assert (
+ "AwEHoUQDQgAEWxLlO+TL8gL91eET9p/HFQbqR1A691AkJgZk3jY"
+ "5mpZqxgX4vcgb") in out
+
+ def test_ssh_ed25519_keys_provided(self, class_client):
+ """Test ed25519 public key was imported."""
+ out = class_client.read_from_file("/etc/ssh/ssh_host_ed25519_key.pub")
+ assert (
+ "AAAAC3NzaC1lZDI1NTE5AAAAINudAZSu4vjZpVWzId5pXmZg1M6"
+ "G15dqjQ2XkNVOEnb5") in out
+
+ """Test ed25519 private key was imported."""
+ out = class_client.read_from_file("/etc/ssh/ssh_host_ed25519_key")
+ assert (
+ "XAAAAAtzc2gtZWQyNTUxOQAAACDbnQGUruL42aVVsyHeaV5mYNT"
+ "OhteXao0Nl5DVThJ2+Q") in out
diff --git a/tests/integration_tests/modules/test_timezone.py b/tests/integration_tests/modules/test_timezone.py
new file mode 100644
index 00000000..111d53f7
--- /dev/null
+++ b/tests/integration_tests/modules/test_timezone.py
@@ -0,0 +1,25 @@
+"""Integration test for the timezone module.
+
+This test specifies a timezone to be used by the ``timezone`` module
+and then checks that if that timezone was respected during boot.
+
+(This is ported from
+``tests/cloud_tests/testcases/modules/timezone.yaml``.)"""
+
+import pytest
+
+
+USER_DATA = """\
+#cloud-config
+timezone: US/Aleutian
+"""
+
+
+@pytest.mark.ci
+class TestTimezone:
+
+ @pytest.mark.user_data(USER_DATA)
+ def test_timezone(self, client):
+ timezone_output = client.execute(
+ 'date "+%Z" --date="Thu, 03 Nov 2016 00:47:00 -0400"')
+ assert timezone_output.strip() == "HDT"
diff --git a/tests/integration_tests/modules/test_users_groups.py b/tests/integration_tests/modules/test_users_groups.py
new file mode 100644
index 00000000..6a51f5a6
--- /dev/null
+++ b/tests/integration_tests/modules/test_users_groups.py
@@ -0,0 +1,83 @@
+"""Integration test for the user_groups module.
+
+This test specifies a number of users and groups via user-data, and confirms
+that they have been configured correctly in the system under test.
+"""
+import re
+
+import pytest
+
+
+USER_DATA = """\
+#cloud-config
+# Add groups to the system
+groups:
+ - secret: [root]
+ - cloud-users
+
+# Add users to the system. Users are added after groups are added.
+users:
+ - default
+ - name: foobar
+ gecos: Foo B. Bar
+ primary_group: foobar
+ groups: users
+ expiredate: 2038-01-19
+ lock_passwd: false
+ passwd: $6$j212wezy$7H/1LT4f9/N3wpgNunhsIqtMj62OKiS3nyNwuizouQc3u7MbYCarYe\
+AHWYPYb2FT.lbioDm2RrkJPb9BZMN1O/
+ - name: barfoo
+ gecos: Bar B. Foo
+ sudo: ALL=(ALL) NOPASSWD:ALL
+ groups: [cloud-users, secret]
+ lock_passwd: true
+ - name: cloudy
+ gecos: Magic Cloud App Daemon User
+ inactive: true
+ system: true
+"""
+
+
+@pytest.mark.ci
+@pytest.mark.user_data(USER_DATA)
+class TestUsersGroups:
+ @pytest.mark.parametrize(
+ "getent_args,regex",
+ [
+ # Test the ubuntu group
+ (["group", "ubuntu"], r"ubuntu:x:[0-9]{4}:"),
+ # Test the cloud-users group
+ (["group", "cloud-users"], r"cloud-users:x:[0-9]{4}:barfoo"),
+ # Test the ubuntu user
+ (
+ ["passwd", "ubuntu"],
+ r"ubuntu:x:[0-9]{4}:[0-9]{4}:Ubuntu:/home/ubuntu:/bin/bash",
+ ),
+ # Test the foobar user
+ (
+ ["passwd", "foobar"],
+ r"foobar:x:[0-9]{4}:[0-9]{4}:Foo B. Bar:/home/foobar:",
+ ),
+ # Test the barfoo user
+ (
+ ["passwd", "barfoo"],
+ r"barfoo:x:[0-9]{4}:[0-9]{4}:Bar B. Foo:/home/barfoo:",
+ ),
+ # Test the cloudy user
+ (["passwd", "cloudy"], r"cloudy:x:[0-9]{3,4}:"),
+ ],
+ )
+ def test_users_groups(self, regex, getent_args, class_client):
+ """Use getent to interrogate the various expected outcomes"""
+ result = class_client.execute(["getent"] + getent_args)
+ assert re.search(regex, result.stdout) is not None, (
+ "'getent {}' resulted in '{}', "
+ "but expected to match regex {}".format(
+ ' '.join(getent_args), result.stdout, regex))
+
+ def test_user_root_in_secret(self, class_client):
+ """Test root user is in 'secret' group."""
+ output = class_client.execute("groups root").stdout
+ _, groups_str = output.split(":", maxsplit=1)
+ groups = groups_str.split()
+ assert "secret" in groups
diff --git a/tests/integration_tests/modules/test_write_files.py b/tests/integration_tests/modules/test_write_files.py
new file mode 100644
index 00000000..15832ae3
--- /dev/null
+++ b/tests/integration_tests/modules/test_write_files.py
@@ -0,0 +1,66 @@
+"""Integration test for the write_files module.
+
+This test specifies files to be created by the ``write_files`` module
+and then checks if those files were created during boot.
+
+(This is ported from
+``tests/cloud_tests/testcases/modules/write_files.yaml``.)"""
+
+import base64
+import pytest
+
+
+ASCII_TEXT = "ASCII text"
+B64_CONTENT = base64.b64encode(ASCII_TEXT.encode("utf-8"))
+
+# NOTE: the binary data can be any binary data, not only executables
+# and can be generated via the base 64 command as such:
+# $ base64 < hello > hello.txt
+# the opposite is running:
+# $ base64 -d < hello.txt > hello
+#
+USER_DATA = """\
+#cloud-config
+write_files:
+- encoding: b64
+ content: {}
+ owner: root:root
+ path: /root/file_b64
+ permissions: '0644'
+- content: |
+ # My new /root/file_text
+
+ SMBDOPTIONS="-D"
+ path: /root/file_text
+- content: !!binary |
+ /Z/xrHR4WINT0UNoKPQKbuovp6+Js+JK
+ path: /root/file_binary
+ permissions: '0555'
+- encoding: gzip
+ content: !!binary |
+ H4sIAIDb/U8C/1NW1E/KzNMvzuBKTc7IV8hIzcnJVyjPL8pJ4QIA6N+MVxsAAAA=
+ path: /root/file_gzip
+ permissions: '0755'
+""".format(B64_CONTENT.decode("ascii"))
+
+
+@pytest.mark.ci
+@pytest.mark.user_data(USER_DATA)
+class TestWriteFiles:
+
+ @pytest.mark.parametrize(
+ "cmd,expected_out", (
+ ("file /root/file_b64", ASCII_TEXT),
+ ("md5sum </root/file_binary", "3801184b97bb8c6e63fa0e1eae2920d7"),
+ ("sha256sum </root/file_binary", (
+ "2c791c4037ea5bd7e928d6a87380f8ba"
+ "7a803cd83d5e4f269e28f5090f0f2c9a"
+ )),
+ ("file /root/file_gzip",
+ "POSIX shell script, ASCII text executable"),
+ ("file /root/file_text", ASCII_TEXT),
+ )
+ )
+ def test_write_files(self, cmd, expected_out, class_client):
+ out = class_client.execute(cmd)
+ assert expected_out in out