فهرست منبع

integration: Adds LUKS OS morphing test

Adds `cryptsetup` to the `data-minion` Dockerfile. Required by osmount
LUKS unlock / lock; `cryptsetup` `luksOpen` / `luksClose` are called
over SSH on the morphing container.

Adds `make_luks_device` to `test_utils.py`: formats the device with LUKS,
opens it, writes a minimal Linux OS tree inside via make_os_device(),
then closes the mapper.

Adds integration test in which the source disk is LUKS-encrypted. The
test runs a full transfer + deployment with skip_os_morphing=False, and
asserts that it completed.
Claudiu Belu 1 ماه پیش
والد
کامیت
72ea2cd492

+ 1 - 1
coriolis/conductor/rpc/server.py

@@ -1433,7 +1433,7 @@ class ConductorServerEndpoint(object):
     def _check_valid_transfer_tasks_execution(transfer, force=False):
         sorted_executions = sorted(
             transfer.executions, key=lambda e: e.number, reverse=True)
-        if not sorted_executions:
+        if not sorted_executions and not force:
             raise exception.InvalidTransferState(
                 "The Transfer has never been executed.")
 

+ 14 - 4
coriolis/osmorphing/osmount/base.py

@@ -430,16 +430,26 @@ class BaseLinuxOSMountTools(luks_mixin.LinuxLUKSMixin, BaseSSHOSMountTools):
         #   where 'ln -s /dev/dm-N /dev/<VG-name>/<LV-name>'
         # Querying for the kernel device name (KNAME) should ensure we get the
         # device names we desire both for physical and logical volumes.
-        volume_devs = self._exec_cmd("lsblk -lnao KNAME").splitlines()
-        LOG.debug("All block devices: %s", str(volume_devs))
+        raw = self._exec_cmd("lsblk -lnao KNAME,TYPE")
+        LOG.debug("All block devices: %s", raw)
 
-        volume_devs = ["/dev/%s" % d for d in volume_devs if
-                       not re.match(r"^.*\d+$", d)]
+        # Exclude partitions; each line is "<kname> <type>".
+        volume_devs = []
+        for line in raw.splitlines():
+            parts = line.split()
+            if len(parts) >= 2 and parts[1] != "part":
+                volume_devs.append("/dev/%s" % parts[0])
 
         LOG.debug("Ignoring block devices: %s", self._ignore_devices)
         volume_devs = [d for d in volume_devs if d
                        not in self._ignore_devices]
 
+        # lsblk reads sysfs, which is shared with the host inside containers,
+        # so it may list devices that have no /dev node here. Drop any such
+        # phantom entries.
+        volume_devs = [d for d in volume_devs
+                       if utils.test_ssh_path(self._ssh, d)]
+
         LOG.info("Volume block devices: %s", volume_devs)
         return volume_devs
 

+ 1 - 1
coriolis/osmorphing/redhat.py

@@ -120,7 +120,7 @@ class BaseRedHatMorphingTools(base.BaseLinuxOSMorphingTools):
             self._write_config_file(network_cfg_file, network_cfg)
 
     def _write_nic_configs(self, nics_info):
-        for idx, _ in enumerate(nics_info):
+        for idx, _ in enumerate(nics_info or []):
             dev_name = "eth%d" % idx
             cfg_path = "etc/sysconfig/network-scripts/ifcfg-%s" % dev_name
             if self._test_path(cfg_path):

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

@@ -372,6 +372,18 @@ class ReplicaIntegrationTestBase(CoriolisIntegrationTestBase):
             transfer_id, shutdown_instances=False)
         self.assertExecutionCompleted(execution.id, timeout=timeout)
 
