From 9515dfe6ad7926f2105162370332b642724cc35a Mon Sep 17 00:00:00 2001 From: Claudiu Belu Date: Wed, 13 May 2026 16:16:03 +0000 Subject: [PATCH 1/2] chore: Shorten the integration test providers path --- coriolis/tests/integration/harness.py | 4 ++-- coriolis/tests/integration/test_deployment.py | 2 +- coriolis/tests/integration/test_failure_recovery.py | 2 +- .../integration/{providers => }/test_provider/__init__.py | 0 .../tests/integration/{providers => }/test_provider/exp.py | 0 .../tests/integration/{providers => }/test_provider/imp.py | 0 coriolis/tests/integration/transfers/test_executions.py | 2 +- 7 files changed, 5 insertions(+), 5 deletions(-) rename coriolis/tests/integration/{providers => }/test_provider/__init__.py (100%) rename coriolis/tests/integration/{providers => }/test_provider/exp.py (100%) rename coriolis/tests/integration/{providers => }/test_provider/imp.py (100%) diff --git a/coriolis/tests/integration/harness.py b/coriolis/tests/integration/harness.py index 07206660..4f5f7919 100644 --- a/coriolis/tests/integration/harness.py +++ b/coriolis/tests/integration/harness.py @@ -66,10 +66,10 @@ # Dotted paths to the export (source) and import (destination) provider # classes. _TEST_EXPORT_PROVIDER = ( - "coriolis.tests.integration.providers.test_provider.exp.TestExportProvider" + "coriolis.tests.integration.test_provider.exp.TestExportProvider" ) _TEST_IMPORT_PROVIDER = ( - "coriolis.tests.integration.providers.test_provider.imp.TestImportProvider" + "coriolis.tests.integration.test_provider.imp.TestImportProvider" ) # Fixed project used for all test requests. diff --git a/coriolis/tests/integration/test_deployment.py b/coriolis/tests/integration/test_deployment.py index 79a1b09b..de6d293c 100644 --- a/coriolis/tests/integration/test_deployment.py +++ b/coriolis/tests/integration/test_deployment.py @@ -15,7 +15,7 @@ from coriolis import constants from coriolis.tests.integration import base -from coriolis.tests.integration.providers.test_provider import imp +from coriolis.tests.integration.test_provider import imp class ReplicaDeploymentIntegrationTest(base.ReplicaIntegrationTestBase): diff --git a/coriolis/tests/integration/test_failure_recovery.py b/coriolis/tests/integration/test_failure_recovery.py index cbda7ebd..86ca4874 100644 --- a/coriolis/tests/integration/test_failure_recovery.py +++ b/coriolis/tests/integration/test_failure_recovery.py @@ -16,7 +16,7 @@ from unittest import mock from coriolis.tests.integration import base -from coriolis.tests.integration.providers.test_provider import imp +from coriolis.tests.integration.test_provider import imp class TransferFailureIntegrationTest(base.ReplicaIntegrationTestBase): diff --git a/coriolis/tests/integration/providers/test_provider/__init__.py b/coriolis/tests/integration/test_provider/__init__.py similarity index 100% rename from coriolis/tests/integration/providers/test_provider/__init__.py rename to coriolis/tests/integration/test_provider/__init__.py diff --git a/coriolis/tests/integration/providers/test_provider/exp.py b/coriolis/tests/integration/test_provider/exp.py similarity index 100% rename from coriolis/tests/integration/providers/test_provider/exp.py rename to coriolis/tests/integration/test_provider/exp.py diff --git a/coriolis/tests/integration/providers/test_provider/imp.py b/coriolis/tests/integration/test_provider/imp.py similarity index 100% rename from coriolis/tests/integration/providers/test_provider/imp.py rename to coriolis/tests/integration/test_provider/imp.py diff --git a/coriolis/tests/integration/transfers/test_executions.py b/coriolis/tests/integration/transfers/test_executions.py index f77576e2..2f754205 100644 --- a/coriolis/tests/integration/transfers/test_executions.py +++ b/coriolis/tests/integration/transfers/test_executions.py @@ -7,7 +7,7 @@ from coriolis import constants from coriolis.tests.integration import base -from coriolis.tests.integration.providers.test_provider import imp +from coriolis.tests.integration.test_provider import imp class TransferExecutionsTests(base.ReplicaIntegrationTestBase): From 9307c3f4d909339afb5d34de705b5335f65fbb65 Mon Sep 17 00:00:00 2001 From: Claudiu Belu Date: Wed, 22 Apr 2026 12:12:43 +0000 Subject: [PATCH 2/2] integration: Adds OS morphing deployment integration test Replace the stub `deploy_os_morphing_resources` / `delete_os_morphing_resources` with a real implementation that starts a container with openssh-server, injects the test public key via bind mount, and returns SSH connection info for the OS morphing pipeline. Adds OS morphing test, in which we prepare a source disk with an Ubuntu filesystem, based on the Ubuntu container image. The test expects that a package (jq) will be present after the OS morphing process. --- coriolis/tests/integration/base.py | 5 ++ .../{ => deployments}/test_deployment.py | 0 .../deployments/test_osmorphing.py | 42 +++++++++++ .../dockerfiles/data-minion/Dockerfile | 2 + coriolis/tests/integration/harness.py | 1 - .../tests/integration/providers/__init__.py | 0 .../tests/integration/test_provider/imp.py | 65 +++++++++++++---- .../test_provider/osmorphing/__init__.py | 10 +++ .../test_provider/osmorphing/ubuntu.py | 18 +++++ coriolis/tests/integration/utils.py | 73 +++++++++++++++++-- 10 files changed, 194 insertions(+), 22 deletions(-) rename coriolis/tests/integration/{ => deployments}/test_deployment.py (100%) create mode 100644 coriolis/tests/integration/deployments/test_osmorphing.py delete mode 100644 coriolis/tests/integration/providers/__init__.py create mode 100644 coriolis/tests/integration/test_provider/osmorphing/__init__.py create mode 100644 coriolis/tests/integration/test_provider/osmorphing/ubuntu.py diff --git a/coriolis/tests/integration/base.py b/coriolis/tests/integration/base.py index 97c843d9..077d680c 100644 --- a/coriolis/tests/integration/base.py +++ b/coriolis/tests/integration/base.py @@ -230,6 +230,7 @@ def f(*args, **kwargs): class ReplicaIntegrationTestBase(CoriolisIntegrationTestBase): _CREATE_MINION_POOLS = False + _SCSI_DEBUG_SIZE_MB = 16 @classmethod def setUpClass(cls): @@ -280,6 +281,10 @@ def setUpClass(cls): "Pool did not reach ALLOCATED (got %s)" % pool_obj.status, ) + # (re)init the scsi_debug module. + test_utils.destroy_scsi_debug() + test_utils.init_scsi_debug(size_mb=cls._SCSI_DEBUG_SIZE_MB) + def setUp(self): super().setUp() diff --git a/coriolis/tests/integration/test_deployment.py b/coriolis/tests/integration/deployments/test_deployment.py similarity index 100% rename from coriolis/tests/integration/test_deployment.py rename to coriolis/tests/integration/deployments/test_deployment.py diff --git a/coriolis/tests/integration/deployments/test_osmorphing.py b/coriolis/tests/integration/deployments/test_osmorphing.py new file mode 100644 index 00000000..54384b72 --- /dev/null +++ b/coriolis/tests/integration/deployments/test_osmorphing.py @@ -0,0 +1,42 @@ +# Copyright 2026 Cloudbase Solutions Srl +# All Rights Reserved. + +"""Integration tests for the OS morphing deployments. + +Exercises deployments with skip_os_morphing=False, OS detection, and package +installation in the target OS. +""" + +from coriolis.tests.integration import base as integration_base +from coriolis.tests.integration import utils as test_utils + + +class OsMorphingDeploymentTest(integration_base.ReplicaIntegrationTestBase): + + # NOTE(claudiub): Size must be high enough to contain the tested OS and + # any new packages to be added during OS morphing. + _SCSI_DEBUG_SIZE_MB = 256 + + def setUp(self): + super().setUp() + test_utils.write_os_image_to_disk(self._src_device, "ubuntu:24.04") + + def test_deployment_with_os_morphing(self): + self.assertFalse( + test_utils.path_exists_on_device(self._src_device, "usr/bin/jq"), + "jq was found on the source device before OS morphing", + ) + + self._execute_and_wait(self._transfer.id) + + deployment = self._client.deployments.create_from_transfer( + self._transfer.id, + skip_os_morphing=False, + ) + self.addCleanup(self._client.deployments.delete, deployment.id) + + self.assertDeploymentCompleted(deployment.id) + self.assertTrue( + test_utils.path_exists_on_device(self._dst_device, "usr/bin/jq"), + "jq was not found on the destination device after OS morphing", + ) diff --git a/coriolis/tests/integration/dockerfiles/data-minion/Dockerfile b/coriolis/tests/integration/dockerfiles/data-minion/Dockerfile index 3781bd81..63828d47 100644 --- a/coriolis/tests/integration/dockerfiles/data-minion/Dockerfile +++ b/coriolis/tests/integration/dockerfiles/data-minion/Dockerfile @@ -5,8 +5,10 @@ FROM ubuntu:24.04 # dbus is required for systemd to fully manage units; # sudo is used by replicator / writer setup. +# kmod is required during OS morphing (modprobe is being called). RUN apt-get update && apt-get install -y --no-install-recommends \ dbus \ + kmod \ openssh-server \ sudo \ systemd \ diff --git a/coriolis/tests/integration/harness.py b/coriolis/tests/integration/harness.py index 4f5f7919..00f44e00 100644 --- a/coriolis/tests/integration/harness.py +++ b/coriolis/tests/integration/harness.py @@ -298,7 +298,6 @@ def __init__(self): group='minion_manager') coriolis_utils.setup_logging() - test_utils.init_scsi_debug() # Policy enforcer: reset so it re-reads the new CONF (no policy file). policy_module.reset() diff --git a/coriolis/tests/integration/providers/__init__.py b/coriolis/tests/integration/providers/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/coriolis/tests/integration/test_provider/imp.py b/coriolis/tests/integration/test_provider/imp.py index e6c9abff..b37d4c78 100644 --- a/coriolis/tests/integration/test_provider/imp.py +++ b/coriolis/tests/integration/test_provider/imp.py @@ -24,6 +24,7 @@ from coriolis.providers.base import BaseReplicaImportProvider from coriolis.providers.base import BaseReplicaImportValidationProvider from coriolis.providers.base import BaseUpdateDestinationReplicaProvider +from coriolis.tests.integration.test_provider import osmorphing from coriolis.tests.integration import utils as test_utils from coriolis import utils as coriolis_utils @@ -155,8 +156,9 @@ def deploy_replica_disks( def deploy_replica_target_resources( self, ctxt, connection_info, target_environment, volumes_info): + devices = [vol["volume_dev"] for vol in volumes_info] result = self._create_minion( - "coriolis-writer", connection_info, volumes_info) + "coriolis-writer", connection_info, devices) return { "volumes_info": volumes_info, @@ -165,10 +167,9 @@ def deploy_replica_target_resources( } def _create_minion( - self, name_prefix, connection_info, volumes_info, - device_cgroup_rules=None): + self, name_prefix, connection_info, devices=None, volumes=None, + device_cgroup_rules=None, setup_writer=True): pkey_path = connection_info["pkey_path"] - dest_devices = [vol["volume_dev"] for vol in volumes_info] container_name = "%s-%s" % (name_prefix, uuid.uuid4().hex[:8]) container_id = test_utils.run_container( @@ -176,7 +177,8 @@ def _create_minion( container_name, is_systemd=True, ssh_key=f"{pkey_path}.pub", - devices=dest_devices, + devices=devices, + volumes=volumes, device_cgroup_rules=device_cgroup_rules, ) @@ -189,20 +191,23 @@ def _create_minion( "ip": container_ip, "port": 22, "username": "root", - "pkey": pkey, + "pkey": coriolis_utils.serialize_key(pkey), } - bootstrapper = backup_writers.HTTPBackupWriterBootstrapper( - ssh_conn_info, WRITER_TEST_PORT) - writer_conn_details = bootstrapper.setup_writer() - return { + info = { "container_id": container_id, "ssh_connection_info": ssh_conn_info, - "backup_writer_connection_info": { + } + if setup_writer: + bootstrapper = backup_writers.HTTPBackupWriterBootstrapper( + ssh_conn_info, WRITER_TEST_PORT) + writer_conn_details = bootstrapper.setup_writer() + info["backup_writer_connection_info"] = { "backend": "http_backup_writer", "connection_details": writer_conn_details, - }, - } + } + + return info except Exception: test_utils.remove_container(container_id) raise @@ -265,19 +270,47 @@ def cleanup_failed_replica_instance_deployment( # BaseInstanceProvider def get_os_morphing_tools(self, os_type, osmorphing_info): - return [] + return osmorphing.OS_MORPHERS # BaseImportInstanceProvider def deploy_os_morphing_resources( self, ctxt, connection_info, target_environment, instance_deployment_info): - return {} + devices = list(target_environment.get("devices", [])) + + # lsblk inside the container sees all the host block devices because + # Docker containers share the host kernel's sysfs (/sys/block/). + # Populate ignore_devices with every host disk except the target + # so osmorphing only considers the devices we actually attached. + ignore_devices = list( + test_utils.get_host_disk_devices() - set(devices) + ) + + # Mount the host's /lib/modules tree so that modprobe can + # resolve built-in modules. + volumes = ["/lib/modules:/lib/modules:ro"] + result = self._create_minion( + "coriolis-osmorphing", connection_info, devices, + volumes, setup_writer=False, + ) + + return { + "os_morphing_resources": {"container_id": result["container_id"]}, + "osmorphing_connection_info": result["ssh_connection_info"], + "osmorphing_info": { + "os_type": instance_deployment_info.get("os_type", "linux"), + "ignore_devices": ignore_devices, + }, + } def delete_os_morphing_resources( self, ctxt, connection_info, target_environment, os_morphing_resources): - pass + if os_morphing_resources: + container_id = os_morphing_resources.get("container_id") + if container_id: + test_utils.remove_container(container_id) # BaseReplicaImportValidationProvider diff --git a/coriolis/tests/integration/test_provider/osmorphing/__init__.py b/coriolis/tests/integration/test_provider/osmorphing/__init__.py new file mode 100644 index 00000000..13ddcd8f --- /dev/null +++ b/coriolis/tests/integration/test_provider/osmorphing/__init__.py @@ -0,0 +1,10 @@ +# Copyright 2026 Cloudbase Solutions Srl +# All Rights Reserved. + +from coriolis.osmorphing import base +from coriolis.tests.integration.test_provider.osmorphing import ubuntu + + +OS_MORPHERS: list[base.BaseLinuxOSMorphingTools] = [ + ubuntu.TestUbuntuOSMorphingTools, +] diff --git a/coriolis/tests/integration/test_provider/osmorphing/ubuntu.py b/coriolis/tests/integration/test_provider/osmorphing/ubuntu.py new file mode 100644 index 00000000..2c96d471 --- /dev/null +++ b/coriolis/tests/integration/test_provider/osmorphing/ubuntu.py @@ -0,0 +1,18 @@ +# Copyright 2026 Cloudbase Solutions Srl +# All Rights Reserved. + +""" +Ubuntu OS Morphing tools. +""" + +from coriolis.osmorphing import ubuntu + + +class TestUbuntuOSMorphingTools(ubuntu.BaseUbuntuMorphingTools): + """Ubuntu OSMorphing tools for integration tests.""" + + # Package meant to be installed during OS morphing. + # jq is a very small package which is not available by default. + _packages = { + None: [("jq", True)], + } diff --git a/coriolis/tests/integration/utils.py b/coriolis/tests/integration/utils.py index 8bbc8d2f..d954e53d 100644 --- a/coriolis/tests/integration/utils.py +++ b/coriolis/tests/integration/utils.py @@ -30,6 +30,12 @@ DATA_MINION_IMAGE = "coriolis-data-minion:test" +def get_host_disk_devices() -> set: + """Return the /dev paths of disk-type block devices visible on the host.""" + disk_names = _lsblk_disk_names() + return {"/dev/" + disk_name for disk_name in disk_names} + + def _lsblk_disk_names() -> set: """Return the set of disk-type block device names visible to lsblk.""" result = _run(["lsblk", "-Jb", "-o", "NAME,TYPE"], check=False) @@ -62,12 +68,13 @@ def _poll_for_new_disks(before, count, timeout=_SETTLE_TIMEOUT): ) -def init_scsi_debug(size_mb=64): - """Load scsi_debug with per_host_store=1. +def init_scsi_debug(size_mb=16): + """Load scsi_debug with per_host_store=1 and size_mb per device. - Must be called once per process before any ``add_scsi_debug_device`` - calls. With ``per_host_store=1`` every host added via the sysfs knob - gets its own independent backing store, so devices never share storage. + Call ``destroy_scsi_debug`` first if the module is already loaded with a + different size. With ``per_host_store=1`` every host added via the sysfs + knob gets its own independent backing store, so devices never share + storage. """ _run([ "modprobe", @@ -303,3 +310,59 @@ def unplug_device_from_container(container_id, device_path): "nsenter", "--target", str(pid), "--mount", "--", "rm", "-f", device_path, ], check=False) + + +# OS Morphing utils + + +def write_os_image_to_disk(device_path, container_image): + """Write a real Linux rootfs to *device_path*. + + Exports the filesystem of a container image via ``docker export`` and + extracts it onto an ext4-formatted device, giving a chroot-able root with + that container OS' standard filesystem and binaries present. + """ + _run(["mkfs.ext4", "-F", device_path]) + + result = _run(["docker", "create", container_image]) + container_id = result.stdout.decode().strip() + + try: + with tempfile.TemporaryDirectory() as mount_point: + _run(["mount", device_path, mount_point]) + + try: + export = subprocess.Popen( + ["docker", "export", container_id], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) + subprocess.run( + ["tar", "-x", "-C", mount_point], + stdin=export.stdout, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=True, + ) + export.stdout.close() + export.wait() + finally: + _run(["umount", mount_point]) + + finally: + _run(["docker", "rm", "-f", container_id], check=False) + + +def path_exists_on_device(device_path, rel_path): + """Checks if *path* exists on the filesystem of *device_path*. + + Mounts the device read-only into a temporary directory, checks for the + path, then unmounts. + """ + with tempfile.TemporaryDirectory() as mount_point: + _run(["mount", "-o", "ro", device_path, mount_point]) + + try: + return os.path.exists(os.path.join(mount_point, rel_path)) + finally: + _run(["umount", mount_point])