From 24b58089a434fcc0552825e1f08c3a00cd326a81 Mon Sep 17 00:00:00 2001 From: Claudiu Belu Date: Wed, 24 Jun 2026 18:19:24 +0000 Subject: [PATCH] Improves LUKS initramfs rebuild for dracut and chroot environments - Conditionally add 'initramfs' crypttab option only for update-initramfs systems (not dracut, where it may confuse the parser) - Case-insensitive UUID matching in crypttab keyfile updates - Per-kernel dracut rebuild instead of --regenerate-all for reliable --include handling across distributions - Add --no-hostonly and --add crypt flags for generic initramfs in chroot - Wrap LUKS UUID lookup errors with a descriptive CoriolisException --- coriolis/osmorphing/osmount/base.py | 3 +- coriolis/osmorphing/osmount/luks_mixin.py | 80 ++++++++++++---- .../resources/luks_firstboot_dracut.sh | 18 +++- .../test_provider/osmorphing/rocky.py | 1 + .../tests/osmorphing/osmount/test_base.py | 4 +- .../osmorphing/osmount/test_luks_mixin.py | 96 ++++++++++++++++--- 6 files changed, 162 insertions(+), 40 deletions(-) diff --git a/coriolis/osmorphing/osmount/base.py b/coriolis/osmorphing/osmount/base.py index c14189e3..4f150931 100644 --- a/coriolis/osmorphing/osmount/base.py +++ b/coriolis/osmorphing/osmount/base.py @@ -401,7 +401,8 @@ def _get_mounted_devices(self): mounted_device_numbers.append( self._exec_cmd(dev_nmb_cmd % dev_name).rstrip()) - block_devs = self._exec_cmd("ls -al /dev | grep ^b").splitlines() + block_devs = self._exec_cmd( + "ls -al /dev | grep ^b || true").splitlines() for dev_line in block_devs: dev = dev_line.split() major_minor = "%s:%s" % ( diff --git a/coriolis/osmorphing/osmount/luks_mixin.py b/coriolis/osmorphing/osmount/luks_mixin.py index b78d33fd..cecbb390 100644 --- a/coriolis/osmorphing/osmount/luks_mixin.py +++ b/coriolis/osmorphing/osmount/luks_mixin.py @@ -307,6 +307,16 @@ def _get_luks_uuid(self, dev_path): def _update_crypttab_keyfile(self, os_root_dir, uuid_to_keyfile): """Update the keyfile column in crypttab for matching LUKS UUIDs.""" + # cryptsetup-initramfs (Ubuntu 22.04+) only embeds crypttab entries + # in the initramfs when the device is verifiable at build time OR when + # the 'initramfs' option is present. Inside a chroot (no udev, no + # /dev/disk/by-uuid/), verification always fails, so we force-add + # 'initramfs' for update-initramfs systems. + # On dracut systems the option is unnecessary and may confuse the + # crypttab parser. + add_initramfs_opt = ( + self._detect_initramfs_tool(os_root_dir) == "update-initramfs") + def _set_keyfile(parts): if len(parts) < 2: return None @@ -316,7 +326,7 @@ def _set_keyfile(parts): if not m: return None - keyfile = uuid_to_keyfile.get(m.group(1)) + keyfile = uuid_to_keyfile.get(m.group(1).lower()) if keyfile is None: return None @@ -324,13 +334,8 @@ def _set_keyfile(parts): parts.append("") parts[2] = keyfile - # cryptsetup-initramfs (Ubuntu 22.04+) only embeds crypttab - # entries in the initramfs when the device is verifiable at build - # time OR when the 'initramfs' option is present. Inside a chroot - # (no udev, no /dev/disk/by-uuid/), verification always fails, so - # we force-add 'initramfs' here. opts_list = [o for o in parts[3].split(",") if o] - if "initramfs" not in opts_list: + if add_initramfs_opt and "initramfs" not in opts_list: opts_list.append("initramfs") parts[3] = ",".join(opts_list) @@ -357,7 +362,12 @@ def _write_migration_keyfiles(self, os_root_dir): uuid_to_keyfile = {} for _, dev_path in self._luks_opened: - luks_uuid = self._get_luks_uuid(dev_path) + try: + luks_uuid = self._get_luks_uuid(dev_path) + except Exception as ex: + raise exception.CoriolisException( + "Could not determine LUKS UUID for '%s'; " + "cannot write migration keyfile." % dev_path) from ex keyfile_path = self._get_migration_keyfile_path(dev_path) abs_path = os.path.join(os_root_dir, keyfile_path.lstrip("/")) @@ -399,7 +409,16 @@ def _configure_dracut_keyfiles(self, os_root_dir, uuid_to_keyfile): conf_abs = os.path.join( os_root_dir, _DRACUT_LUKS_CONF_PATH.lstrip("/")) - conf_content = 'install_items+=" %s "\n' % " ".join(install_items) + # hostonly="no" forces a generic initramfs, so the rebuilt image works + # on the target hypervisor (e.g.: virtio_blk on KVM), regardless of + # what hardware is visible inside the OS morphing minion at build time. + # add_dracutmodules ensures the crypt module is included even when + # dracut's host-detection runs inside a minion without LUKS devices. + conf_content = ( + 'hostonly="no"\n' + 'add_dracutmodules+=" crypt "\n' + 'install_items+=" %s "\n' + ) % " ".join(install_items) self._write_remote_file(conf_abs, conf_content) self._exec_cmd( "sudo chown root:root %s && sudo chmod 644 %s" % ( @@ -488,18 +507,7 @@ def _rebuild_initramfs(self, os_root_dir): self._exec_cmd( "sudo chroot %s update-initramfs -u -k all" % os_root_dir) elif tool == "dracut": - # --regenerate-all scans the chroot's own /lib/modules/ for - # installed kernels instead of relying on uname -r - # - # Explicitly --include the crypttab and any LUKS migration keyfiles - # so that systemd-cryptsetup-generator finds them in the initramfs - # and uses the crypttab mapper name (luks-root) and keyfile for - # auto-unlock. install_items in dracut.conf.d embeds the keyfile - # but does NOT guarantee that crypttab ends up in the image. - include_args = self._build_dracut_include_args(os_root_dir) - self._exec_cmd( - "sudo chroot %s dracut --regenerate-all --force %s" - % (os_root_dir, " ".join(include_args))) + self._rebuild_initramfs_dracut(os_root_dir) else: raise exception.CoriolisException( "No initramfs tool found in OS at '%s'; cannot rebuild " @@ -530,6 +538,36 @@ def install_encryption_firstboot_setup( script_content, user_provided=False, script_filename="luks-firstboot.sh") + def _rebuild_initramfs_dracut(self, os_root_dir): + """Rebuild all dracut initramfs images inside the OS chroot.""" + include_args = self._build_dracut_include_args(os_root_dir) + + try: + kvers_out = self._exec_cmd( + "sudo ls -1 '%s/lib/modules/' 2>/dev/null" % os_root_dir + ).strip().splitlines() + kvers = [k.strip() for k in kvers_out if k.strip()] + except Exception: + kvers = [] + + if not kvers: + raise exception.CoriolisException( + "No kernel versions found under '%s/lib/modules/'; " + "cannot rebuild the initramfs for LUKS auto-unlock." % + os_root_dir) + + for kver in kvers: + LOG.info("Rebuilding dracut initramfs for kernel %s", kver) + # --no-hostonly and --add crypt override conf.d settings that + # could be ignored in a chroot without running udevd / uname. + # --add-drivers dm-crypt directly embeds dm-crypt.ko, so the + # crypt DM target type is available even when instmods can't + # resolve it via modules.dep (e.g.: stripped cloud images). + self._exec_cmd( + "sudo chroot '%s' dracut -f --kver '%s' " + "--no-hostonly --add crypt --add-drivers dm-crypt %s" + % (os_root_dir, kver, " ".join(include_args))) + def _fix_grub_luks_root(self, os_root_dir): """Patch grub.cfg to use crypttab mapper names for LUKS root devices. diff --git a/coriolis/osmorphing/osmount/resources/luks_firstboot_dracut.sh b/coriolis/osmorphing/osmount/resources/luks_firstboot_dracut.sh index 585551f7..ee7e30d3 100644 --- a/coriolis/osmorphing/osmount/resources/luks_firstboot_dracut.sh +++ b/coriolis/osmorphing/osmount/resources/luks_firstboot_dracut.sh @@ -144,11 +144,27 @@ rm -f "${keyfiles[@]}" rmdir "$KEYFILE_DIR" 2>/dev/null || true echo "Rebuilding initramfs." +# Rebuild every installed kernel so none of them retain the embedded keyfile. +kvers=() +for kdir in /lib/modules/*/; do + kver="${kdir%/}" + kver="${kver##*/}" + [ -n "$kver" ] && kvers+=("$kver") +done + +if [ "${#kvers[@]}" -eq 0 ]; then + echo "ERROR: no kernel versions found under /lib/modules/; cannot rebuild initramfs." >&2 + exit 1 +fi + # Embed the updated crypttab, so systemd-cryptsetup-generator uses the crypttab # mapper name and tpm2-device=auto for auto-unlock. The Coriolis dracut.conf.d # entry is deleted after this rebuild, so its install_items (TPM2 plugin + libtss2) # are still picked up here. -dracut --force --include /etc/crypttab /etc/crypttab +for kver in "${kvers[@]}"; do + echo "Rebuilding dracut initramfs for kernel $kver." + dracut --force --kver "$kver" --include /etc/crypttab /etc/crypttab +done rm -f "$DRACUT_CONF" echo "Firstboot LUKS cleanup complete." diff --git a/coriolis/tests/integration/test_provider/osmorphing/rocky.py b/coriolis/tests/integration/test_provider/osmorphing/rocky.py index b5488fd9..f98d04b7 100644 --- a/coriolis/tests/integration/test_provider/osmorphing/rocky.py +++ b/coriolis/tests/integration/test_provider/osmorphing/rocky.py @@ -31,5 +31,6 @@ class LUKSTestRockyLinuxOSMorphingTools(TestRockyLinuxOSMorphingTools): ("jq", True), ("dracut", False), ("cryptsetup", False), + ("device-mapper", False), ], } diff --git a/coriolis/tests/osmorphing/osmount/test_base.py b/coriolis/tests/osmorphing/osmount/test_base.py index 08f53848..5258fa3f 100644 --- a/coriolis/tests/osmorphing/osmount/test_base.py +++ b/coriolis/tests/osmorphing/osmount/test_base.py @@ -605,7 +605,7 @@ def test__get_mounted_devices(self, mock_exec_cmd): mock.call("cat /proc/mounts"), mock.call("readlink -en /dev/sda1"), mock.call("mountpoint -x /dev/sda1"), - mock.call("ls -al /dev | grep ^b"), + mock.call("ls -al /dev | grep ^b || true"), ]) self.assertEqual(result, ['/dev/sda1', '/dev/sda2']) @@ -629,7 +629,7 @@ def test__get_mounted_devices_not_found(self, mock_test_ssh_path, mock_exec_cmd.assert_has_calls([ mock.call("cat /proc/mounts"), mock.call("readlink -en /dev/sda1"), - mock.call("ls -al /dev | grep ^b") + mock.call("ls -al /dev | grep ^b || true") ]) mock_test_ssh_path.assert_called_once_with( diff --git a/coriolis/tests/osmorphing/osmount/test_luks_mixin.py b/coriolis/tests/osmorphing/osmount/test_luks_mixin.py index a60863a2..d8c2334e 100644 --- a/coriolis/tests/osmorphing/osmount/test_luks_mixin.py +++ b/coriolis/tests/osmorphing/osmount/test_luks_mixin.py @@ -340,9 +340,11 @@ def test__get_luks_uuid(self, mock_exec_cmd): "sudo cryptsetup luksUUID %s" % _DEV ) + @mock.patch.object(luks_mixin.LinuxLUKSMixin, "_detect_initramfs_tool") @mock.patch.object(luks_mixin.LinuxLUKSMixin, "_transform_crypttab") - def test__update_crypttab_keyfile(self, mock_transform): + def test__update_crypttab_keyfile(self, mock_transform, mock_detect_tool): mock_transform.return_value = True + mock_detect_tool.return_value = "update-initramfs" self.mixin._update_crypttab_keyfile(_OS_ROOT_DIR, {_UUID: _KEYFILE}) mock_transform.assert_called_once() @@ -365,16 +367,32 @@ def test__update_crypttab_keyfile(self, mock_transform): ] self.assertIsNone(transform(parts)) - # UUID= format match; 'initramfs' always appended. + # UUID= format match; update-initramfs -> 'initramfs' option added. result = transform(["luks-root", "UUID=%s" % _UUID, "none", "none"]) self.assertEqual(result[2], _KEYFILE) self.assertIn("initramfs", result[3].split(",")) + # Case-insensitive UUID match: uppercase UUID in crypttab still matches + # lowercase key in the map. + result = transform( + ["luks-root", "UUID=%s" % _UUID.upper(), "none", "none"]) + self.assertEqual(result[2], _KEYFILE) + # /by-uuid/ path also matches. parts = ["luks-root", "/dev/disk/by-uuid/%s" % _UUID, "none", "none"] result = transform(parts) self.assertEqual(result[2], _KEYFILE) + # dracut -> 'initramfs' option NOT added. + mock_transform.reset_mock() + mock_detect_tool.return_value = "dracut" + self.mixin._update_crypttab_keyfile(_OS_ROOT_DIR, {_UUID: _KEYFILE}) + transform_dracut = mock_transform.call_args[0][1] + result = transform_dracut( + ["luks-root", "UUID=%s" % _UUID, "none", "none"]) + self.assertEqual(result[2], _KEYFILE) + self.assertNotIn("initramfs", result[3].split(",")) + # transform returns False -> exception. mock_transform.return_value = False self.assertRaises( @@ -452,6 +470,16 @@ def test__write_migration_keyfiles( _OS_ROOT_DIR, ) + # _get_luks_uuid raises: wrapped in CoriolisException. + mock_detect_tool.return_value = "dracut" + mock_uuid.side_effect = Exception("cryptsetup went boom") + self.mixin._luks_opened = [("coriolis_sda", _DEV)] + self.assertRaises( + exception.CoriolisException, + self.mixin._write_migration_keyfiles, + _OS_ROOT_DIR, + ) + @mock.patch.object(luks_mixin.LinuxLUKSMixin, "_write_remote_file") @mock.patch.object(base.BaseSSHOSMountTools, "_exec_cmd") @mock.patch.object(luks_mixin.utils, "test_ssh_path") @@ -471,6 +499,8 @@ def test__configure_dracut_keyfiles( written = mock_write.call_args[0][1] self.assertIn(_KEYFILE, written) self.assertIn("install_items+=", written) + self.assertIn('hostonly="no"', written) + self.assertIn('add_dracutmodules+=" crypt "', written) self.assertNotIn(plugin_path, written) mock_exec.assert_called_once_with( "sudo chown root:root %s && sudo chmod 644 %s" @@ -565,11 +595,11 @@ def test__build_dracut_include_args(self, mock_test, mock_exec): ] self.assertEqual(result, expected) - @mock.patch.object(luks_mixin.LinuxLUKSMixin, "_build_dracut_include_args") + @mock.patch.object(luks_mixin.LinuxLUKSMixin, "_rebuild_initramfs_dracut") @mock.patch.object(luks_mixin.LinuxLUKSMixin, "_detect_initramfs_tool") @mock.patch.object(base.BaseSSHOSMountTools, "_exec_cmd") def test__rebuild_initramfs( - self, mock_exec, mock_detect, mock_include_args + self, mock_exec, mock_detect, mock_rebuild_dracut ): # update-initramfs. mock_detect.return_value = "update-initramfs" @@ -577,22 +607,15 @@ def test__rebuild_initramfs( mock_exec.assert_called_once_with( "sudo chroot %s update-initramfs -u -k all" % _OS_ROOT_DIR ) + mock_rebuild_dracut.assert_not_called() - # dracut: --regenerate-all --force with --include args. + # dracut: delegates to _rebuild_initramfs_dracut. mock_exec.reset_mock() mock_detect.return_value = "dracut" - mock_include_args.return_value = [ - "--include", - "/etc/crypttab", - "/etc/crypttab", - ] self.mixin._rebuild_initramfs(_OS_ROOT_DIR) - mock_include_args.assert_called_once_with(_OS_ROOT_DIR) - mock_exec.assert_called_once_with( - "sudo chroot %s dracut --regenerate-all --force " - "--include /etc/crypttab /etc/crypttab" % _OS_ROOT_DIR - ) + mock_rebuild_dracut.assert_called_once_with(_OS_ROOT_DIR) + mock_exec.assert_not_called() # no tool found. mock_detect.return_value = None @@ -602,6 +625,49 @@ def test__rebuild_initramfs( _OS_ROOT_DIR, ) + @mock.patch.object(luks_mixin.LinuxLUKSMixin, "_build_dracut_include_args") + @mock.patch.object(base.BaseSSHOSMountTools, "_exec_cmd") + def test__rebuild_initramfs_dracut(self, mock_exec, mock_include_args): + # ls raises: treated as empty kernel list -> CoriolisException. + mock_exec.side_effect = Exception("ls failed") + self.assertRaises( + exception.CoriolisException, + self.mixin._rebuild_initramfs_dracut, + _OS_ROOT_DIR, + ) + mock_include_args.assert_called_once_with(_OS_ROOT_DIR) + + # ls succeeds but returns empty output -> CoriolisException. + mock_exec.reset_mock() + mock_exec.side_effect = None + mock_exec.return_value = " \n\n" + self.assertRaises( + exception.CoriolisException, + self.mixin._rebuild_initramfs_dracut, + _OS_ROOT_DIR, + ) + + # initramfs rebuilt. + mock_include_args.return_value = [ + "--include", + "/etc/crypttab", + "/etc/crypttab", + ] + + mock_exec.reset_mock() + mock_exec.side_effect = [ + "5.15.0-generic\n", # ls /lib/modules/ + None, # dracut call + ] + self.mixin._rebuild_initramfs_dracut(_OS_ROOT_DIR) + + self.assertEqual(mock_exec.call_count, 2) + mock_exec.assert_any_call( + "sudo chroot '%s' dracut -f --kver '5.15.0-generic' " + "--no-hostonly --add crypt --add-drivers dm-crypt " + "--include /etc/crypttab /etc/crypttab" % _OS_ROOT_DIR + ) + @mock.patch.object(luks_mixin.LinuxLUKSMixin, '_detect_initramfs_tool') @mock.patch.object(luks_mixin.LinuxLUKSMixin, '_rebuild_initramfs') @mock.patch.object(luks_mixin.LinuxLUKSMixin, '_fix_grub_luks_root')