+    def _execute_transfer_and_deployment(self, deployment_kwargs=None):
+        deployment_kwargs = deployment_kwargs or {}
+
+        self._execute_and_wait(self._transfer.id)
+        deployment = self._client.deployments.create_from_transfer(
+            self._transfer.id,
+            skip_os_morphing=False,
+            **deployment_kwargs,
+        )
+        self.addCleanup(self._cleanup_deployment, deployment.id)
+        self.assertDeploymentCompleted(deployment.id)
+
     def _cleanup_execution(self, transfer_id, execution_id):
         """Cancel a running execution if needed, then delete it.
 

+ 138 - 0
coriolis/tests/integration/deployments/test_luks_osmorphing.py

@@ -0,0 +1,138 @@
+# Copyright 2026 Cloudbase Solutions Srl
+# All Rights Reserved.
+
+"""Integration tests for LUKS-encrypted OS morphing deployments.
+
+The source disk is formatted with LUKS and contains a minimal Linux OS
+inside. The transfer copies the raw encrypted chunks to the destination
+device. During OS morphing, the osmount layer detects the LUKS container,
+unlocks it with the supplied passphrase, mounts the filesystem, and morphs
+it. OS families tested:
+
+- Ubuntu 24.04 (initramfs-based): initramfs is regenerated via
+  update-initramfs.
+- Rocky Linux 9 (dracut-based): initramfs is regenerated via dracut.
+
+Must be run as root; requires the scsi_debug kernel module and cryptsetup.
+"""
+
+import os
+import tempfile
+import unittest
+
+from coriolis import constants
+from coriolis.tests.integration import base as integration_base
+from coriolis.tests.integration import harness as integration_harness
+from coriolis.tests.integration import utils as test_utils
+
+_LUKS_PASSPHRASE = "it-luks-encrypted"
+
+
+class _LUKSOSMorphingMixin:
+
+    # Extra space for initramfs-tools and cryptsetup-initramfs packages that
+    # the LUKS morphing tools install on top of the base OS image.
+    _SCSI_DEBUG_SIZE_MB = 512
+
+    @classmethod
+    def setUpClass(cls):
+        harness = integration_harness._IntegrationHarness.get()
+        if not harness.uses_core_test_import_provider():
+            raise unittest.SkipTest(
+                "OS morphing tests require local disk access")
+        super().setUpClass()
+
+    def setUp(self):
+        with tempfile.NamedTemporaryFile(
+                mode="w", suffix=".key", delete=False) as fh:
+            self._key_file = fh.name
+            fh.write(_LUKS_PASSPHRASE)
+        self.addCleanup(os.unlink, self._key_file)
+
+        super().setUp()
+        self._prepare_src_device()
+
+    def _prepare_src_device(self):
+        test_utils.make_luks_device(
+            self._src_device, self._key_file, "ubuntu:24.04")
+
+        dest_env = {
+            "devices": [self._dst_device],
+            constants.ENCRYPTED_DISKS_PASS: _LUKS_PASSPHRASE,
+        }
+        self._client.transfers.update(
+            self._transfer.id,
+            {"destination_environment": dest_env},
+        )
+
+    def _check_path_exists(self, device, path):
+        with test_utils.luks_open(device, self._key_file) as mapper_path:
+            return test_utils.path_exists_on_device(mapper_path, path)
+
+    def _assert_luks_common_firstboot_files(self):
+        dst_basename = os.path.basename(self._dst_device)
+        for path in [
+            "usr/local/sbin/coriolis-luks-firstboot.sh",
+            "etc/systemd/system/coriolis-luks-firstboot.service",
+            "etc/systemd/system/multi-user.target.wants/"
+            "coriolis-luks-firstboot.service",
+            "etc/luks/coriolis_%s.key" % dst_basename,
+        ]:
+            self.assertTrue(
+                self._check_path_exists(self._dst_device, path),
+                "%s not found after LUKS OS morphing" % path,
+            )
+
+    def test_deployment_with_os_morphing(self):
+        self.assertFalse(
+            self._check_path_exists(self._src_device, "usr/bin/jq"),
+            "jq was found on the source device before OS morphing",
+        )
+
+        self._execute_transfer_and_deployment()
+
+        self.assertTrue(
+            self._check_path_exists(self._dst_device, "usr/bin/jq"),
+            "jq was not found on the destination device after OS morphing",
+        )
+        self._assert_firstboot_setup()
+
+
+class LUKSOSMorphingDeploymentTest(
+        _LUKSOSMorphingMixin, integration_base.ReplicaIntegrationTestBase):
+    """LUKS + initramfs OS morphing test using Ubuntu 24.04."""
+
+    def _assert_firstboot_setup(self):
+        self._assert_luks_common_firstboot_files()
+        self.assertTrue(
+            self._check_path_exists(
+                self._dst_device, "etc/cryptsetup-initramfs/conf-hook"),
+            "cryptsetup-initramfs conf-hook not found after LUKS OS morphing",
+        )
+
+
+class LUKSRockyLinuxOSMorphingDeploymentTest(
+        _LUKSOSMorphingMixin, integration_base.ReplicaIntegrationTestBase):
+    """LUKS + dracut OS morphing test using Rocky Linux 9."""
+
+    def _prepare_src_device(self):
+        test_utils.make_luks_device(
+            self._src_device, self._key_file, "rockylinux:9")
+
+        dest_env = {
+            "devices": [self._dst_device],
+            constants.ENCRYPTED_DISKS_PASS: _LUKS_PASSPHRASE,
+        }
+        self._client.transfers.update(
+            self._transfer.id,
+            {"destination_environment": dest_env},
+        )
+
+    def _assert_firstboot_setup(self):
+        self._assert_luks_common_firstboot_files()
+        self.assertTrue(
+            self._check_path_exists(
+                self._dst_device,
+                "etc/dracut.conf.d/99-coriolis-luks.conf"),
+            "dracut LUKS keyfile config not found after LUKS OS morphing",
+        )

+ 0 - 12
coriolis/tests/integration/deployments/test_osmorphing.py

@@ -35,18 +35,6 @@ class OsMorphingDeploymentTest(integration_base.ReplicaIntegrationTestBase):
         super().setUp()
         test_utils.write_os_image_to_disk(self._src_device, "ubuntu:24.04")
 
-    def _execute_transfer_and_deployment(self, deployment_kwargs=None):
-        deployment_kwargs = deployment_kwargs or {}
-
-        self._execute_and_wait(self._transfer.id)
-        deployment = self._client.deployments.create_from_transfer(
-            self._transfer.id,
-            skip_os_morphing=False,
-            **deployment_kwargs,
-        )
-        self.addCleanup(self._cleanup_deployment, deployment.id)
-        self.assertDeploymentCompleted(deployment.id)
-
     def test_deployment_with_os_morphing(self):
         self.assertFalse(
             test_utils.path_exists_on_device(self._src_device, "usr/bin/jq"),

+ 2 - 0
coriolis/tests/integration/dockerfiles/data-minion/Dockerfile

@@ -6,7 +6,9 @@ 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).
+# cryptsetup is required to unlock / lock LUKS-encrypted devices during OS morphing.
 RUN apt-get update && apt-get install -y --no-install-recommends \
+    cryptsetup \
     dbus \
     kmod \
     openssh-server \

+ 28 - 5
coriolis/tests/integration/test_provider/imp.py

@@ -16,6 +16,7 @@ import uuid
 from oslo_log import log as logging
 import paramiko
 
+from coriolis import constants
 from coriolis.providers import backup_writers
 from coriolis.providers.base import BaseDestinationMinionPoolProvider
 from coriolis.providers.base import BaseEndpointDestinationOptionsProvider
@@ -269,11 +270,13 @@ class TestImportProvider(
         devices = [
             vol["volume_dev"] for vol in volumes_info if vol.get("volume_dev")
         ]
-        return {
-            "instance_deployment_info": {
-                "devices": devices,
-            },
-        }
+        info = {"devices": devices}
+
+        passphrase = target_environment.get(constants.ENCRYPTED_DISKS_PASS)
+        if passphrase:
+            info[constants.ENCRYPTED_DISKS_PASS] = passphrase
+
+        return {"instance_deployment_info": info}
 
     def finalize_replica_instance_deployment(
             self, ctxt, connection_info, target_environment,
@@ -303,6 +306,8 @@ class TestImportProvider(
     # BaseInstanceProvider
 
     def get_os_morphing_tools(self, os_type, osmorphing_info):
+        if osmorphing_info.get(constants.ENCRYPTED_DISKS_PASS):
+            return osmorphing.LUKS_OS_MORPHERS
         return osmorphing.OS_MORPHERS
 
     # BaseImportInstanceProvider
@@ -320,12 +325,29 @@ class TestImportProvider(
             test_utils.get_host_disk_devices() - set(devices)
         )
 
+        device_cgroup_rules = None
+        passphrase = instance_deployment_info.get(
+            constants.ENCRYPTED_DISKS_PASS)
+        if passphrase:
+            # luksOpen inside the container needs /dev/mapper/control.
+            # Docker only gives containers the device nodes passed at run time,
+            # so we must include it explicitly.
+            #
+            # After luksOpen, the kernel creates a new dm block device (dm-N).
+            # udevd inside the container tries to mknod it, but the device
+            # cgroup blocks access to device numbers not in the container's
+            # allowlist. "b *:* rwm" lifts that restriction for block devices,
+            # so the new mapper node becomes accessible.
+            devices = devices + ["/dev/mapper/control"]
+            device_cgroup_rules = ["b *:* rwm"]
+
         # 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,
+            device_cgroup_rules=device_cgroup_rules,
         )
 
         return {
@@ -334,6 +356,7 @@ class TestImportProvider(
             "osmorphing_info": {
                 "os_type": instance_deployment_info.get("os_type", "linux"),
                 "ignore_devices": ignore_devices,
+                constants.ENCRYPTED_DISKS_PASS: passphrase,
             },
         }
 

+ 7 - 0
coriolis/tests/integration/test_provider/osmorphing/__init__.py

@@ -2,9 +2,16 @@
 # All Rights Reserved.
 
 from coriolis.osmorphing import base
+from coriolis.tests.integration.test_provider.osmorphing import rocky
 from coriolis.tests.integration.test_provider.osmorphing import ubuntu
 
 
 OS_MORPHERS: list[base.BaseLinuxOSMorphingTools] = [
+    rocky.TestRockyLinuxOSMorphingTools,
     ubuntu.TestUbuntuOSMorphingTools,
 ]
+
+LUKS_OS_MORPHERS: list[base.BaseLinuxOSMorphingTools] = [
+    rocky.LUKSTestRockyLinuxOSMorphingTools,
+    ubuntu.LUKSTestUbuntuOSMorphingTools,
+]

+ 35 - 0
coriolis/tests/integration/test_provider/osmorphing/rocky.py

@@ -0,0 +1,35 @@
+# Copyright 2026 Cloudbase Solutions Srl
+# All Rights Reserved.
+
+"""
+Rocky Linux OS Morphing tools.
+"""
+
+from coriolis.osmorphing import rocky
+
+
+class TestRockyLinuxOSMorphingTools(rocky.BaseRockyLinuxMorphingTools):
+    """Rocky Linux OSMorphing tools for integration tests."""
+
+    # Package meant to be installed during OS morphing.
+    # jq is a small package not present in the base container image.
+    _packages = {
+        None: [("jq", True)],
+    }
+
+
+class LUKSTestRockyLinuxOSMorphingTools(TestRockyLinuxOSMorphingTools):
+    """Rocky Linux morphing tools for LUKS integration tests.
+
+    Extends the base test tools with dracut and cryptsetup, which provide
+    the initramfs rebuild tool and LUKS support. The base Rocky Linux Docker
+    image omits them; they must be installed so initramfs can be rebuilt.
+    """
+
+    _packages = {
+        None: [
+            ("jq", True),
+            ("dracut", False),
+            ("cryptsetup", False),
+        ],
+    }

+ 17 - 0
coriolis/tests/integration/test_provider/osmorphing/ubuntu.py

@@ -16,3 +16,20 @@ class TestUbuntuOSMorphingTools(ubuntu.BaseUbuntuMorphingTools):
     _packages = {
         None: [("jq", True)],
     }
+
+
+class LUKSTestUbuntuOSMorphingTools(TestUbuntuOSMorphingTools):
+    """Ubuntu morphing tools for LUKS integration tests.
+
+    Extends the base test tools with initramfs-tools and cryptsetup-initramfs,
+    which provide update-initramfs and the LUKS hook. The base Ubuntu Docker
+    image omits them; they must be installed so initramfs can be rebuilt.
+    """
+
+    _packages = {
+        None: [
+            ("jq", True),
+            ("initramfs-tools", False),
+            ("cryptsetup-initramfs", False),
+        ],
+    }

+ 72 - 4
coriolis/tests/integration/utils.py

@@ -5,6 +5,7 @@
 Integration test utils.
 """
 
