From 404f0a4a6542cdc721901d149ac981a81199aa79 Mon Sep 17 00:00:00 2001 From: James Falcon 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/instances.py | 157 +++++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 tests/integration_tests/instances.py (limited to 'tests/integration_tests/instances.py') 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()) -- 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/instances.py') 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/instances.py') 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 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/instances.py') 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/instances.py') 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