From 82ffc53273927bfc8d71e7f0c858753552d85cf1 Mon Sep 17 00:00:00 2001 From: James Falcon Date: Thu, 1 Oct 2020 15:32:35 -0500 Subject: Initial implementation of integration testing infrastructure (#581) --- tests/integration_tests/conftest.py | 106 +++++++++++ tests/integration_tests/integration_settings.py | 95 ++++++++++ tests/integration_tests/platforms.py | 235 ++++++++++++++++++++++++ 3 files changed, 436 insertions(+) create mode 100644 tests/integration_tests/conftest.py create mode 100644 tests/integration_tests/integration_settings.py create mode 100644 tests/integration_tests/platforms.py (limited to 'tests/integration_tests') diff --git a/tests/integration_tests/conftest.py b/tests/integration_tests/conftest.py new file mode 100644 index 00000000..a170bfc9 --- /dev/null +++ b/tests/integration_tests/conftest.py @@ -0,0 +1,106 @@ +# This file is part of cloud-init. See LICENSE file for license information. +import os +import logging +import pytest +import sys +from contextlib import contextmanager + +from tests.integration_tests import integration_settings +from tests.integration_tests.platforms import ( + dynamic_client, + LxdContainerClient, + client_name_to_class +) + +log = logging.getLogger('integration_testing') +log.addHandler(logging.StreamHandler(sys.stdout)) +log.setLevel(logging.INFO) + + +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 = client_name_to_class.keys() + supported_platforms = set(all_platforms).intersection( + mark.name for mark in item.iter_markers()) + current_platform = integration_settings.PLATFORM + if supported_platforms and current_platform not in supported_platforms: + pytest.skip('Cannot run on platform {}'.format(current_platform)) + + +# 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.fixture(scope='session', autouse=True) +def setup_image(): + """Setup the target environment with the correct version of cloud-init. + + So we can launch instances / run tests with the correct image + """ + client = dynamic_client() + log.info('Setting up environment for %s', client.datasource) + if integration_settings.CLOUD_INIT_SOURCE == 'NONE': + pass # that was easy + elif integration_settings.CLOUD_INIT_SOURCE == 'IN_PLACE': + if not isinstance(client, LxdContainerClient): + raise ValueError( + 'IN_PLACE as CLOUD_INIT_SOURCE only works for LXD') + # The mount needs to happen after the instance is launched, so + # no further action needed here + elif integration_settings.CLOUD_INIT_SOURCE == 'PROPOSED': + client.launch() + client.install_proposed_image() + elif integration_settings.CLOUD_INIT_SOURCE.startswith('ppa:'): + client.launch() + client.install_ppa(integration_settings.CLOUD_INIT_SOURCE) + elif os.path.isfile(str(integration_settings.CLOUD_INIT_SOURCE)): + client.launch() + client.install_deb() + if client.instance: + # 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): + """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) + with dynamic_client(user_data=user_data) as instance: + yield instance + + +@pytest.yield_fixture +def client(request, fixture_utils): + """Provide a client that runs for every test.""" + with _client(request, fixture_utils) as client: + yield client + + +@pytest.yield_fixture(scope='module') +def module_client(request, fixture_utils): + """Provide a client that runs once per module.""" + with _client(request, fixture_utils) as client: + yield client + + +@pytest.yield_fixture(scope='class') +def class_client(request, fixture_utils): + """Provide a client that runs once per class.""" + with _client(request, fixture_utils) as client: + yield client diff --git a/tests/integration_tests/integration_settings.py b/tests/integration_tests/integration_settings.py new file mode 100644 index 00000000..ddd587db --- /dev/null +++ b/tests/integration_tests/integration_settings.py @@ -0,0 +1,95 @@ +# 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 +# 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 +# , e.g., ppa:cloud-init-dev/proposed +# Install from a PPA. It MUST start with 'ppa:' +# +# 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/platforms.py b/tests/integration_tests/platforms.py new file mode 100644 index 00000000..b42414b9 --- /dev/null +++ b/tests/integration_tests/platforms.py @@ -0,0 +1,235 @@ +# This file is part of cloud-init. See LICENSE file for license information. +from abc import ABC, abstractmethod +import logging +import os +from tempfile import NamedTemporaryFile + +from pycloudlib import EC2, GCE, Azure, OCI, LXD +from pycloudlib.cloud import BaseCloud +from pycloudlib.instance import BaseInstance + +import cloudinit +from cloudinit.subp import subp +from tests.integration_tests import integration_settings + +try: + from typing import Callable, Optional +except ImportError: + pass + + +log = logging.getLogger('integration_testing') + + +class IntegrationClient(ABC): + client = None # type: Optional[BaseCloud] + instance = None # type: Optional[BaseInstance] + datasource = None # type: Optional[str] + use_sudo = True + current_image = None + + def __init__(self, user_data=None, instance_type=None, wait=True, + settings=integration_settings, launch_kwargs=None): + self.user_data = user_data + self.instance_type = settings.INSTANCE_TYPE if \ + instance_type is None else instance_type + self.wait = wait + self.settings = settings + self.launch_kwargs = launch_kwargs if launch_kwargs else {} + self.client = self._get_client() + + @abstractmethod + def _get_client(self): + raise NotImplementedError + + def _get_image(self): + if self.current_image: + return self.current_image + image_id = self.settings.OS_IMAGE + try: + image_id = self.client.released_image(self.settings.OS_IMAGE) + except (ValueError, IndexError): + pass + return image_id + + def launch(self): + 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.client.get_instance( + self.settings.EXISTING_INSTANCE_ID + ) + return + image_id = self._get_image() + launch_args = { + 'image_id': image_id, + 'user_data': self.user_data, + 'wait': self.wait, + } + if self.instance_type: + launch_args['instance_type'] = self.instance_type + launch_args.update(self.launch_kwargs) + self.instance = self.client.launch(**launch_args) + log.info('Launched instance: %s', self.instance) + + def destroy(self): + self.instance.delete() + + def execute(self, command): + return self.instance.execute(command) + + def pull_file(self, remote_file, local_file): + self.instance.pull_file(remote_file, local_file) + + def push_file(self, local_path, remote_path): + self.instance.push_file(local_path, remote_path) + + def read_from_file(self, remote_path) -> str: + tmp_file = NamedTemporaryFile('r') + self.pull_file(remote_path, tmp_file.name) + with tmp_file as f: + contents = f.read() + return contents + + 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.client.snapshot(self.instance, clean=True) + + 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) + IntegrationClient.current_image = 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): + self.launch() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + if not self.settings.KEEP_INSTANCE: + self.destroy() + + +class Ec2Client(IntegrationClient): + datasource = 'ec2' + + def _get_client(self): + return EC2(tag='ec2-integration-test') + + +class GceClient(IntegrationClient): + datasource = 'gce' + + def _get_client(self): + return GCE( + tag='gce-integration-test', + project=self.settings.GCE_PROJECT, + region=self.settings.GCE_REGION, + zone=self.settings.GCE_ZONE, + ) + + +class AzureClient(IntegrationClient): + datasource = 'azure' + + def _get_client(self): + return Azure(tag='azure-integration-test') + + +class OciClient(IntegrationClient): + datasource = 'oci' + + def _get_client(self): + return OCI( + tag='oci-integration-test', + compartment_id=self.settings.OCI_COMPARTMENT_ID + ) + + +class LxdContainerClient(IntegrationClient): + datasource = 'lxd_container' + use_sudo = False + + def _get_client(self): + return LXD(tag='lxd-integration-test') + + def _mount_source(self): + command = ( + 'lxc config device add {name} host-cloud-init disk ' + 'source={cloudinit_path} ' + 'path=/usr/lib/python3/dist-packages/cloudinit' + ).format( + name=self.instance.name, cloudinit_path=cloudinit.__path__[0]) + subp(command.split()) + + def launch(self): + super().launch() + if self.settings.CLOUD_INIT_SOURCE == 'IN_PLACE': + self._mount_source() + + +client_name_to_class = { + 'ec2': Ec2Client, + 'gce': GceClient, + # 'azure': AzureClient, # Not supported yet + 'oci': OciClient, + 'lxd_container': LxdContainerClient +} + +try: + dynamic_client = client_name_to_class[ + integration_settings.PLATFORM + ] # type: Callable[..., IntegrationClient] +except KeyError: + raise ValueError( + "{} is an invalid PLATFORM specified in settings. " + "Must be one of {}".format( + integration_settings.PLATFORM, list(client_name_to_class.keys()) + ) + ) -- cgit v1.2.3 From 11fa714f3a7b031657b642d0c2f0155854d5ee0e Mon Sep 17 00:00:00 2001 From: Daniel Watkins Date: Fri, 2 Oct 2020 15:13:19 -0400 Subject: add integration test for LP: #1886531 (#592) --- tests/integration_tests/bugs/test_lp1886531.py | 27 ++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 tests/integration_tests/bugs/test_lp1886531.py (limited to 'tests/integration_tests') diff --git a/tests/integration_tests/bugs/test_lp1886531.py b/tests/integration_tests/bugs/test_lp1886531.py new file mode 100644 index 00000000..b1112185 --- /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 /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 -- cgit v1.2.3 From efa4d5be85c596c06cfd4c2613ab010ce54796e8 Mon Sep 17 00:00:00 2001 From: Daniel Watkins Date: Tue, 6 Oct 2020 11:12:31 -0400 Subject: integration_tests: improve cloud-init.log assertions (#593) --- tests/integration_tests/conftest.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) (limited to 'tests/integration_tests') diff --git a/tests/integration_tests/conftest.py b/tests/integration_tests/conftest.py index a170bfc9..37197ae3 100644 --- a/tests/integration_tests/conftest.py +++ b/tests/integration_tests/conftest.py @@ -104,3 +104,29 @@ def class_client(request, fixture_utils): """Provide a client that runs once per class.""" with _client(request, fixture_utils) 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 -- cgit v1.2.3 From 5bf287f430b97860bf746e61b83ff53b834592d0 Mon Sep 17 00:00:00 2001 From: Daniel Watkins Date: Tue, 6 Oct 2020 11:55:49 -0400 Subject: integration_tests: don't error on cloud-init failure (#596) pycloudlib's default behaviour is to raise an exception if cloud-init fails to run in an instance being launched. For cloud-init testing, we want our test assertions to flag up failures, so we disable this behaviour for instances we launch. --- tests/integration_tests/platforms.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'tests/integration_tests') diff --git a/tests/integration_tests/platforms.py b/tests/integration_tests/platforms.py index b42414b9..ba1d4e9f 100644 --- a/tests/integration_tests/platforms.py +++ b/tests/integration_tests/platforms.py @@ -28,12 +28,11 @@ class IntegrationClient(ABC): use_sudo = True current_image = None - def __init__(self, user_data=None, instance_type=None, wait=True, + def __init__(self, user_data=None, instance_type=None, settings=integration_settings, launch_kwargs=None): self.user_data = user_data self.instance_type = settings.INSTANCE_TYPE if \ instance_type is None else instance_type - self.wait = wait self.settings = settings self.launch_kwargs = launch_kwargs if launch_kwargs else {} self.client = self._get_client() @@ -65,12 +64,13 @@ class IntegrationClient(ABC): launch_args = { 'image_id': image_id, 'user_data': self.user_data, - 'wait': self.wait, + 'wait': False, } if self.instance_type: launch_args['instance_type'] = self.instance_type launch_args.update(self.launch_kwargs) self.instance = self.client.launch(**launch_args) + self.instance.wait(raise_on_cloudinit_failure=False) log.info('Launched instance: %s', self.instance) def destroy(self): -- cgit v1.2.3 From 6bd2cda1d3812750027590fe02a5b9415c8f8c9e Mon Sep 17 00:00:00 2001 From: James Falcon Date: Thu, 15 Oct 2020 15:48:38 -0500 Subject: Add Azure support to integration test framework (#604) --- tests/integration_tests/integration_settings.py | 1 + tests/integration_tests/platforms.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) (limited to 'tests/integration_tests') diff --git a/tests/integration_tests/integration_settings.py b/tests/integration_tests/integration_settings.py index ddd587db..a0609f7e 100644 --- a/tests/integration_tests/integration_settings.py +++ b/tests/integration_tests/integration_settings.py @@ -10,6 +10,7 @@ KEEP_INSTANCE = False # One of: # lxd_container +# azure # ec2 # gce # oci diff --git a/tests/integration_tests/platforms.py b/tests/integration_tests/platforms.py index ba1d4e9f..3755eb1e 100644 --- a/tests/integration_tests/platforms.py +++ b/tests/integration_tests/platforms.py @@ -217,7 +217,7 @@ class LxdContainerClient(IntegrationClient): client_name_to_class = { 'ec2': Ec2Client, 'gce': GceClient, - # 'azure': AzureClient, # Not supported yet + 'azure': AzureClient, 'oci': OciClient, 'lxd_container': LxdContainerClient } -- cgit v1.2.3 From 268fefa309c20181f18ce1081e26aa90cc0b2f85 Mon Sep 17 00:00:00 2001 From: Daniel Watkins Date: Fri, 16 Oct 2020 10:22:50 -0400 Subject: integration_tests: implement citest tests run in Travis (#605) Specifically: * `apt_configure_sources_list` * `ntp_servers` * `set_password_list` * `users_groups` Although not currently run in Travis, `set_password_list_string` was ported over alongside `set_password_list` (as `test_set_password`). --- .../modules/test_apt_configure_sources_list.py | 50 +++++++ .../integration_tests/modules/test_ntp_servers.py | 57 ++++++++ .../integration_tests/modules/test_set_password.py | 149 +++++++++++++++++++++ .../integration_tests/modules/test_users_groups.py | 79 +++++++++++ 4 files changed, 335 insertions(+) create mode 100644 tests/integration_tests/modules/test_apt_configure_sources_list.py create mode 100644 tests/integration_tests/modules/test_ntp_servers.py create mode 100644 tests/integration_tests/modules/test_set_password.py create mode 100644 tests/integration_tests/modules/test_users_groups.py (limited to 'tests/integration_tests') 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..d64b3956 --- /dev/null +++ b/tests/integration_tests/modules/test_apt_configure_sources_list.py @@ -0,0 +1,50 @@ +"""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", +] + + +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..4cad8926 --- /dev/null +++ b/tests/integration_tests/modules/test_ntp_servers.py @@ -0,0 +1,57 @@ +"""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.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_set_password.py b/tests/integration_tests/modules/test_set_password.py new file mode 100644 index 00000000..ae6fdefc --- /dev/null +++ b/tests/integration_tests/modules/test_set_password.py @@ -0,0 +1,149 @@ +"""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.user_data(LIST_USER_DATA) +class TestPasswordList(Mixin): + """Launch an instance with LIST_USER_DATA, ensure Mixin tests pass.""" + + +@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_users_groups.py b/tests/integration_tests/modules/test_users_groups.py new file mode 100644 index 00000000..b1fa8c22 --- /dev/null +++ b/tests/integration_tests/modules/test_users_groups.py @@ -0,0 +1,79 @@ +"""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.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 + + 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 -- cgit v1.2.3 From c0e8480678e3a9173c9de1271f651fb3ba375f22 Mon Sep 17 00:00:00 2001 From: Daniel Watkins Date: Fri, 16 Oct 2020 10:41:21 -0400 Subject: integration_tests: emit settings to log during setup (#601) --- tests/integration_tests/conftest.py | 1 + tests/integration_tests/platforms.py | 11 +++++++++++ 2 files changed, 12 insertions(+) (limited to 'tests/integration_tests') diff --git a/tests/integration_tests/conftest.py b/tests/integration_tests/conftest.py index 37197ae3..9c4eb03a 100644 --- a/tests/integration_tests/conftest.py +++ b/tests/integration_tests/conftest.py @@ -48,6 +48,7 @@ def setup_image(): """ client = dynamic_client() log.info('Setting up environment for %s', client.datasource) + client.emit_settings_to_log() if integration_settings.CLOUD_INIT_SOURCE == 'NONE': pass # that was easy elif integration_settings.CLOUD_INIT_SOURCE == 'IN_PLACE': diff --git a/tests/integration_tests/platforms.py b/tests/integration_tests/platforms.py index 3755eb1e..bade5fe0 100644 --- a/tests/integration_tests/platforms.py +++ b/tests/integration_tests/platforms.py @@ -37,6 +37,17 @@ class IntegrationClient(ABC): self.launch_kwargs = launch_kwargs if launch_kwargs else {} self.client = self._get_client() + 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_client(self): raise NotImplementedError -- cgit v1.2.3 From b94962b558e929a365bcfad1ca9a9445eee575e8 Mon Sep 17 00:00:00 2001 From: lucasmoura Date: Mon, 19 Oct 2020 16:09:51 -0300 Subject: Add more integration tests (#615) Translate the following tests from `cloud_tests` to the new integration test framework: * test_runcmd.py * seed_random_data.py * set_hostname.py * set_hostname_fqdn.py * snap.py * ssh_auth_key_fingerprints_disable.py * ssh_auth_key_fingerprints_enable.py * ssh_import_id.py * ssh_keys_generate.py * ssh_keys_provided.py * timezone.py * write_files.py --- .../modules/test_package_update_upgrade_install.py | 74 ++++++++++++ tests/integration_tests/modules/test_runcmd.py | 24 ++++ .../modules/test_seed_random_data.py | 27 +++++ .../integration_tests/modules/test_set_hostname.py | 46 +++++++ tests/integration_tests/modules/test_snap.py | 28 +++++ .../modules/test_ssh_auth_key_fingerprints.py | 47 ++++++++ .../integration_tests/modules/test_ssh_generate.py | 50 ++++++++ .../modules/test_ssh_import_id.py | 28 +++++ .../modules/test_ssh_keys_provided.py | 134 +++++++++++++++++++++ tests/integration_tests/modules/test_timezone.py | 24 ++++ .../integration_tests/modules/test_write_files.py | 65 ++++++++++ 11 files changed, 547 insertions(+) create mode 100644 tests/integration_tests/modules/test_package_update_upgrade_install.py create mode 100644 tests/integration_tests/modules/test_runcmd.py create mode 100644 tests/integration_tests/modules/test_seed_random_data.py create mode 100644 tests/integration_tests/modules/test_set_hostname.py create mode 100644 tests/integration_tests/modules/test_snap.py create mode 100644 tests/integration_tests/modules/test_ssh_auth_key_fingerprints.py create mode 100644 tests/integration_tests/modules/test_ssh_generate.py create mode 100644 tests/integration_tests/modules/test_ssh_import_id.py create mode 100644 tests/integration_tests/modules/test_ssh_keys_provided.py create mode 100644 tests/integration_tests/modules/test_timezone.py create mode 100644 tests/integration_tests/modules/test_write_files.py (limited to 'tests/integration_tests') 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.*)$" % 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..eabe778d --- /dev/null +++ b/tests/integration_tests/modules/test_runcmd.py @@ -0,0 +1,24 @@ +"""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 +""" + + +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..db3d2193 --- /dev/null +++ b/tests/integration_tests/modules/test_seed_random_data.py @@ -0,0 +1,27 @@ +"""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 +""" + + +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..ff46feb9 --- /dev/null +++ b/tests/integration_tests/modules/test_set_hostname.py @@ -0,0 +1,46 @@ +"""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 +""" + + +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_snap.py b/tests/integration_tests/modules/test_snap.py new file mode 100644 index 00000000..d78a0b1e --- /dev/null +++ b/tests/integration_tests/modules/test_snap.py @@ -0,0 +1,28 @@ +"""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 +""" + + +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..e88d9a02 --- /dev/null +++ b/tests/integration_tests/modules/test_ssh_auth_key_fingerprints.py @@ -0,0 +1,47 @@ +"""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 + + +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..8c60fb87 --- /dev/null +++ b/tests/integration_tests/modules/test_ssh_generate.py @@ -0,0 +1,50 @@ +"""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.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..2f2ac92c --- /dev/null +++ b/tests/integration_tests/modules/test_ssh_import_id.py @@ -0,0 +1,28 @@ +"""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 +""" + + +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..4699518d --- /dev/null +++ b/tests/integration_tests/modules/test_ssh_keys_provided.py @@ -0,0 +1,134 @@ +"""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 + 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.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_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..6080d79e --- /dev/null +++ b/tests/integration_tests/modules/test_timezone.py @@ -0,0 +1,24 @@ +"""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 +""" + + +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_write_files.py b/tests/integration_tests/modules/test_write_files.py new file mode 100644 index 00000000..d7032a0c --- /dev/null +++ b/tests/integration_tests/modules/test_write_files.py @@ -0,0 +1,65 @@ +"""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.user_data(USER_DATA) +class TestWriteFiles: + + @pytest.mark.parametrize( + "cmd,expected_out", ( + ("file /root/file_b64", ASCII_TEXT), + ("md5sum Date: Mon, 26 Oct 2020 11:16:20 -0500 Subject: refactor integration testing infrastructure (#610) * Separated IntegrationClient into separate cloud and instance abstractions. This makes it easier to control the lifetime of the pycloudlib's cloud and instance abstractions separately. * Created new cloud-specific subclasses accordingly * Moved platform parsing and initialization code into its own file * Created new session-wide autorun fixture to automatically initialize and destroy the dynamic cloud --- tests/integration_tests/clouds.py | 126 ++++++++++++++++++ tests/integration_tests/conftest.py | 75 +++++++---- tests/integration_tests/instances.py | 157 ++++++++++++++++++++++ tests/integration_tests/platforms.py | 246 ----------------------------------- 4 files changed, 335 insertions(+), 269 deletions(-) create mode 100644 tests/integration_tests/clouds.py create mode 100644 tests/integration_tests/instances.py delete mode 100644 tests/integration_tests/platforms.py (limited to 'tests/integration_tests') diff --git a/tests/integration_tests/clouds.py b/tests/integration_tests/clouds.py new file mode 100644 index 00000000..06f1c623 --- /dev/null +++ b/tests/integration_tests/clouds.py @@ -0,0 +1,126 @@ +# 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, LXD + +from tests.integration_tests import integration_settings +from tests.integration_tests.instances import ( + IntegrationEc2Instance, + IntegrationGceInstance, + IntegrationAzureInstance, IntegrationInstance, + IntegrationOciInstance, + IntegrationLxdContainerInstance, +) + +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() + + @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 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 + launch_kwargs = { + 'image_id': self.image_id, + 'user_data': user_data, + 'wait': False, + } + launch_kwargs.update(launch_kwargs) + pycloudlib_instance = self.cloud_instance.launch(**launch_kwargs) + pycloudlib_instance.wait(raise_on_cloudinit_failure=False) + 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 LxdContainerCloud(IntegrationCloud): + datasource = 'lxd_container' + integration_instance_cls = IntegrationLxdContainerInstance + + def _get_cloud_instance(self): + return LXD(tag='lxd-integration-test') diff --git a/tests/integration_tests/conftest.py b/tests/integration_tests/conftest.py index 9c4eb03a..9163ac66 100644 --- a/tests/integration_tests/conftest.py +++ b/tests/integration_tests/conftest.py @@ -1,21 +1,32 @@ # This file is part of cloud-init. See LICENSE file for license information. -import os import logging +import os import pytest import sys from contextlib import contextmanager from tests.integration_tests import integration_settings -from tests.integration_tests.platforms import ( - dynamic_client, - LxdContainerClient, - client_name_to_class +from tests.integration_tests.clouds import ( + Ec2Cloud, + GceCloud, + AzureCloud, + OciCloud, + LxdContainerCloud, ) + 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, +} + def pytest_runtest_setup(item): """Skip tests on unsupported clouds. @@ -25,7 +36,7 @@ def pytest_runtest_setup(item): platform, then skip the test. If platform specific marks are not specified, then we assume the test can be run anywhere. """ - all_platforms = client_name_to_class.keys() + all_platforms = platforms.keys() supported_platforms = set(all_platforms).intersection( mark.name for mark in item.iter_markers()) current_platform = integration_settings.PLATFORM @@ -40,33 +51,51 @@ 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]() + yield cloud + cloud.destroy() + + @pytest.fixture(scope='session', autouse=True) -def setup_image(): +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 = dynamic_client() - log.info('Setting up environment for %s', client.datasource) - client.emit_settings_to_log() + 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 not isinstance(client, LxdContainerClient): + if session_cloud.datasource != 'lxd_container': raise ValueError( 'IN_PLACE as CLOUD_INIT_SOURCE only works for LXD') # The mount needs to happen after the instance is launched, so # no further action needed here elif integration_settings.CLOUD_INIT_SOURCE == 'PROPOSED': - client.launch() + client = session_cloud.launch() client.install_proposed_image() elif integration_settings.CLOUD_INIT_SOURCE.startswith('ppa:'): - client.launch() + client = session_cloud.launch() client.install_ppa(integration_settings.CLOUD_INIT_SOURCE) elif os.path.isfile(str(integration_settings.CLOUD_INIT_SOURCE)): - client.launch() + client = session_cloud.launch() client.install_deb() - if client.instance: + 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() @@ -74,7 +103,7 @@ def setup_image(): @contextmanager -def _client(request, fixture_utils): +def _client(request, fixture_utils, session_cloud): """Fixture implementation for the client fixtures. Launch the dynamic IntegrationClient instance using any provided @@ -82,28 +111,28 @@ def _client(request, fixture_utils): """ user_data = fixture_utils.closest_marker_first_arg_or( request, 'user_data', None) - with dynamic_client(user_data=user_data) as instance: + with session_cloud.launch(user_data=user_data) as instance: yield instance @pytest.yield_fixture -def client(request, fixture_utils): +def client(request, fixture_utils, session_cloud): """Provide a client that runs for every test.""" - with _client(request, fixture_utils) as client: + with _client(request, fixture_utils, session_cloud) as client: yield client @pytest.yield_fixture(scope='module') -def module_client(request, fixture_utils): +def module_client(request, fixture_utils, session_cloud): """Provide a client that runs once per module.""" - with _client(request, fixture_utils) as client: + with _client(request, fixture_utils, session_cloud) as client: yield client @pytest.yield_fixture(scope='class') -def class_client(request, fixture_utils): +def class_client(request, fixture_utils, session_cloud): """Provide a client that runs once per class.""" - with _client(request, fixture_utils) as client: + with _client(request, fixture_utils, session_cloud) as client: yield client diff --git a/tests/integration_tests/instances.py b/tests/integration_tests/instances.py new file mode 100644 index 00000000..d64c1ab2 --- /dev/null +++ b/tests/integration_tests/instances.py @@ -0,0 +1,157 @@ +# This file is part of cloud-init. See LICENSE file for license information. +import logging +import os +from tempfile import NamedTemporaryFile + +from pycloudlib.instance import BaseInstance + +import cloudinit +from cloudinit.subp import subp +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') + + +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 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) + ] + ) + ) + + def destroy(self): + self.instance.delete() + + def execute(self, command): + return self.instance.execute(command) + + def pull_file(self, remote_file, local_file): + self.instance.pull_file(remote_file, local_file) + + def push_file(self, local_path, remote_path): + self.instance.push_file(local_path, remote_path) + + def read_from_file(self, remote_path) -> str: + tmp_file = NamedTemporaryFile('r') + self.pull_file(remote_path, tmp_file.name) + with tmp_file as f: + contents = f.read() + return contents + + 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 IntegrationLxdContainerInstance(IntegrationInstance): + use_sudo = False + + def __init__(self, cloud: 'IntegrationCloud', instance: BaseInstance, + settings=integration_settings): + super().__init__(cloud, instance, settings) + if self.settings.CLOUD_INIT_SOURCE == 'IN_PLACE': + self._mount_source() + + def _mount_source(self): + command = ( + 'lxc config device add {name} host-cloud-init disk ' + 'source={cloudinit_path} ' + 'path=/usr/lib/python3/dist-packages/cloudinit' + ).format( + name=self.instance.name, cloudinit_path=cloudinit.__path__[0]) + subp(command.split()) diff --git a/tests/integration_tests/platforms.py b/tests/integration_tests/platforms.py deleted file mode 100644 index bade5fe0..00000000 --- a/tests/integration_tests/platforms.py +++ /dev/null @@ -1,246 +0,0 @@ -# This file is part of cloud-init. See LICENSE file for license information. -from abc import ABC, abstractmethod -import logging -import os -from tempfile import NamedTemporaryFile - -from pycloudlib import EC2, GCE, Azure, OCI, LXD -from pycloudlib.cloud import BaseCloud -from pycloudlib.instance import BaseInstance - -import cloudinit -from cloudinit.subp import subp -from tests.integration_tests import integration_settings - -try: - from typing import Callable, Optional -except ImportError: - pass - - -log = logging.getLogger('integration_testing') - - -class IntegrationClient(ABC): - client = None # type: Optional[BaseCloud] - instance = None # type: Optional[BaseInstance] - datasource = None # type: Optional[str] - use_sudo = True - current_image = None - - def __init__(self, user_data=None, instance_type=None, - settings=integration_settings, launch_kwargs=None): - self.user_data = user_data - self.instance_type = settings.INSTANCE_TYPE if \ - instance_type is None else instance_type - self.settings = settings - self.launch_kwargs = launch_kwargs if launch_kwargs else {} - self.client = self._get_client() - - 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_client(self): - raise NotImplementedError - - def _get_image(self): - if self.current_image: - return self.current_image - image_id = self.settings.OS_IMAGE - try: - image_id = self.client.released_image(self.settings.OS_IMAGE) - except (ValueError, IndexError): - pass - return image_id - - def launch(self): - 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.client.get_instance( - self.settings.EXISTING_INSTANCE_ID - ) - return - image_id = self._get_image() - launch_args = { - 'image_id': image_id, - 'user_data': self.user_data, - 'wait': False, - } - if self.instance_type: - launch_args['instance_type'] = self.instance_type - launch_args.update(self.launch_kwargs) - self.instance = self.client.launch(**launch_args) - self.instance.wait(raise_on_cloudinit_failure=False) - log.info('Launched instance: %s', self.instance) - - def destroy(self): - self.instance.delete() - - def execute(self, command): - return self.instance.execute(command) - - def pull_file(self, remote_file, local_file): - self.instance.pull_file(remote_file, local_file) - - def push_file(self, local_path, remote_path): - self.instance.push_file(local_path, remote_path) - - def read_from_file(self, remote_path) -> str: - tmp_file = NamedTemporaryFile('r') - self.pull_file(remote_path, tmp_file.name) - with tmp_file as f: - contents = f.read() - return contents - - 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.client.snapshot(self.instance, clean=True) - - 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) - IntegrationClient.current_image = 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): - self.launch() - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - if not self.settings.KEEP_INSTANCE: - self.destroy() - - -class Ec2Client(IntegrationClient): - datasource = 'ec2' - - def _get_client(self): - return EC2(tag='ec2-integration-test') - - -class GceClient(IntegrationClient): - datasource = 'gce' - - def _get_client(self): - return GCE( - tag='gce-integration-test', - project=self.settings.GCE_PROJECT, - region=self.settings.GCE_REGION, - zone=self.settings.GCE_ZONE, - ) - - -class AzureClient(IntegrationClient): - datasource = 'azure' - - def _get_client(self): - return Azure(tag='azure-integration-test') - - -class OciClient(IntegrationClient): - datasource = 'oci' - - def _get_client(self): - return OCI( - tag='oci-integration-test', - compartment_id=self.settings.OCI_COMPARTMENT_ID - ) - - -class LxdContainerClient(IntegrationClient): - datasource = 'lxd_container' - use_sudo = False - - def _get_client(self): - return LXD(tag='lxd-integration-test') - - def _mount_source(self): - command = ( - 'lxc config device add {name} host-cloud-init disk ' - 'source={cloudinit_path} ' - 'path=/usr/lib/python3/dist-packages/cloudinit' - ).format( - name=self.instance.name, cloudinit_path=cloudinit.__path__[0]) - subp(command.split()) - - def launch(self): - super().launch() - if self.settings.CLOUD_INIT_SOURCE == 'IN_PLACE': - self._mount_source() - - -client_name_to_class = { - 'ec2': Ec2Client, - 'gce': GceClient, - 'azure': AzureClient, - 'oci': OciClient, - 'lxd_container': LxdContainerClient -} - -try: - dynamic_client = client_name_to_class[ - integration_settings.PLATFORM - ] # type: Callable[..., IntegrationClient] -except KeyError: - raise ValueError( - "{} is an invalid PLATFORM specified in settings. " - "Must be one of {}".format( - integration_settings.PLATFORM, list(client_name_to_class.keys()) - ) - ) -- cgit v1.2.3 From 2ea3121fece7dcdb0d5c424cb6fc597699c0e895 Mon Sep 17 00:00:00 2001 From: Daniel Watkins Date: Mon, 2 Nov 2020 13:08:53 -0500 Subject: test_lp1886531: don't assume /etc/fstab exists (#639) As the bug manifested because groovy doesn't ship /etc/fstab, we should not assume that /etc/fstab will be present for us to remove in our bootcmd. Co-authored-by: Rick Harding --- tests/integration_tests/bugs/test_lp1886531.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'tests/integration_tests') diff --git a/tests/integration_tests/bugs/test_lp1886531.py b/tests/integration_tests/bugs/test_lp1886531.py index b1112185..058ea8bb 100644 --- a/tests/integration_tests/bugs/test_lp1886531.py +++ b/tests/integration_tests/bugs/test_lp1886531.py @@ -15,7 +15,7 @@ import pytest USER_DATA = """\ #cloud-config bootcmd: -- rm /etc/fstab +- rm -f /etc/fstab """ -- cgit v1.2.3 From d619f5171ac0ce5b626ef4575ad5f4468e94c987 Mon Sep 17 00:00:00 2001 From: Daniel Watkins Date: Mon, 2 Nov 2020 13:40:10 -0500 Subject: integration_tests: various launch improvements (#638) * integration_tests: fix passing launch_kwargs to session_cloud.launch * integration_tests: log the launch_kwargs before launching instances * integration_tests: add support for specifying instance name for tests Co-authored-by: Rick Harding --- tests/integration_tests/clouds.py | 11 ++++++++--- tests/integration_tests/conftest.py | 10 +++++++++- tox.ini | 1 + 3 files changed, 18 insertions(+), 4 deletions(-) (limited to 'tests/integration_tests') diff --git a/tests/integration_tests/clouds.py b/tests/integration_tests/clouds.py index 06f1c623..08c86198 100644 --- a/tests/integration_tests/clouds.py +++ b/tests/integration_tests/clouds.py @@ -54,13 +54,18 @@ class IntegrationCloud(ABC): self.settings.EXISTING_INSTANCE_ID ) return - launch_kwargs = { + kwargs = { 'image_id': self.image_id, 'user_data': user_data, 'wait': False, } - launch_kwargs.update(launch_kwargs) - pycloudlib_instance = self.cloud_instance.launch(**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.cloud_instance.launch(**kwargs) pycloudlib_instance.wait(raise_on_cloudinit_failure=False) log.info('Launched instance: %s', pycloudlib_instance) return self.get_instance(pycloudlib_instance, settings) diff --git a/tests/integration_tests/conftest.py b/tests/integration_tests/conftest.py index 9163ac66..34e674e9 100644 --- a/tests/integration_tests/conftest.py +++ b/tests/integration_tests/conftest.py @@ -111,7 +111,15 @@ def _client(request, fixture_utils, session_cloud): """ user_data = fixture_utils.closest_marker_first_arg_or( request, 'user_data', None) - with session_cloud.launch(user_data=user_data) as instance: + 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 diff --git a/tox.ini b/tox.ini index a8681197..816e6e8e 100644 --- a/tox.ini +++ b/tox.ini @@ -158,3 +158,4 @@ markers = oci: test will only run on OCI platform lxd_container: test will only run in LXD container user_data: the user data to be passed to the test instance + instance_name: the name to be used for the test instance -- cgit v1.2.3 From f42359741a6446d3c4ef25266ec290ed6f925b3a Mon Sep 17 00:00:00 2001 From: James Falcon Date: Wed, 4 Nov 2020 11:54:20 -0600 Subject: Fix launch_kwargs bug in integration tests (#654) Integration test instance launch would previously fail if provided launch_kwargs is None --- tests/integration_tests/clouds.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'tests/integration_tests') diff --git a/tests/integration_tests/clouds.py b/tests/integration_tests/clouds.py index 08c86198..71d4e85d 100644 --- a/tests/integration_tests/clouds.py +++ b/tests/integration_tests/clouds.py @@ -59,7 +59,8 @@ class IntegrationCloud(ABC): 'user_data': user_data, 'wait': False, } - kwargs.update(launch_kwargs) + 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()) -- cgit v1.2.3 From cd752df6154c403e6dccaf5e797c1d4f8396f756 Mon Sep 17 00:00:00 2001 From: Daniel Watkins Date: Wed, 18 Nov 2020 09:48:47 -0500 Subject: only run a subset of integration tests in CI (#672) This introduces the "ci" mark, used to indicate a test which should run as part of our CI integration testing run and the integration-tests-ci tox environment, which runs only those tests. Travis has been adjusted to use this tox environment. (All current module tests have been marked with the "ci" mark, but the one bug test that we have has not.) --- .travis.yml | 2 +- .../integration_tests/modules/test_apt_configure_sources_list.py | 1 + tests/integration_tests/modules/test_ntp_servers.py | 1 + tests/integration_tests/modules/test_runcmd.py | 1 + tests/integration_tests/modules/test_seed_random_data.py | 1 + tests/integration_tests/modules/test_set_hostname.py | 1 + tests/integration_tests/modules/test_set_password.py | 2 ++ tests/integration_tests/modules/test_snap.py | 1 + tests/integration_tests/modules/test_ssh_auth_key_fingerprints.py | 1 + tests/integration_tests/modules/test_ssh_generate.py | 1 + tests/integration_tests/modules/test_ssh_import_id.py | 1 + tests/integration_tests/modules/test_ssh_keys_provided.py | 1 + tests/integration_tests/modules/test_timezone.py | 1 + tests/integration_tests/modules/test_users_groups.py | 1 + tests/integration_tests/modules/test_write_files.py | 1 + tox.ini | 8 ++++++++ 16 files changed, 24 insertions(+), 1 deletion(-) (limited to 'tests/integration_tests') diff --git a/.travis.yml b/.travis.yml index 496c1a81..6d25f477 100644 --- a/.travis.yml +++ b/.travis.yml @@ -199,7 +199,7 @@ matrix: fi # Use sudo to get a new shell where we're in the sbuild group - sudo -E su $USER -c 'sbuild --nolog --no-run-lintian --verbose --dist=xenial cloud-init_*.dsc' - - sg lxd -c 'CLOUD_INIT_IMAGE_SOURCE="$(ls *.deb)" tox -e integration-tests' & + - sg lxd -c 'CLOUD_INIT_IMAGE_SOURCE="$(ls *.deb)" tox -e integration-tests-ci' & - | SECONDS=0 while [ -e /proc/$! ]; do diff --git a/tests/integration_tests/modules/test_apt_configure_sources_list.py b/tests/integration_tests/modules/test_apt_configure_sources_list.py index d64b3956..d2bcc61a 100644 --- a/tests/integration_tests/modules/test_apt_configure_sources_list.py +++ b/tests/integration_tests/modules/test_apt_configure_sources_list.py @@ -39,6 +39,7 @@ EXPECTED_REGEXES = [ ] +@pytest.mark.ci class TestAptConfigureSourcesList: @pytest.mark.user_data(USER_DATA) diff --git a/tests/integration_tests/modules/test_ntp_servers.py b/tests/integration_tests/modules/test_ntp_servers.py index 4cad8926..e72389c1 100644 --- a/tests/integration_tests/modules/test_ntp_servers.py +++ b/tests/integration_tests/modules/test_ntp_servers.py @@ -22,6 +22,7 @@ ntp: EXPECTED_SERVERS = yaml.safe_load(USER_DATA)["ntp"]["servers"] +@pytest.mark.ci @pytest.mark.user_data(USER_DATA) class TestNtpServers: diff --git a/tests/integration_tests/modules/test_runcmd.py b/tests/integration_tests/modules/test_runcmd.py index eabe778d..50d1851e 100644 --- a/tests/integration_tests/modules/test_runcmd.py +++ b/tests/integration_tests/modules/test_runcmd.py @@ -16,6 +16,7 @@ runcmd: """ +@pytest.mark.ci class TestRuncmd: @pytest.mark.user_data(USER_DATA) diff --git a/tests/integration_tests/modules/test_seed_random_data.py b/tests/integration_tests/modules/test_seed_random_data.py index db3d2193..b365fa98 100644 --- a/tests/integration_tests/modules/test_seed_random_data.py +++ b/tests/integration_tests/modules/test_seed_random_data.py @@ -19,6 +19,7 @@ random_seed: """ +@pytest.mark.ci class TestSeedRandomData: @pytest.mark.user_data(USER_DATA) diff --git a/tests/integration_tests/modules/test_set_hostname.py b/tests/integration_tests/modules/test_set_hostname.py index ff46feb9..2bfa403d 100644 --- a/tests/integration_tests/modules/test_set_hostname.py +++ b/tests/integration_tests/modules/test_set_hostname.py @@ -25,6 +25,7 @@ fqdn: cloudinit2.i9n.cloud-init.io """ +@pytest.mark.ci class TestHostname: @pytest.mark.user_data(USER_DATA_HOSTNAME) diff --git a/tests/integration_tests/modules/test_set_password.py b/tests/integration_tests/modules/test_set_password.py index ae6fdefc..b13f76fb 100644 --- a/tests/integration_tests/modules/test_set_password.py +++ b/tests/integration_tests/modules/test_set_password.py @@ -139,11 +139,13 @@ class Mixin: 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 index d78a0b1e..b626f6b0 100644 --- a/tests/integration_tests/modules/test_snap.py +++ b/tests/integration_tests/modules/test_snap.py @@ -19,6 +19,7 @@ snap: """ +@pytest.mark.ci class TestSnap: @pytest.mark.user_data(USER_DATA) diff --git a/tests/integration_tests/modules/test_ssh_auth_key_fingerprints.py b/tests/integration_tests/modules/test_ssh_auth_key_fingerprints.py index e88d9a02..b9b0d85e 100644 --- a/tests/integration_tests/modules/test_ssh_auth_key_fingerprints.py +++ b/tests/integration_tests/modules/test_ssh_auth_key_fingerprints.py @@ -28,6 +28,7 @@ ssh_authorized_keys: """ # noqa +@pytest.mark.ci class TestSshAuthkeyFingerprints: @pytest.mark.user_data(USER_DATA_SSH_AUTHKEY_DISABLE) diff --git a/tests/integration_tests/modules/test_ssh_generate.py b/tests/integration_tests/modules/test_ssh_generate.py index 8c60fb87..60c36982 100644 --- a/tests/integration_tests/modules/test_ssh_generate.py +++ b/tests/integration_tests/modules/test_ssh_generate.py @@ -20,6 +20,7 @@ authkey_hash: sha512 """ +@pytest.mark.ci @pytest.mark.user_data(USER_DATA) class TestSshKeysGenerate: diff --git a/tests/integration_tests/modules/test_ssh_import_id.py b/tests/integration_tests/modules/test_ssh_import_id.py index 2f2ac92c..45d37d6c 100644 --- a/tests/integration_tests/modules/test_ssh_import_id.py +++ b/tests/integration_tests/modules/test_ssh_import_id.py @@ -17,6 +17,7 @@ ssh_import_id: """ +@pytest.mark.ci class TestSshImportId: @pytest.mark.user_data(USER_DATA) diff --git a/tests/integration_tests/modules/test_ssh_keys_provided.py b/tests/integration_tests/modules/test_ssh_keys_provided.py index 4699518d..dc6d2fc1 100644 --- a/tests/integration_tests/modules/test_ssh_keys_provided.py +++ b/tests/integration_tests/modules/test_ssh_keys_provided.py @@ -78,6 +78,7 @@ ssh_keys: """ # noqa +@pytest.mark.ci @pytest.mark.user_data(USER_DATA) class TestSshKeysProvided: diff --git a/tests/integration_tests/modules/test_timezone.py b/tests/integration_tests/modules/test_timezone.py index 6080d79e..111d53f7 100644 --- a/tests/integration_tests/modules/test_timezone.py +++ b/tests/integration_tests/modules/test_timezone.py @@ -15,6 +15,7 @@ timezone: US/Aleutian """ +@pytest.mark.ci class TestTimezone: @pytest.mark.user_data(USER_DATA) diff --git a/tests/integration_tests/modules/test_users_groups.py b/tests/integration_tests/modules/test_users_groups.py index b1fa8c22..6a085a8f 100644 --- a/tests/integration_tests/modules/test_users_groups.py +++ b/tests/integration_tests/modules/test_users_groups.py @@ -38,6 +38,7 @@ AHWYPYb2FT.lbioDm2RrkJPb9BZMN1O/ """ +@pytest.mark.ci @pytest.mark.user_data(USER_DATA) class TestUsersGroups: @pytest.mark.parametrize( diff --git a/tests/integration_tests/modules/test_write_files.py b/tests/integration_tests/modules/test_write_files.py index d7032a0c..15832ae3 100644 --- a/tests/integration_tests/modules/test_write_files.py +++ b/tests/integration_tests/modules/test_write_files.py @@ -44,6 +44,7 @@ write_files: """.format(B64_CONTENT.decode("ascii")) +@pytest.mark.ci @pytest.mark.user_data(USER_DATA) class TestWriteFiles: diff --git a/tox.ini b/tox.ini index 32174dee..4320ab87 100644 --- a/tox.ini +++ b/tox.ini @@ -146,6 +146,13 @@ passenv = CLOUD_INIT_* deps = -r{toxinidir}/integration-requirements.txt +[testenv:integration-tests-ci] +commands = {[testenv:integration-tests]commands} +passenv = {[testenv:integration-tests]passenv} +deps = {[testenv:integration-tests]deps} +setenv = + PYTEST_ADDOPTS="-k ci" + [pytest] # TODO: s/--strict/--strict-markers/ once xenial support is dropped testpaths = cloudinit tests/unittests @@ -153,6 +160,7 @@ addopts = --strict markers = allow_subp_for: allow subp usage for the given commands (disable_subp_usage) allow_all_subp: allow all subp usage (disable_subp_usage) + ci: run this integration test as part of CI test runs ds_sys_cfg: a sys_cfg dict to be used by datasource fixtures ec2: test will only run on EC2 platform gce: test will only run on GCE platform -- cgit v1.2.3 From bfaee8cc9b8fd23dbc118ae548fc2ca695a0d707 Mon Sep 17 00:00:00 2001 From: Daniel Watkins Date: Thu, 19 Nov 2020 12:42:49 -0500 Subject: integration_tests: restore emission of settings to log (#657) --- tests/integration_tests/clouds.py | 11 +++++++++++ tests/integration_tests/conftest.py | 1 + tests/integration_tests/instances.py | 11 ----------- 3 files changed, 12 insertions(+), 11 deletions(-) (limited to 'tests/integration_tests') diff --git a/tests/integration_tests/clouds.py b/tests/integration_tests/clouds.py index 71d4e85d..fe89c0c6 100644 --- a/tests/integration_tests/clouds.py +++ b/tests/integration_tests/clouds.py @@ -31,6 +31,17 @@ class IntegrationCloud(ABC): 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 diff --git a/tests/integration_tests/conftest.py b/tests/integration_tests/conftest.py index 34e674e9..eacb2ae2 100644 --- a/tests/integration_tests/conftest.py +++ b/tests/integration_tests/conftest.py @@ -62,6 +62,7 @@ def session_cloud(): ) cloud = platforms[integration_settings.PLATFORM]() + cloud.emit_settings_to_log() yield cloud cloud.destroy() diff --git a/tests/integration_tests/instances.py b/tests/integration_tests/instances.py index d64c1ab2..0db7c07b 100644 --- a/tests/integration_tests/instances.py +++ b/tests/integration_tests/instances.py @@ -29,17 +29,6 @@ class IntegrationInstance: self.instance = instance self.settings = settings - 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) - ] - ) - ) - def destroy(self): self.instance.delete() -- cgit v1.2.3 From 9707a08a82161cd4129f6cdd10978cde50bea747 Mon Sep 17 00:00:00 2001 From: James Falcon Date: Thu, 19 Nov 2020 11:54:35 -0600 Subject: Make mount in place for tests work (#667) IMAGE_SOURCE = 'IN_PLACE' wasn't working previously. Replaced LXD launch with an init, then mount, then start. --- tests/integration_tests/clouds.py | 44 ++++++++++++++++++++++++++++++++++-- tests/integration_tests/conftest.py | 2 +- tests/integration_tests/instances.py | 17 -------------- 3 files changed, 43 insertions(+), 20 deletions(-) (limited to 'tests/integration_tests') diff --git a/tests/integration_tests/clouds.py b/tests/integration_tests/clouds.py index fe89c0c6..2841261b 100644 --- a/tests/integration_tests/clouds.py +++ b/tests/integration_tests/clouds.py @@ -4,6 +4,8 @@ import logging from pycloudlib import EC2, GCE, Azure, OCI, LXD +import cloudinit +from cloudinit.subp import subp from tests.integration_tests import integration_settings from tests.integration_tests.instances import ( IntegrationEc2Instance, @@ -55,6 +57,11 @@ class IntegrationCloud(ABC): 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: @@ -77,8 +84,9 @@ class IntegrationCloud(ABC): "\n".join("{}={}".format(*item) for item in kwargs.items()) ) ) - pycloudlib_instance = self.cloud_instance.launch(**kwargs) - pycloudlib_instance.wait(raise_on_cloudinit_failure=False) + + pycloudlib_instance = self._perform_launch(kwargs) + log.info('Launched instance: %s', pycloudlib_instance) return self.get_instance(pycloudlib_instance, settings) @@ -141,3 +149,35 @@ class LxdContainerCloud(IntegrationCloud): def _get_cloud_instance(self): return LXD(tag='lxd-integration-test') + + def _perform_launch(self, launch_kwargs): + launch_kwargs['inst_type'] = launch_kwargs.pop('instance_type', None) + launch_kwargs.pop('wait') + + pycloudlib_instance = self.cloud_instance.init( + launch_kwargs.pop('name', None), + launch_kwargs.pop('image_id'), + **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 + + def _mount_source(self, instance): + container_path = '/usr/lib/python3/dist-packages/cloudinit' + format_variables = { + 'name': instance.name, + 'cloudinit_path': cloudinit.__path__[0], + 'container_path': container_path, + } + log.info( + 'Mounting source {cloudinit_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={cloudinit_path} ' + 'path={container_path}' + ).format(**format_variables) + subp(command.split()) diff --git a/tests/integration_tests/conftest.py b/tests/integration_tests/conftest.py index eacb2ae2..e31a9192 100644 --- a/tests/integration_tests/conftest.py +++ b/tests/integration_tests/conftest.py @@ -81,7 +81,7 @@ def setup_image(session_cloud): if session_cloud.datasource != 'lxd_container': raise ValueError( 'IN_PLACE as CLOUD_INIT_SOURCE only works for LXD') - # The mount needs to happen after the instance is launched, so + # 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() diff --git a/tests/integration_tests/instances.py b/tests/integration_tests/instances.py index 0db7c07b..67a6fb92 100644 --- a/tests/integration_tests/instances.py +++ b/tests/integration_tests/instances.py @@ -5,8 +5,6 @@ from tempfile import NamedTemporaryFile from pycloudlib.instance import BaseInstance -import cloudinit -from cloudinit.subp import subp from tests.integration_tests import integration_settings try: @@ -129,18 +127,3 @@ class IntegrationOciInstance(IntegrationInstance): class IntegrationLxdContainerInstance(IntegrationInstance): use_sudo = False - - def __init__(self, cloud: 'IntegrationCloud', instance: BaseInstance, - settings=integration_settings): - super().__init__(cloud, instance, settings) - if self.settings.CLOUD_INIT_SOURCE == 'IN_PLACE': - self._mount_source() - - def _mount_source(self): - command = ( - 'lxc config device add {name} host-cloud-init disk ' - 'source={cloudinit_path} ' - 'path=/usr/lib/python3/dist-packages/cloudinit' - ).format( - name=self.instance.name, cloudinit_path=cloudinit.__path__[0]) - subp(command.split()) -- cgit v1.2.3 From 5d4a9a4a50a496d27510f63217bcc0c25d9a8939 Mon Sep 17 00:00:00 2001 From: Daniel Watkins Date: Fri, 20 Nov 2020 14:12:54 -0500 Subject: add integration test for LP: #1900837 (#679) As the first test of this SRU cycle, this also introduces the sru_2020_11 mark to allow us to easily identify the set of tests generated for this SRU. --- tests/integration_tests/bugs/test_lp1900837.py | 28 ++++++++++++++++++++++++++ tox.ini | 1 + 2 files changed, 29 insertions(+) create mode 100644 tests/integration_tests/bugs/test_lp1900837.py (limited to 'tests/integration_tests') 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/tox.ini b/tox.ini index c86d38e9..066f923a 100644 --- a/tox.ini +++ b/tox.ini @@ -169,3 +169,4 @@ markers = lxd_container: test will only run in LXD container user_data: the user data to be passed to the test instance instance_name: the name to be used for the test instance + sru_2020_11: test is part of the 2020/11 SRU verification -- cgit v1.2.3 From 66b4be8b6da188a0667bd8c86a25155b6f4f3f6c Mon Sep 17 00:00:00 2001 From: Jonathan Lung Date: Fri, 20 Nov 2020 15:59:51 -0500 Subject: Support configuring SSH host certificates. (#660) Existing config writes keys to /etc/ssh after deleting files matching a glob that includes certificate files. Since sshd looks for certificates in the same directory as the keys, a host certificate must be placed in this directory. This update enables the certificate's contents to be specified along with the keys. Co-authored-by: jonathan lung Co-authored-by: jonathan lung --- cloudinit/config/cc_ssh.py | 31 +++++++--- cloudinit/config/tests/test_ssh.py | 68 ++++++++++++++++++++-- .../modules/test_ssh_keys_provided.py | 13 +++++ tools/.github-cla-signers | 1 + 4 files changed, 101 insertions(+), 12 deletions(-) (limited to 'tests/integration_tests') diff --git a/cloudinit/config/cc_ssh.py b/cloudinit/config/cc_ssh.py index 9b2a333a..05a16dbc 100755 --- a/cloudinit/config/cc_ssh.py +++ b/cloudinit/config/cc_ssh.py @@ -83,8 +83,9 @@ enabled by default. Host keys can be added using the ``ssh_keys`` configuration key. The argument to this config key should be a dictionary entries for the public and private keys of each desired key type. Entries in the ``ssh_keys`` config dict should -have keys in the format ``_private`` and ``_public``, -e.g. ``rsa_private: `` and ``rsa_public: ``. See below for supported +have keys in the format ``_private``, ``_public``, and, +optionally, ``_certificate``, e.g. ``rsa_private: ``, +``rsa_public: ``, and ``rsa_certificate: ``. See below for supported key types. Not all key types have to be specified, ones left unspecified will not be used. If this config option is used, then no keys will be generated. @@ -94,7 +95,8 @@ not be used. If this config option is used, then no keys will be generated. secure .. note:: - to specify multiline private host keys, use yaml multiline syntax + to specify multiline private host keys and certificates, use yaml + multiline syntax If no host keys are specified using ``ssh_keys``, then keys will be generated using ``ssh-keygen``. By default one public/private pair of each supported @@ -128,12 +130,17 @@ config flags are: ... -----END RSA PRIVATE KEY----- rsa_public: ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAGEAoPRhIfLvedSDKw7Xd ... + rsa_certificate: | + ssh-rsa-cert-v01@openssh.com AAAAIHNzaC1lZDI1NTE5LWNlcnQt ... dsa_private: | -----BEGIN DSA PRIVATE KEY----- MIIBxwIBAAJhAKD0YSHy73nUgysO13XsJmd4fHiFyQ+00R7VVu2iV9Qco ... -----END DSA PRIVATE KEY----- dsa_public: ssh-dsa AAAAB3NzaC1yc2EAAAABIwAAAGEAoPRhIfLvedSDKw7Xd ... + dsa_certificate: | + ssh-dsa-cert-v01@openssh.com AAAAIHNzaC1lZDI1NTE5LWNlcnQt ... + ssh_genkeytypes: disable_root: disable_root_opts: @@ -169,6 +176,8 @@ for k in GENERATE_KEY_NAMES: CONFIG_KEY_TO_FILE.update({"%s_private" % k: (KEY_FILE_TPL % k, 0o600)}) CONFIG_KEY_TO_FILE.update( {"%s_public" % k: (KEY_FILE_TPL % k + ".pub", 0o600)}) + CONFIG_KEY_TO_FILE.update( + {"%s_certificate" % k: (KEY_FILE_TPL % k + "-cert.pub", 0o600)}) PRIV_TO_PUB["%s_private" % k] = "%s_public" % k KEY_GEN_TPL = 'o=$(ssh-keygen -yf "%s") && echo "$o" root@localhost > "%s"' @@ -186,12 +195,18 @@ def handle(_name, cfg, cloud, log, _args): util.logexc(log, "Failed deleting key file %s", f) if "ssh_keys" in cfg: - # if there are keys in cloud-config, use them + # if there are keys and/or certificates in cloud-config, use them for (key, val) in cfg["ssh_keys"].items(): - if key in CONFIG_KEY_TO_FILE: - tgt_fn = CONFIG_KEY_TO_FILE[key][0] - tgt_perms = CONFIG_KEY_TO_FILE[key][1] - util.write_file(tgt_fn, val, tgt_perms) + # skip entry if unrecognized + if key not in CONFIG_KEY_TO_FILE: + continue + tgt_fn = CONFIG_KEY_TO_FILE[key][0] + tgt_perms = CONFIG_KEY_TO_FILE[key][1] + util.write_file(tgt_fn, val, tgt_perms) + # set server to present the most recently identified certificate + if '_certificate' in key: + cert_config = {'HostCertificate': tgt_fn} + ssh_util.update_ssh_config(cert_config) for (priv, pub) in PRIV_TO_PUB.items(): if pub in cfg['ssh_keys'] or priv not in cfg['ssh_keys']: diff --git a/cloudinit/config/tests/test_ssh.py b/cloudinit/config/tests/test_ssh.py index 0c554414..87ccdb60 100644 --- a/cloudinit/config/tests/test_ssh.py +++ b/cloudinit/config/tests/test_ssh.py @@ -10,6 +10,8 @@ import logging LOG = logging.getLogger(__name__) MODPATH = "cloudinit.config.cc_ssh." +KEY_NAMES_NO_DSA = [name for name in cc_ssh.GENERATE_KEY_NAMES + if name not in 'dsa'] @mock.patch(MODPATH + "ssh_util.setup_user_keys") @@ -25,7 +27,7 @@ class TestHandleSsh(CiTestCase): } self.test_hostkey_files = [] hostkey_tmpdir = self.tmp_dir() - for key_type in ['dsa', 'ecdsa', 'ed25519', 'rsa']: + for key_type in cc_ssh.GENERATE_KEY_NAMES: key_data = self.test_hostkeys[key_type] filename = 'ssh_host_%s_key.pub' % key_type filepath = os.path.join(hostkey_tmpdir, filename) @@ -223,7 +225,7 @@ class TestHandleSsh(CiTestCase): cfg = {} expected_call = [self.test_hostkeys[key_type] for key_type - in ['ecdsa', 'ed25519', 'rsa']] + in KEY_NAMES_NO_DSA] cc_ssh.handle("name", cfg, cloud, LOG, None) self.assertEqual([mock.call(expected_call)], cloud.datasource.publish_host_keys.call_args_list) @@ -252,7 +254,7 @@ class TestHandleSsh(CiTestCase): cfg = {'ssh_publish_hostkeys': {'enabled': True}} expected_call = [self.test_hostkeys[key_type] for key_type - in ['ecdsa', 'ed25519', 'rsa']] + in KEY_NAMES_NO_DSA] cc_ssh.handle("name", cfg, cloud, LOG, None) self.assertEqual([mock.call(expected_call)], cloud.datasource.publish_host_keys.call_args_list) @@ -339,7 +341,65 @@ class TestHandleSsh(CiTestCase): cfg = {'ssh_publish_hostkeys': {'enabled': True, 'blacklist': []}} expected_call = [self.test_hostkeys[key_type] for key_type - in ['dsa', 'ecdsa', 'ed25519', 'rsa']] + in cc_ssh.GENERATE_KEY_NAMES] cc_ssh.handle("name", cfg, cloud, LOG, None) self.assertEqual([mock.call(expected_call)], cloud.datasource.publish_host_keys.call_args_list) + + @mock.patch(MODPATH + "ug_util.normalize_users_groups") + @mock.patch(MODPATH + "util.write_file") + def test_handle_ssh_keys_in_cfg(self, m_write_file, m_nug, m_setup_keys): + """Test handle with ssh keys and certificate.""" + # Populate a config dictionary to pass to handle() as well + # as the expected file-writing calls. + cfg = {"ssh_keys": {}} + + expected_calls = [] + for key_type in cc_ssh.GENERATE_KEY_NAMES: + private_name = "{}_private".format(key_type) + public_name = "{}_public".format(key_type) + cert_name = "{}_certificate".format(key_type) + + # Actual key contents don"t have to be realistic + private_value = "{}_PRIVATE_KEY".format(key_type) + public_value = "{}_PUBLIC_KEY".format(key_type) + cert_value = "{}_CERT_KEY".format(key_type) + + cfg["ssh_keys"][private_name] = private_value + cfg["ssh_keys"][public_name] = public_value + cfg["ssh_keys"][cert_name] = cert_value + + expected_calls.extend([ + mock.call( + '/etc/ssh/ssh_host_{}_key'.format(key_type), + private_value, + 384 + ), + mock.call( + '/etc/ssh/ssh_host_{}_key.pub'.format(key_type), + public_value, + 384 + ), + mock.call( + '/etc/ssh/ssh_host_{}_key-cert.pub'.format(key_type), + cert_value, + 384 + ), + mock.call( + '/etc/ssh/sshd_config', + ('HostCertificate /etc/ssh/ssh_host_{}_key-cert.pub' + '\n'.format(key_type)), + preserve_mode=True + ) + ]) + + # Run the handler. + m_nug.return_value = ([], {}) + with mock.patch(MODPATH + 'ssh_util.parse_ssh_config', + return_value=[]): + cc_ssh.handle("name", cfg, self.tmp_cloud(distro='ubuntu'), + LOG, None) + + # Check that all expected output has been done. + for call_ in expected_calls: + self.assertIn(call_, m_write_file.call_args_list) diff --git a/tests/integration_tests/modules/test_ssh_keys_provided.py b/tests/integration_tests/modules/test_ssh_keys_provided.py index dc6d2fc1..27d193c1 100644 --- a/tests/integration_tests/modules/test_ssh_keys_provided.py +++ b/tests/integration_tests/modules/test_ssh_keys_provided.py @@ -45,6 +45,7 @@ ssh_keys: 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 @@ -108,6 +109,18 @@ class TestSshKeysProvided: "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") diff --git a/tools/.github-cla-signers b/tools/.github-cla-signers index b2464768..9b594a44 100644 --- a/tools/.github-cla-signers +++ b/tools/.github-cla-signers @@ -15,6 +15,7 @@ jqueuniet jsf9k landon912 lucasmoura +lungj manuelisimo marlluslustosa matthewruffell -- cgit v1.2.3 From e454dea5855019a5acdd6acafdef2ae07d069235 Mon Sep 17 00:00:00 2001 From: James Falcon Date: Mon, 23 Nov 2020 11:50:57 -0600 Subject: Integration test for fallocate falling back to dd (#681) See #585 --- tests/integration_tests/bugs/test_lp1897099.py | 31 ++++++++++++++++++++++++++ tests/integration_tests/conftest.py | 15 ++++++++++--- tox.ini | 1 + 3 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 tests/integration_tests/bugs/test_lp1897099.py (limited to 'tests/integration_tests') 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/conftest.py b/tests/integration_tests/conftest.py index e31a9192..54867096 100644 --- a/tests/integration_tests/conftest.py +++ b/tests/integration_tests/conftest.py @@ -37,11 +37,20 @@ def pytest_runtest_setup(item): specified, then we assume the test can be run anywhere. """ all_platforms = platforms.keys() - supported_platforms = set(all_platforms).intersection( - mark.name for mark in item.iter_markers()) + 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('Cannot run on platform {}'.format(current_platform)) + pytest.skip(unsupported_message) # disable_subp_usage is defined at a higher level, but we don't diff --git a/tox.ini b/tox.ini index 066f923a..f08e0f03 100644 --- a/tox.ini +++ b/tox.ini @@ -167,6 +167,7 @@ markers = azure: test will only run on Azure platform oci: test will only run on OCI platform lxd_container: test will only run in LXD container + no_container: test cannot run in a container user_data: the user data to be passed to the test instance instance_name: the name to be used for the test instance sru_2020_11: test is part of the 2020/11 SRU verification -- cgit v1.2.3 From 8a493bf08d8b09d4f3a35dae725756d157844201 Mon Sep 17 00:00:00 2001 From: James Falcon Date: Mon, 23 Nov 2020 12:12:52 -0600 Subject: LXD VM support in integration tests (#678) --- integration-requirements.txt | 2 +- tests/integration_tests/clouds.py | 78 +++++++++++++++++++++++++----------- tests/integration_tests/conftest.py | 4 +- tests/integration_tests/instances.py | 2 +- tox.ini | 1 + 5 files changed, 61 insertions(+), 26 deletions(-) (limited to 'tests/integration_tests') diff --git a/integration-requirements.txt b/integration-requirements.txt index 61d2e504..e8ddb648 100644 --- a/integration-requirements.txt +++ b/integration-requirements.txt @@ -1,5 +1,5 @@ # PyPI requirements for cloud-init integration testing # https://cloudinit.readthedocs.io/en/latest/topics/integration_tests.html # -pycloudlib @ git+https://github.com/canonical/pycloudlib.git@e6b2b3732a2a17d48bdf1167f56eb14576215d3c +pycloudlib @ git+https://github.com/canonical/pycloudlib.git@9211c0e5b34794595565d4626bc41ddbe14994f2 pytest diff --git a/tests/integration_tests/clouds.py b/tests/integration_tests/clouds.py index 2841261b..88ac4408 100644 --- a/tests/integration_tests/clouds.py +++ b/tests/integration_tests/clouds.py @@ -2,7 +2,8 @@ from abc import ABC, abstractmethod import logging -from pycloudlib import EC2, GCE, Azure, OCI, LXD +from pycloudlib import EC2, GCE, Azure, OCI, LXDContainer, LXDVirtualMachine +from pycloudlib.lxd.instance import LXDInstance import cloudinit from cloudinit.subp import subp @@ -12,7 +13,7 @@ from tests.integration_tests.instances import ( IntegrationGceInstance, IntegrationAzureInstance, IntegrationInstance, IntegrationOciInstance, - IntegrationLxdContainerInstance, + IntegrationLxdInstance, ) try: @@ -143,20 +144,48 @@ class OciCloud(IntegrationCloud): ) -class LxdContainerCloud(IntegrationCloud): - datasource = 'lxd_container' - integration_instance_cls = IntegrationLxdContainerInstance +class _LxdIntegrationCloud(IntegrationCloud): + integration_instance_cls = IntegrationLxdInstance def _get_cloud_instance(self): - return LXD(tag='lxd-integration-test') + 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), - launch_kwargs.pop('image_id'), + release, + profile_list=profile_list, **launch_kwargs ) if self.settings.CLOUD_INIT_SOURCE == 'IN_PLACE': @@ -165,19 +194,22 @@ class LxdContainerCloud(IntegrationCloud): pycloudlib_instance.wait(raise_on_cloudinit_failure=False) return pycloudlib_instance - def _mount_source(self, instance): - container_path = '/usr/lib/python3/dist-packages/cloudinit' - format_variables = { - 'name': instance.name, - 'cloudinit_path': cloudinit.__path__[0], - 'container_path': container_path, - } - log.info( - 'Mounting source {cloudinit_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={cloudinit_path} ' - 'path={container_path}' - ).format(**format_variables) - subp(command.split()) + +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 index 54867096..73b44bfc 100644 --- a/tests/integration_tests/conftest.py +++ b/tests/integration_tests/conftest.py @@ -12,6 +12,7 @@ from tests.integration_tests.clouds import ( AzureCloud, OciCloud, LxdContainerCloud, + LxdVmCloud, ) @@ -25,6 +26,7 @@ platforms = { 'azure': AzureCloud, 'oci': OciCloud, 'lxd_container': LxdContainerCloud, + 'lxd_vm': LxdVmCloud, } @@ -87,7 +89,7 @@ def setup_image(session_cloud): if integration_settings.CLOUD_INIT_SOURCE == 'NONE': pass # that was easy elif integration_settings.CLOUD_INIT_SOURCE == 'IN_PLACE': - if session_cloud.datasource != 'lxd_container': + 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 diff --git a/tests/integration_tests/instances.py b/tests/integration_tests/instances.py index 67a6fb92..ca0b38d5 100644 --- a/tests/integration_tests/instances.py +++ b/tests/integration_tests/instances.py @@ -125,5 +125,5 @@ class IntegrationOciInstance(IntegrationInstance): pass -class IntegrationLxdContainerInstance(IntegrationInstance): +class IntegrationLxdInstance(IntegrationInstance): use_sudo = False diff --git a/tox.ini b/tox.ini index f08e0f03..30b11398 100644 --- a/tox.ini +++ b/tox.ini @@ -167,6 +167,7 @@ markers = azure: test will only run on Azure platform oci: test will only run on OCI platform lxd_container: test will only run in LXD container + lxd_vm: test will only run in LXD VM no_container: test cannot run in a container user_data: the user data to be passed to the test instance instance_name: the name to be used for the test instance -- cgit v1.2.3 From 6e86d2a5649b3a9113923c73154ebf02224732a6 Mon Sep 17 00:00:00 2001 From: James Falcon Date: Mon, 23 Nov 2020 15:52:19 -0600 Subject: Ensure proper root permissions in integration tests (#664) Tests previously assumed that when executing commands and transferring files that user will have root permissions. This change updated integration testing infrastructure so that is true. --- integration-requirements.txt | 2 +- tests/integration_tests/instances.py | 45 +++++++++++++++++----- .../integration_tests/modules/test_users_groups.py | 5 ++- 3 files changed, 40 insertions(+), 12 deletions(-) (limited to 'tests/integration_tests') diff --git a/integration-requirements.txt b/integration-requirements.txt index e8ddb648..3648a0f1 100644 --- a/integration-requirements.txt +++ b/integration-requirements.txt @@ -1,5 +1,5 @@ # PyPI requirements for cloud-init integration testing # https://cloudinit.readthedocs.io/en/latest/topics/integration_tests.html # -pycloudlib @ git+https://github.com/canonical/pycloudlib.git@9211c0e5b34794595565d4626bc41ddbe14994f2 +pycloudlib @ git+https://github.com/canonical/pycloudlib.git@4b8d2cd5ac6316810ce16d081842da575625ca4f pytest diff --git a/tests/integration_tests/instances.py b/tests/integration_tests/instances.py index ca0b38d5..9b13288c 100644 --- a/tests/integration_tests/instances.py +++ b/tests/integration_tests/instances.py @@ -1,9 +1,11 @@ # 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 @@ -18,6 +20,11 @@ except ImportError: 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 @@ -30,21 +37,39 @@ class IntegrationInstance: def destroy(self): self.instance.delete() - def execute(self, command): - return self.instance.execute(command) + 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_file, local_file): - self.instance.pull_file(remote_file, local_file) + 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): - self.instance.push_file(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: - tmp_file = NamedTemporaryFile('r') - self.pull_file(remote_path, tmp_file.name) - with tmp_file as f: - contents = f.read() - return contents + 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 diff --git a/tests/integration_tests/modules/test_users_groups.py b/tests/integration_tests/modules/test_users_groups.py index 6a085a8f..6a51f5a6 100644 --- a/tests/integration_tests/modules/test_users_groups.py +++ b/tests/integration_tests/modules/test_users_groups.py @@ -70,7 +70,10 @@ class TestUsersGroups: 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 + 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.""" -- cgit v1.2.3