+import contextlib
 import json
 import os
 import socket
@@ -394,17 +395,84 @@ def write_os_image_to_disk(device_path, container_image):
         _run(["docker", "rm", "-f", container_id], check=False)
 
 
+def _fixup_luks_inner_os(mapper_path, luks_uuid):
+    """Patch the OS image inside a LUKS mapper to work with OS morphing.
+
+    Docker container images are not full OS installs, so a few things need
+    fixing before Coriolis can morph them:
+
+    1. /etc/crypttab is missing: the LUKS mixin needs a UUID= entry there to
+       configure initramfs auto-unlock.
+    2. /boot may be absent (e.g. Rocky Linux 9 Docker image): the osmount
+       root-finder requires etc, bin, sbin, and boot to all be present.
+    """
+    mapper_name = "luks-%s" % luks_uuid
+    crypttab_entry = "%s\tUUID=%s\tnone\tluks\n" % (mapper_name, luks_uuid)
+
+    with tempfile.TemporaryDirectory() as mount_point:
+        _run(["mount", mapper_path, mount_point])
+
+        try:
+            etc_dir = os.path.join(mount_point, "etc")
+            os.makedirs(etc_dir, exist_ok=True)
+            crypttab_path = os.path.join(etc_dir, "crypttab")
+
+            with open(crypttab_path, "w") as fh:
+                fh.write(crypttab_entry)
+
+            os.makedirs(os.path.join(mount_point, "boot"), exist_ok=True)
+        finally:
+            _run(["umount", mount_point])
+
+
+def make_luks_device(device_path, key_file, container_image):
+    """Format *device_path* with LUKS and write a minimal Linux OS inside.
+
+    The mapper device is opened only for the duration of the call. It is closed
+    before returning, leaving the raw device encrypted.
+
+    Exports the filesystem the container image onto the given device, then
+    writes a /etc/crypttab entry so that the LUKS mixin can find the UUID
+    when configuring initramfs auto-unlock during OS morphing.
+    """
+    _run([
+        "cryptsetup", "luksFormat", "--batch-mode", "--key-file", key_file,
+        device_path,
+    ])
+
+    luks_uuid = _run(
+        ["cryptsetup", "luksUUID", device_path]).stdout.decode().strip()
+
+    with luks_open(device_path, key_file) as mapper_path:
+        write_os_image_to_disk(mapper_path, container_image)
+        _fixup_luks_inner_os(mapper_path, luks_uuid)
+
+
+@contextlib.contextmanager
+def luks_open(device_path, key_file):
+    mapper_name = "coriolis_luks_setup_%s" % os.path.basename(device_path)
+    _run([
+        "cryptsetup", "luksOpen", "--key-file", key_file, device_path,
+        mapper_name,
+    ])
+
+    try:
+        yield "/dev/mapper/%s" % mapper_name
+    finally:
+        _run(["cryptsetup", "luksClose", mapper_name])
+
+
 def path_exists_on_device(device_path, rel_path):
