Просмотр исходного кода

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.
Claudiu Belu 3 недель назад
Родитель
Сommit
a766c33917

+ 5 - 0
coriolis/tests/integration/base.py

@@ -230,6 +230,7 @@ class CoriolisIntegrationTestBase(test_base.CoriolisBaseTestCase):
 class ReplicaIntegrationTestBase(CoriolisIntegrationTestBase):
 
     _CREATE_MINION_POOLS = False
+    _SCSI_DEBUG_SIZE_MB = 16
 
     @classmethod
     def setUpClass(cls):
@@ -280,6 +281,10 @@ class ReplicaIntegrationTestBase(CoriolisIntegrationTestBase):
                     "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()
 

+ 0 - 0
coriolis/tests/integration/test_deployment.py → coriolis/tests/integration/deployments/test_deployment.py


+ 42 - 0
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",
+        )

+ 2 - 0
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 \

+ 0 - 1
coriolis/tests/integration/harness.py

@@ -298,7 +298,6 @@ class _IntegrationHarness:
             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()

+ 0 - 0
coriolis/tests/integration/providers/__init__.py


+ 49 - 16
coriolis/tests/integration/test_provider/imp.py

@@ -24,6 +24,7 @@ from coriolis.providers.base import BaseEndpointStorageProvider
 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 @@ class TestImportProvider(
 
     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 @@ class TestImportProvider(
         }
 
     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 @@ class TestImportProvider(
             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 @@ class TestImportProvider(
                 "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 @@ class TestImportProvider(
     # 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
 

+ 10 - 0
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,
+]

+ 18 - 0
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)],
+    }

+ 68 - 5
coriolis/tests/integration/utils.py

@@ -30,6 +30,12 @@ _SCSI_DEBUG_ADD_HOST = "/sys/bus/pseudo/drivers/scsi_debug/add_host"
 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])