From 541691d7ca1b6bb33652fd44d26949a13aa42b8f Mon Sep 17 00:00:00 2001 From: ckyrouac Date: Wed, 11 Mar 2026 15:39:03 -0400 Subject: [PATCH] install: Enable installing to devices with multiple parents Signed-off-by: ckyrouac --- crates/lib/src/bootloader.rs | 20 +- crates/lib/src/install.rs | 6 +- hack/provision-derived.sh | 42 +++ tmt/plans/integration.fmf | 7 + tmt/tests/booted/test-multi-device-esp.nu | 430 ++++++++++++++++++++++ tmt/tests/test-39-multi-device-esp.fmf | 7 + 6 files changed, 504 insertions(+), 8 deletions(-) create mode 100644 tmt/tests/booted/test-multi-device-esp.nu create mode 100644 tmt/tests/test-39-multi-device-esp.fmf diff --git a/crates/lib/src/bootloader.rs b/crates/lib/src/bootloader.rs index 0c19cc04f..bc310f796 100644 --- a/crates/lib/src/bootloader.rs +++ b/crates/lib/src/bootloader.rs @@ -67,9 +67,15 @@ pub(crate) fn supports_bootupd(root: &Dir) -> Result { Ok(r) } +/// Install the bootloader via bootupd. +/// +/// We pass `--filesystem` pointing at a block-backed mount so that bootupd +/// can resolve the backing device(s) itself via `lsblk`. In the bwrap path +/// we bind-mount the physical root at `/sysroot` to give `lsblk` a real +/// block-backed path; in the non-bwrap path the rootfs mount works directly. #[context("Installing bootloader")] pub(crate) fn install_via_bootupd( - device: &bootc_blockdev::Device, + _device: &bootc_blockdev::Device, rootfs: &Utf8Path, configopts: &crate::install::InstallConfigOpts, deployment_path: Option<&str>, @@ -91,8 +97,6 @@ pub(crate) fn install_via_bootupd( println!("Installing bootloader via bootupd"); - let device_path = device.path(); - // Build the bootupctl arguments let mut bootupd_args: Vec<&str> = vec!["backend", "install"]; if configopts.bootupd_skip_boot_uuid { @@ -107,7 +111,9 @@ pub(crate) fn install_via_bootupd( if let Some(ref opts) = bootupd_opts { bootupd_args.extend(opts.iter().copied()); } - bootupd_args.extend(["--device", &device_path, rootfs_mount]); + + bootupd_args.extend(["--filesystem", rootfs_mount]); + bootupd_args.push(rootfs_mount); // Run inside a bwrap container. It takes care of mounting and creating // the necessary API filesystems in the target deployment and acts as @@ -115,6 +121,7 @@ pub(crate) fn install_via_bootupd( if let Some(deploy) = deployment_path { let target_root = rootfs.join(deploy); let boot_path = rootfs.join("boot"); + let rootfs_path = rootfs.to_path_buf(); tracing::debug!("Running bootupctl via bwrap in {}", target_root); @@ -125,7 +132,10 @@ pub(crate) fn install_via_bootupd( let cmd = BwrapCmd::new(&target_root) // Bind mount /boot from the physical target root so bootupctl can find // the boot partition and install the bootloader there - .bind(&boot_path, &"/boot"); + .bind(&boot_path, &"/boot") + // Bind mount the physical root at /sysroot so bootupd can resolve + // backing block devices via lsblk for --filesystem + .bind(&rootfs_path, &"/sysroot"); // The $PATH in the bwrap env is not complete enough for some images // so we inject a reasonnable default. diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 1d59deb63..badf90598 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -1939,6 +1939,7 @@ async fn install_to_filesystem_impl( // Drop exclusive ownership since we're done with mutation let rootfs = &*rootfs; + //TODO: loop over all parents devices? match rootfs.device_info.pttype.as_deref() { Some("dos") => crate::utils::medium_visibility_warning( "Installing to `dos` format partitions is not recommended", @@ -2564,9 +2565,8 @@ pub(crate) async fn install_to_filesystem( // Find the real underlying backing device for the root. This is currently just required // for GRUB (BIOS) and in the future zipl (I think). let device_info = { - let dev = - bootc_blockdev::list_dev(Utf8Path::new(&inspect.source))?.require_single_root()?; - tracing::debug!("Backing device: {}", dev.path()); + let dev = bootc_blockdev::list_dev(Utf8Path::new(&inspect.source))?; + tracing::debug!("Target filesystem backing device: {}", dev.path()); dev }; diff --git a/hack/provision-derived.sh b/hack/provision-derived.sh index 59d9d15c6..bf026faec 100755 --- a/hack/provision-derived.sh +++ b/hack/provision-derived.sh @@ -69,6 +69,48 @@ resize_rootfs: false CLOUDEOF fi +# Temporary: update bootupd from @CoreOS/continuous copr until +# CentOS Stream 10 base image includes a version supporting --filesystem +. /usr/lib/os-release +case "${ID}-${VERSION_ID}" in + "centos-10"|"rhel-10."*) + case $ID in + fedora) copr_distro="fedora" ;; + *) copr_distro="centos-stream" ;; + esac + # Update bootc first from rhcontainerbot copr; the new bootupd + # requires a newer bootc than what ships in the base image. + cat >/etc/yum.repos.d/rhcontainerbot-bootc.repo </etc/yum.repos.d/coreos-continuous.repo <, mountpoint: string] { + # Unmount if mounted + do { umount $mountpoint } | complete | ignore + do { rmdir $mountpoint } | complete | ignore + + # Deactivate and remove LVM + do { lvchange -an $"($vg_name)/test_lv" } | complete | ignore + do { lvremove -f $"($vg_name)/test_lv" } | complete | ignore + do { vgchange -an $vg_name } | complete | ignore + do { vgremove -f $vg_name } | complete | ignore + + # Remove PVs and detach loop devices + for loop in $loops { + if ($loop | path exists) { + do { pvremove -f $loop } | complete | ignore + do { losetup -d $loop } | complete | ignore + } + } +} + +# Create a disk with GPT, optional ESP, and LVM partition +# Returns the loop device path +def setup_disk_with_partitions [ + disk_path: string, + with_esp: bool, + disk_size: string = "5G" +] { + # Create disk image + truncate -s $disk_size $disk_path + + # Setup loop device + let loop = (losetup -f --show $disk_path | str trim) + + # Create partition table + if $with_esp { + # GPT with ESP (512MB) + LVM partition + $"label: gpt\nsize=512M, type=($ESP_TYPE)\ntype=($LVM_TYPE)\n" | sfdisk $loop + + # Reload partition table (partx is part of util-linux) + partx -u $loop + sleep 1sec + + # Format ESP + mkfs.vfat -F 32 $"($loop)p1" + } else { + # GPT with only LVM partition (full disk) + $"label: gpt\ntype=($LVM_TYPE)\n" | sfdisk $loop + + # Reload partition table (partx is part of util-linux) + partx -u $loop + sleep 1sec + } + + $loop +} + +# Create a disk with GPT, ESP, and a root partition (no LVM) +# Returns the loop device path +def setup_disk_with_root [ + disk_path: string, + disk_size: string = "5G" +] { + truncate -s $disk_size $disk_path + let loop = (losetup -f --show $disk_path | str trim) + + # GPT with ESP (512MB) + root partition + $"label: gpt\nsize=512M, type=($ESP_TYPE)\ntype=($ROOT_TYPE)\n" | sfdisk $loop + partx -u $loop + sleep 1sec + + mkfs.vfat -F 32 $"($loop)p1" + mkfs.ext4 -q $"($loop)p2" + + $loop +} + +# Simple cleanup for non-LVM scenarios (single loop device, no VG) +def cleanup_simple [loop: string, mountpoint: string] { + do { umount $mountpoint } | complete | ignore + do { rmdir $mountpoint } | complete | ignore + + if ($loop | path exists) { + do { losetup -d $loop } | complete | ignore + } +} + +# Validate that an ESP partition has bootloader files installed +def validate_esp [esp_partition: string] { + let esp_mount = "/var/mnt/esp_check" + mkdir $esp_mount + mount $esp_partition $esp_mount + + # Check for EFI directory with bootloader files + let efi_dir = $"($esp_mount)/EFI" + if not ($efi_dir | path exists) { + umount $esp_mount + rmdir $esp_mount + error make {msg: $"ESP validation failed: EFI directory not found on ($esp_partition)"} + } + + # Verify there's actual content in EFI (not just empty) + let efi_contents = (ls $efi_dir | length) + umount $esp_mount + rmdir $esp_mount + + if $efi_contents == 0 { + error make {msg: $"ESP validation failed: EFI directory is empty on ($esp_partition)"} + } +} + +# Run bootc install to-existing-root from within the container image under test +def run_install [mountpoint: string] { + (podman run + --rm + --privileged + -v $"($mountpoint):/target" + -v /dev:/dev + -v /run/udev:/run/udev:ro + -v /usr/share/empty:/usr/lib/bootc/bound-images.d + --pid=host + --security-opt label=type:unconfined_t + --env BOOTC_BOOTLOADER_DEBUG=1 + $target_image + bootc install to-existing-root + --disable-selinux + --acknowledge-destructive + --target-no-signature-verification + /target) +} + +# Test scenario 1: Single ESP on first device +def test_single_esp [] { + tap begin "multi-device ESP detection tests" + + bootc image copy-to-storage + + print "Starting single ESP test" + + let vg_name = "test_single_esp_vg" + let mountpoint = "/var/mnt/test_single_esp" + let disk1 = "/var/tmp/disk1_single.img" + let disk2 = "/var/tmp/disk2_single.img" + + # Setup disks + # DISK1: ESP + LVM partition + # DISK2: Full LVM partition (no ESP) + let loop1 = (setup_disk_with_partitions $disk1 true) + let loop2 = (setup_disk_with_partitions $disk2 false) + + try { + # Create LVM spanning both devices + # Use partition 2 from disk1 (after ESP) and partition 1 from disk2 (full disk) + pvcreate $"($loop1)p2" $"($loop2)p1" + vgcreate $vg_name $"($loop1)p2" $"($loop2)p1" + lvcreate -l "100%FREE" -n test_lv $vg_name + + let lv_path = $"/dev/($vg_name)/test_lv" + + # Create filesystem and mount + mkfs.ext4 -q $lv_path + mkdir $mountpoint + mount $lv_path $mountpoint + + # Create boot directory + mkdir $"($mountpoint)/boot" + + # Show block device hierarchy + lsblk --pairs --paths --inverse --output NAME,TYPE $lv_path + + run_install $mountpoint + + # Validate ESP was installed correctly + validate_esp $"($loop1)p1" + } catch {|e| + cleanup $vg_name [$loop1, $loop2] $mountpoint + rm -f $disk1 $disk2 + error make {msg: $"Single ESP test failed: ($e)"} + } + + # Cleanup + cleanup $vg_name [$loop1, $loop2] $mountpoint + rm -f $disk1 $disk2 + + print "Single ESP test completed successfully" + tmt-reboot +} + +# Test scenario 2: ESP on both devices +def test_dual_esp [] { + print "Starting dual ESP test" + + let vg_name = "test_dual_esp_vg" + let mountpoint = "/var/mnt/test_dual_esp" + let disk1 = "/var/tmp/disk1_dual.img" + let disk2 = "/var/tmp/disk2_dual.img" + + # Setup disks + # DISK1: ESP + LVM partition + # DISK2: ESP + LVM partition + let loop1 = (setup_disk_with_partitions $disk1 true) + let loop2 = (setup_disk_with_partitions $disk2 true) + + try { + # Create LVM spanning both devices + # Use partition 2 from both disks (after ESP) + pvcreate $"($loop1)p2" $"($loop2)p2" + vgcreate $vg_name $"($loop1)p2" $"($loop2)p2" + lvcreate -l "100%FREE" -n test_lv $vg_name + + let lv_path = $"/dev/($vg_name)/test_lv" + + # Create filesystem and mount + mkfs.ext4 -q $lv_path + mkdir $mountpoint + mount $lv_path $mountpoint + + # Create boot directory + mkdir $"($mountpoint)/boot" + + # Show block device hierarchy + lsblk --pairs --paths --inverse --output NAME,TYPE $lv_path + + run_install $mountpoint + + # Validate both ESPs were installed correctly + validate_esp $"($loop1)p1" + validate_esp $"($loop2)p1" + } catch {|e| + cleanup $vg_name [$loop1, $loop2] $mountpoint + rm -f $disk1 $disk2 + error make {msg: $"Dual ESP test failed: ($e)"} + } + + # Cleanup + cleanup $vg_name [$loop1, $loop2] $mountpoint + rm -f $disk1 $disk2 + + print "Dual ESP test completed successfully" +} + +# Test scenario 3: Three devices, ESP on disk1 and disk3 only +def test_three_devices_partial_esp [] { + print "Starting three devices partial ESP test" + + let vg_name = "test_three_dev_vg" + let mountpoint = "/var/mnt/test_three_dev" + let disk1 = "/var/tmp/disk1_three.img" + let disk2 = "/var/tmp/disk2_three.img" + let disk3 = "/var/tmp/disk3_three.img" + + # Setup disks + # DISK1: ESP + LVM partition + # DISK2: Full LVM partition (no ESP) + # DISK3: ESP + LVM partition + let loop1 = (setup_disk_with_partitions $disk1 true) + let loop2 = (setup_disk_with_partitions $disk2 false) + let loop3 = (setup_disk_with_partitions $disk3 true) + + try { + # Create LVM spanning all three devices + pvcreate $"($loop1)p2" $"($loop2)p1" $"($loop3)p2" + vgcreate $vg_name $"($loop1)p2" $"($loop2)p1" $"($loop3)p2" + lvcreate -l "100%FREE" -n test_lv $vg_name + + let lv_path = $"/dev/($vg_name)/test_lv" + + # Create filesystem and mount + mkfs.ext4 -q $lv_path + mkdir $mountpoint + mount $lv_path $mountpoint + + # Create boot directory + mkdir $"($mountpoint)/boot" + + # Show block device hierarchy + lsblk --pairs --paths --inverse --output NAME,TYPE $lv_path + + run_install $mountpoint + + # Validate ESP installed on disk1 and disk3, disk2 has no ESP + validate_esp $"($loop1)p1" + validate_esp $"($loop3)p1" + } catch {|e| + cleanup $vg_name [$loop1, $loop2, $loop3] $mountpoint + rm -f $disk1 $disk2 $disk3 + error make {msg: $"Three devices partial ESP test failed: ($e)"} + } + + # Cleanup + cleanup $vg_name [$loop1, $loop2, $loop3] $mountpoint + rm -f $disk1 $disk2 $disk3 + + print "Three devices partial ESP test completed successfully" +} + +# Test scenario 4: Single device with ESP + root partition (no LVM) +def test_single_device_no_lvm [] { + print "Starting single device no LVM test" + + let mountpoint = "/var/mnt/test_no_lvm" + let disk1 = "/var/tmp/disk1_nolvm.img" + + let loop1 = (setup_disk_with_root $disk1 "10G") + + try { + # Mount root partition directly (no LVM) + mkdir $mountpoint + mount $"($loop1)p2" $mountpoint + + # Create boot directory + mkdir $"($mountpoint)/boot" + + # Show block device hierarchy + lsblk --pairs --paths --inverse --output NAME,TYPE $"($loop1)p2" + + run_install $mountpoint + + # Validate ESP was installed correctly + validate_esp $"($loop1)p1" + } catch {|e| + cleanup_simple $loop1 $mountpoint + rm -f $disk1 + error make {msg: $"Single device no LVM test failed: ($e)"} + } + + # Cleanup + cleanup_simple $loop1 $mountpoint + rm -f $disk1 + + print "Single device no LVM test completed successfully" +} + +# Test scenario 5: No ESP on any device (install should fail gracefully) +def test_no_esp_failure [] { + print "Starting no ESP failure test" + + let vg_name = "test_no_esp_vg" + let mountpoint = "/var/mnt/test_no_esp" + let disk1 = "/var/tmp/disk1_noesp.img" + let disk2 = "/var/tmp/disk2_noesp.img" + + # Setup disks - neither has ESP + let loop1 = (setup_disk_with_partitions $disk1 false) + let loop2 = (setup_disk_with_partitions $disk2 false) + + try { + # Create LVM spanning both devices + pvcreate $"($loop1)p1" $"($loop2)p1" + vgcreate $vg_name $"($loop1)p1" $"($loop2)p1" + lvcreate -l "100%FREE" -n test_lv $vg_name + + let lv_path = $"/dev/($vg_name)/test_lv" + + # Create filesystem and mount + mkfs.ext4 -q $lv_path + mkdir $mountpoint + mount $lv_path $mountpoint + + # Create boot directory + mkdir $"($mountpoint)/boot" + + # Show block device hierarchy + lsblk --pairs --paths --inverse --output NAME,TYPE $lv_path + + # Run install and expect it to fail + let result = (do { + run_install $mountpoint + } | complete) + + assert ($result.exit_code != 0) "Expected install to fail with no ESP partitions" + print $"Install failed as expected with exit code ($result.exit_code)" + } catch {|e| + cleanup $vg_name [$loop1, $loop2] $mountpoint + rm -f $disk1 $disk2 + error make {msg: $"No ESP failure test failed: ($e)"} + } + + # Cleanup + cleanup $vg_name [$loop1, $loop2] $mountpoint + rm -f $disk1 $disk2 + + print "No ESP failure test completed successfully" + tap ok +} + +def main [] { + # See https://tmt.readthedocs.io/en/stable/stories/features.html#reboot-during-test + match $env.TMT_REBOOT_COUNT? { + null | "0" => test_single_esp, + "1" => { test_dual_esp; test_three_devices_partial_esp; tmt-reboot }, + "2" => { test_single_device_no_lvm; test_no_esp_failure }, + $o => { error make { msg: $"Invalid TMT_REBOOT_COUNT ($o)" } }, + } +} diff --git a/tmt/tests/test-39-multi-device-esp.fmf b/tmt/tests/test-39-multi-device-esp.fmf new file mode 100644 index 000000000..415a2a537 --- /dev/null +++ b/tmt/tests/test-39-multi-device-esp.fmf @@ -0,0 +1,7 @@ +summary: Test multi-device ESP detection for to-existing-root +test: nu booted/test-multi-device-esp.nu +duration: 60m +require: + - lvm2 + - dosfstools + - e2fsprogs