diff options
Diffstat (limited to 'tests/integration_tests')
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 |