-    """Checks if *path* exists on the filesystem of *device_path*.
+    """Checks if *rel_path* exists on the filesystem of *device_path*.
 
-    Mounts the device read-only into a temporary directory, checks for the
-    path, then unmounts.
+    Uses lexists so dangling symlinks (e.g. absolute targets only valid inside
+    the OS, not on the host) are still reported as present.
     """
     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))
+            return os.path.lexists(os.path.join(mount_point, rel_path))
         finally:
             _run(["umount", mount_point])
 

+ 19 - 5
coriolis/tests/osmorphing/osmount/test_base.py

@@ -73,7 +73,11 @@ class BaseSSHOSMountToolsTestCase(test_base.CoriolisBaseTestCase):
 
     @mock.patch('paramiko.SSHClient')
     @mock.patch.object(base.utils, 'wait_for_port_connectivity')
-    def test__connect(self, mock_wait_for_port_connectivity, mock_ssh_client):
+    @mock.patch.object(base.utils, 'deserialize_key')
+    def test__connect(
+        self, mock_deserialize_key, mock_wait_for_port_connectivity,
+        mock_ssh_client,
+    ):
         base_os_mount_tools = TestBaseSSHOSMountTools(
             self.conn_info, self.event_manager,
             mock.sentinel.ignore_devices, mock.sentinel.operation_timeout)
@@ -86,6 +90,7 @@ class BaseSSHOSMountToolsTestCase(test_base.CoriolisBaseTestCase):
                              level=logging.INFO):
             original_connect(base_os_mount_tools)
 
+        mock_deserialize_key.assert_called_with(self.conn_info['pkey'])
         mock_wait_for_port_connectivity.assert_has_calls([
             mock.call(self.conn_info['ip'], 22),
             mock.call(self.conn_info['ip'], 22),
@@ -95,7 +100,7 @@ class BaseSSHOSMountToolsTestCase(test_base.CoriolisBaseTestCase):
         self.ssh.connect.assert_called_once_with(
             hostname=self.conn_info['ip'], port=22,
             username=self.conn_info['username'],
-            pkey=self.conn_info['pkey'],
+            pkey=mock_deserialize_key.return_value,
             password=self.conn_info['password'])
 
         self.ssh.set_log_channel.assert_called_once_with(
@@ -646,14 +651,23 @@ class BaseLinuxOSMountToolsTestCase(test_base.CoriolisBaseTestCase):
 
         self.assertEqual(result, expected_result)
 
+    @mock.patch.object(base.utils, 'test_ssh_path', return_value=True)
     @mock.patch.object(base.BaseSSHOSMountTools, '_exec_cmd')
-    def test__get_volume_block_devices(self, mock_exec_cmd):
-        mock_exec_cmd.return_value = "sda\nsda1\nsda2\nsdb\nsdb1\nsdb2"
+    def test__get_volume_block_devices(self, mock_exec_cmd, _mock_test_path):
+        lsblk_output = (
+            "sda  disk\n"
+            "sda1 part\n"
+            "sda2 part\n"
+            "sdb  disk\n"
+            "sdb1 part\n"
+            "sdb2 part\n"
+        )
+        mock_exec_cmd.return_value = lsblk_output
         self.base_os_mount_tools._ignore_devices = ["/dev/sda1"]
 
         result = self.base_os_mount_tools._get_volume_block_devices()
 
-        mock_exec_cmd.assert_called_once_with("lsblk -lnao KNAME")
+        mock_exec_cmd.assert_called_once_with("lsblk -lnao KNAME,TYPE")
         expected_result = ["/dev/sda", "/dev/sdb"]
 
         self.assertEqual(result, expected_result)

+ 3 - 1
coriolis/utils.py

@@ -553,12 +553,14 @@ def connect_ssh(hostname, port, username, pkey=None, password=None,
                 connect_timeout=None, banner_timeout=None):
     """Open and return a connected paramiko SSHClient.
 
-    :param pkey: a paramiko.PKey instance or None.
+    :param pkey: a paramiko.PKey instance, a serialized PEM string, or None.
     :param password: plaintext password or None.
     :param connect_timeout: socket-level timeout in seconds (None = default).
     :param banner_timeout: banner timeout in seconds passed to paramiko.
     :raises: exception.CoriolisException on failure.
     """
+    if isinstance(pkey, str):
+        pkey = deserialize_key(pkey)
     ssh = paramiko.SSHClient()
     ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
     kwargs = dict(