From 460c2efa6f62c3a9ebea655e1d6595209dc28edd Mon Sep 17 00:00:00 2001 From: ckyrouac Date: Wed, 11 Mar 2026 15:39:03 -0400 Subject: [PATCH 1/9] install: Enable installing to devices with multiple parents Assisted-by: Claude Code (Opus 4) Signed-off-by: ckyrouac Signed-off-by: Colin Walters --- crates/lib/src/bootloader.rs | 111 +++++- crates/lib/src/install.rs | 5 +- crates/utils/src/bwrap.rs | 21 +- hack/provision-derived.sh | 38 ++ tmt/plans/integration.fmf | 11 + tmt/tests/booted/test-multi-device-esp.nu | 441 ++++++++++++++++++++++ tmt/tests/test-39-multi-device-esp.fmf | 7 + 7 files changed, 615 insertions(+), 19 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..fafad4915 100644 --- a/crates/lib/src/bootloader.rs +++ b/crates/lib/src/bootloader.rs @@ -11,7 +11,7 @@ use fn_error_context::context; use bootc_mount as mount; use crate::bootc_composefs::boot::{SecurebootKeys, mount_esp}; -use crate::{discoverable_partition_specification, utils}; +use crate::utils; /// The name of the mountpoint for efi (as a subdirectory of /boot, or at the toplevel) pub(crate) const EFI_DIR: &str = "efi"; @@ -23,7 +23,13 @@ const BOOTUPD_UPDATES: &str = "usr/lib/bootupd/updates"; // from: https://github.com/systemd/systemd/blob/26b2085d54ebbfca8637362eafcb4a8e3faf832f/man/systemd-boot.xml#L392 const SYSTEMD_KEY_DIR: &str = "loader/keys"; -/// Mount ESP part at /boot/efi +/// Mount the first ESP found among backing devices at /boot/efi. +/// +/// This is used by the install-alongside path to clean stale bootloader +/// files before reinstallation. On multi-device setups only the first +/// ESP is mounted and cleaned; stale files on additional ESPs are left +/// in place (bootupd will overwrite them during installation). +// TODO: clean all ESPs on multi-device setups pub(crate) fn mount_esp_part(root: &Dir, root_path: &Utf8Path, is_ostree: bool) -> Result<()> { let efi_path = Utf8Path::new("boot").join(crate::bootloader::EFI_DIR); let Some(esp_fd) = root @@ -45,11 +51,14 @@ pub(crate) fn mount_esp_part(root: &Dir, root_path: &Utf8Path, is_ostree: bool) root }; - let dev = bootc_blockdev::list_dev_by_dir(physical_root)?.require_single_root()?; - if let Some(esp_dev) = dev.find_partition_of_type(bootc_blockdev::ESP) { - let esp_path = esp_dev.path(); - bootc_mount::mount(&esp_path, &root_path.join(&efi_path))?; - tracing::debug!("Mounted {esp_path} at /boot/efi"); + let roots = bootc_blockdev::list_dev_by_dir(physical_root)?.find_all_roots()?; + for dev in &roots { + if let Some(esp_dev) = dev.find_partition_of_type(bootc_blockdev::ESP) { + let esp_path = esp_dev.path(); + bootc_mount::mount(&esp_path, &root_path.join(&efi_path))?; + tracing::debug!("Mounted {esp_path} at /boot/efi"); + return Ok(()); + } } Ok(()) } @@ -67,6 +76,48 @@ pub(crate) fn supports_bootupd(root: &Dir) -> Result { Ok(r) } +/// Check whether the target bootupd supports `--filesystem`. +/// +/// Runs `bootupctl backend install --help` and looks for `--filesystem` in the +/// output. When `deployment_path` is set the command runs inside a bwrap +/// container so we probe the binary from the target image. +fn bootupd_supports_filesystem(rootfs: &Utf8Path, deployment_path: Option<&str>) -> Result { + let help_args = ["bootupctl", "backend", "install", "--help"]; + let output = if let Some(deploy) = deployment_path { + let target_root = rootfs.join(deploy); + BwrapCmd::new(&target_root) + .setenv( + "PATH", + "/bin:/usr/bin:/sbin:/usr/sbin:/usr/local/bin:/usr/local/sbin", + ) + .run_get_string(help_args)? + } else { + Command::new("bootupctl") + .args(&help_args[1..]) + .log_debug() + .run_get_string()? + }; + + let use_filesystem = output.contains("--filesystem"); + + if use_filesystem { + tracing::debug!("bootupd supports --filesystem"); + } else { + tracing::debug!("bootupd does not support --filesystem, falling back to --device"); + } + + Ok(use_filesystem) +} + +/// Install the bootloader via bootupd. +/// +/// When the target bootupd supports `--filesystem` we pass it 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. +/// +/// For older bootupd versions that lack `--filesystem` we fall back to the +/// legacy `--device ` invocation. #[context("Installing bootloader")] pub(crate) fn install_via_bootupd( device: &bootc_blockdev::Device, @@ -91,8 +142,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 +156,29 @@ 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]); + + // When the target bootupd lacks --filesystem support, fall back to the + // legacy --device flag. For --device we need the whole-disk device path + // (e.g. /dev/vda), not a partition (e.g. /dev/vda3), so resolve the + // parent via require_single_root(). (Older bootupd doesn't support + // multiple backing devices anyway.) + // Computed before building bootupd_args so the String lives long enough. + let root_device_path = if bootupd_supports_filesystem(rootfs, deployment_path) + .context("Probing bootupd --filesystem support")? + { + None + } else { + Some(device.require_single_root()?.path()) + }; + if let Some(ref dev) = root_device_path { + tracing::debug!("bootupd does not support --filesystem, falling back to --device {dev}"); + bootupd_args.extend(["--device", dev]); + bootupd_args.push(rootfs_mount); + } else { + tracing::debug!("bootupd supports --filesystem"); + 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 +186,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); @@ -122,11 +194,17 @@ pub(crate) fn install_via_bootupd( let mut bwrap_args = vec!["bootupctl"]; bwrap_args.extend(bootupd_args); - let cmd = BwrapCmd::new(&target_root) + let mut 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"); + // Only bind mount the physical root at /sysroot when using --filesystem; + // bootupd needs it to resolve backing block devices via lsblk. + if root_device_path.is_none() { + cmd = cmd.bind(&rootfs_path, &"/sysroot"); + } + // The $PATH in the bwrap env is not complete enough for some images // so we inject a reasonnable default. // This is causing bootupctl and/or sfdisk binaries @@ -145,6 +223,11 @@ pub(crate) fn install_via_bootupd( } } +/// Install systemd-boot to the first ESP found among backing devices. +/// +/// On multi-device setups only the first ESP is installed to; additional +/// ESPs on other backing devices are left untouched. +// TODO: install to all ESPs on multi-device setups #[context("Installing bootloader")] pub(crate) fn install_systemd_boot( device: &bootc_blockdev::Device, @@ -153,8 +236,10 @@ pub(crate) fn install_systemd_boot( _deployment_path: Option<&str>, autoenroll: Option, ) -> Result<()> { - let esp_part = device - .find_partition_of_type(discoverable_partition_specification::ESP) + let roots = device.find_all_roots()?; + let esp_part = roots + .iter() + .find_map(|root| root.find_partition_of_type(discoverable_partition_specification::ESP)) .ok_or_else(|| anyhow::anyhow!("ESP partition not found"))?; let esp_mount = mount_esp(&esp_part.path()).context("Mounting ESP")?; diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 16b9db5a8..2a18c45c2 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -2611,9 +2611,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/crates/utils/src/bwrap.rs b/crates/utils/src/bwrap.rs index 353edb10d..3d3069ead 100644 --- a/crates/utils/src/bwrap.rs +++ b/crates/utils/src/bwrap.rs @@ -59,8 +59,8 @@ impl<'a> BwrapCmd<'a> { self } - /// Run the specified command inside the container. - pub fn run>(self, args: impl IntoIterator) -> Result<()> { + /// Build the bwrap `Command` with all bind mounts, env vars, and args. + fn build_command>(&self, args: impl IntoIterator) -> Command { let mut cmd = Command::new("bwrap"); // Bind the root filesystem @@ -92,6 +92,21 @@ impl<'a> BwrapCmd<'a> { cmd.arg("--"); cmd.args(args); - cmd.log_debug().run_inherited_with_cmd_context() + cmd + } + + /// Run the specified command inside the container. + pub fn run>(self, args: impl IntoIterator) -> Result<()> { + self.build_command(args) + .log_debug() + .run_inherited_with_cmd_context() + } + + /// Run the specified command inside the container and capture stdout as a string. + pub fn run_get_string>( + self, + args: impl IntoIterator, + ) -> Result { + self.build_command(args).log_debug().run_get_string() } } diff --git a/hack/provision-derived.sh b/hack/provision-derived.sh index 531115129..e8dcb665c 100755 --- a/hack/provision-derived.sh +++ b/hack/provision-derived.sh @@ -72,6 +72,44 @@ resize_rootfs: false CLOUDEOF fi +# Temporary: update bootupd from @CoreOS/continuous copr until +# base images include a version supporting --filesystem +. /usr/lib/os-release +case $ID in + fedora) copr_distro="fedora" ;; + *) copr_distro="centos-stream" ;; +esac +# Update bootc from rhcontainerbot copr; the new bootupd +# requires a newer bootc than what ships in some base images. +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 [] { + # This test exercises bootupd-based bootloader installation which only + # supports GRUB today. Skip when the image uses systemd-boot. + if (tap is_composefs) { + let st = bootc status --json | from json + if ($st.status.booted.composefs.bootloader | str downcase) == "systemd" { + print "SKIP: multi-device ESP test not supported with systemd-boot" + tap ok + return + } + } + + # 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 From 347a6f4963c0b8848fc9a80cb86da6e44ddb36ee Mon Sep 17 00:00:00 2001 From: ckyrouac Date: Wed, 25 Mar 2026 15:41:13 -0400 Subject: [PATCH 2/9] composefs: Walk parent devices to find ESP partition The composefs BLS and UKI boot setup paths called find_partition_of_esp() directly on the device, which fails when the root filesystem is on an LVM logical volume (the ESP is on the parent disk, not the LV). The store module had the same issue via require_single_root() + find_partition_of_esp(). Switch all call sites to find_colocated_esps() which walks up to the physical disk(s) via find_all_roots() before searching for the ESP, consistent with what install_systemd_boot and mount_esp_part already do. Assisted-by: Claude Code (Opus 4) Signed-off-by: ckyrouac Signed-off-by: Colin Walters --- crates/lib/src/bootc_composefs/boot.rs | 59 +++++++++++++++++++++----- crates/lib/src/store/mod.rs | 16 +++++-- 2 files changed, 60 insertions(+), 15 deletions(-) diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index cec6ecc37..1ab10c6f9 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -548,8 +548,18 @@ pub(crate) fn setup_composefs_bls_boot( } } - // Locate ESP partition device - let esp_part = root_setup.device_info.find_partition_of_esp()?; + // Locate ESP partition device by walking up to the root disk(s) + let esp_part = root_setup + .device_info + .find_colocated_esps()? + .and_then(|mut v| { + if v.is_empty() { + None + } else { + Some(v.remove(0)) + } + }) + .ok_or_else(|| anyhow::anyhow!("ESP partition not found"))?; ( root_setup.physical_root_path.clone(), @@ -586,10 +596,18 @@ pub(crate) fn setup_composefs_bls_boot( .context("Failed to create 'composefs=' parameter")?; cmdline.add_or_modify(¶m); - // Locate ESP partition device - let root_dev = - bootc_blockdev::list_dev_by_dir(&storage.physical_root)?.require_single_root()?; - let esp_dev = root_dev.find_partition_of_esp()?; + // Locate ESP partition device by walking up to the root disk(s) + let root_dev = bootc_blockdev::list_dev_by_dir(&storage.physical_root)?; + let esp_dev = root_dev + .find_colocated_esps()? + .and_then(|mut v| { + if v.is_empty() { + None + } else { + Some(v.remove(0)) + } + }) + .ok_or_else(|| anyhow::anyhow!("ESP partition not found"))?; ( Utf8PathBuf::from("/sysroot"), @@ -1097,7 +1115,18 @@ pub(crate) fn setup_composefs_uki_boot( BootSetupType::Setup((root_setup, state, postfetch, ..)) => { state.require_no_kargs_for_uki()?; - let esp_part = root_setup.device_info.find_partition_of_esp()?; + // Locate ESP partition device by walking up to the root disk(s) + let esp_part = root_setup + .device_info + .find_colocated_esps()? + .and_then(|mut v| { + if v.is_empty() { + None + } else { + Some(v.remove(0)) + } + }) + .ok_or_else(|| anyhow::anyhow!("ESP partition not found"))?; ( root_setup.physical_root_path.clone(), @@ -1112,10 +1141,18 @@ pub(crate) fn setup_composefs_uki_boot( let sysroot = Utf8PathBuf::from("/sysroot"); // Still needed for root_path let bootloader = host.require_composefs_booted()?.bootloader.clone(); - // Locate ESP partition device - let root_dev = - bootc_blockdev::list_dev_by_dir(&storage.physical_root)?.require_single_root()?; - let esp_dev = root_dev.find_partition_of_esp()?; + // Locate ESP partition device by walking up to the root disk(s) + let root_dev = bootc_blockdev::list_dev_by_dir(&storage.physical_root)?; + let esp_dev = root_dev + .find_colocated_esps()? + .and_then(|mut v| { + if v.is_empty() { + None + } else { + Some(v.remove(0)) + } + }) + .ok_or_else(|| anyhow::anyhow!("ESP partition not found"))?; ( sysroot, diff --git a/crates/lib/src/store/mod.rs b/crates/lib/src/store/mod.rs index 8fd09d826..ce715d59a 100644 --- a/crates/lib/src/store/mod.rs +++ b/crates/lib/src/store/mod.rs @@ -198,10 +198,18 @@ impl BootedStorage { } let composefs = Arc::new(composefs); - //TODO: this assumes a single ESP on the root device - let root_dev = - bootc_blockdev::list_dev_by_dir(&physical_root)?.require_single_root()?; - let esp_dev = root_dev.find_partition_of_esp()?; + // Locate ESP by walking up to the root disk(s) + let root_dev = bootc_blockdev::list_dev_by_dir(&physical_root)?; + let esp_dev = root_dev + .find_colocated_esps()? + .and_then(|mut v| { + if v.is_empty() { + None + } else { + Some(v.remove(0)) + } + }) + .ok_or_else(|| anyhow::anyhow!("ESP partition not found"))?; let esp_mount = mount_esp(&esp_dev.path())?; let boot_dir = match get_bootloader()? { From fa1b1d285d908f8a64fd2b6a72aafe6f0d2ca856 Mon Sep 17 00:00:00 2001 From: ckyrouac Date: Wed, 25 Mar 2026 20:14:02 -0400 Subject: [PATCH 3/9] tests: Use locally-built image in test-install-outside-container The test was using `get_target_image` which returns the upstream `docker://quay.io/centos-bootc/centos-bootc:stream10`. On composefs+grub variants provisioned with an updated bootupd from copr, the upstream image has stock bootupd with incompatible EFI update metadata, causing the install to fail with "Failed to find EFI update metadata". Switch to using `containers-storage:localhost/bootc` (the locally-built image), matching the pattern used by test-32, test-37, and test-38. The locally-built image has the updated bootupd with compatible metadata. Assisted-by: Claude Code (Opus 4) Signed-off-by: ckyrouac Signed-off-by: Colin Walters --- tmt/tests/booted/test-install-outside-container.nu | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tmt/tests/booted/test-install-outside-container.nu b/tmt/tests/booted/test-install-outside-container.nu index 45f66372c..d4eb0b6ac 100644 --- a/tmt/tests/booted/test-install-outside-container.nu +++ b/tmt/tests/booted/test-install-outside-container.nu @@ -6,9 +6,10 @@ use std assert use tap.nu -# Use an OS-matched target image to avoid version mismatches -# (e.g., XFS features created by newer mkfs.xfs not recognized by older grub2) -let target_image = (tap get_target_image) +# Use the locally-built image which has updated bootupd with compatible +# EFI update metadata, matching the pattern used by test-32/37/38. +bootc image copy-to-storage +let target_image = "containers-storage:localhost/bootc" # setup filesystem mkdir /var/mnt From c7ecf6fe22915995692f52021a83daebc61e4781 Mon Sep 17 00:00:00 2001 From: ckyrouac Date: Wed, 25 Mar 2026 20:53:46 -0400 Subject: [PATCH 4/9] tests: Fix install-outside-container for composefs+grub The initial change to use locally-built images had two additional issues on composefs: 1. containers-storage: transport fails on composefs's read-only root with "mkdir /.local: read-only file system". Fix by exporting the image to an OCI layout directory on writable /var/tmp instead. 2. run_install() was masking /sysroot/ostree and removing bootupd update metadata, which composefs needs for bootloader installation and boot binaries. Fix by making run_install() skip these ostree-specific workarounds on composefs systems. Note: the composefs install-outside-container code path still has a separate bug ("Shared boot binaries not found" in boot.rs:745) that needs fixing in the Rust code. Assisted-by: Claude Code (Opus 4) Signed-off-by: ckyrouac Signed-off-by: Colin Walters --- tmt/tests/booted/tap.nu | 13 +++++++++++-- tmt/tests/booted/test-install-outside-container.nu | 10 +++++----- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/tmt/tests/booted/tap.nu b/tmt/tests/booted/tap.nu index 686131079..34a5fc085 100644 --- a/tmt/tests/booted/tap.nu +++ b/tmt/tests/booted/tap.nu @@ -55,12 +55,21 @@ export def get_target_image [] { # Run a bootc install command in an isolated mount namespace. # This handles the common setup needed for install tests run outside a container. +# For ostree: masks off bootupd updates and /sysroot/ostree to reproduce +# https://github.com/bootc-dev/bootc/issues/1778 +# For composefs: only removes bound images (bootupd metadata and boot +# binaries under /sysroot/ostree are needed for installation). export def run_install [cmd: string] { + let is_cfs = (is_composefs) + let mask_cmds = if $is_cfs { + "true" + } else { + "if test -d /sysroot/ostree; then mount --bind /usr/share/empty /sysroot/ostree; fi\nrm -vrf /usr/lib/bootupd/updates" + } systemd-run -p MountFlags=slave -qdPG -- /bin/sh -c $" set -xeuo pipefail bootc usr-overlay -if test -d /sysroot/ostree; then mount --bind /usr/share/empty /sysroot/ostree; fi -rm -vrf /usr/lib/bootupd/updates +($mask_cmds) rm -vrf /usr/lib/bootc/bound-images.d ($cmd) " diff --git a/tmt/tests/booted/test-install-outside-container.nu b/tmt/tests/booted/test-install-outside-container.nu index d4eb0b6ac..8c88cf17a 100644 --- a/tmt/tests/booted/test-install-outside-container.nu +++ b/tmt/tests/booted/test-install-outside-container.nu @@ -7,9 +7,12 @@ use std assert use tap.nu # Use the locally-built image which has updated bootupd with compatible -# EFI update metadata, matching the pattern used by test-32/37/38. +# EFI update metadata. Export to OCI layout on a writable path since +# containers-storage: transport can't work when the root fs is read-only +# (composefs), and install-outside-container tests run directly on the host. bootc image copy-to-storage -let target_image = "containers-storage:localhost/bootc" +skopeo copy containers-storage:localhost/bootc oci:/var/tmp/bootc-oci +let target_image = "oci:/var/tmp/bootc-oci" # setup filesystem mkdir /var/mnt @@ -22,9 +25,6 @@ let result = bootc install to-filesystem /var/mnt e>| find "--source-imgref must assert not equal $result null umount /var/mnt -# Mask off the bootupd state to reproduce https://github.com/bootc-dev/bootc/issues/1778 -# Also it turns out that installation outside of containers dies due to `error: Multiple commit objects found` -# so we mask off /sysroot/ostree # And using systemd-run here breaks our install_t so we disable SELinux enforcement setenforce 0 From 58fa354beedfd5d990e9a4228c2e9c198d5126f4 Mon Sep 17 00:00:00 2001 From: ckyrouac Date: Thu, 26 Mar 2026 13:33:55 +0000 Subject: [PATCH 5/9] tests: Skip multi-device ESP test on non-UEFI systems The multi-device ESP test creates ESP partitions and expects bootupd to install a UEFI bootloader. On BIOS-booted systems, bootupd instead tries to install GRUB for i386-pc, which requires a BIOS Boot Partition and fails. The test plan already requests UEFI provisioning via the hardware hint, but Testing Farm does not always honor this on CentOS Stream x86_64. Add a runtime check for /sys/firmware/efi so the test skips gracefully on BIOS hosts rather than failing. Assisted-by: Claude Code (Opus 4.6) Signed-off-by: ckyrouac Signed-off-by: Colin Walters --- tmt/tests/booted/test-multi-device-esp.nu | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tmt/tests/booted/test-multi-device-esp.nu b/tmt/tests/booted/test-multi-device-esp.nu index d75f430ad..95f18e2fd 100644 --- a/tmt/tests/booted/test-multi-device-esp.nu +++ b/tmt/tests/booted/test-multi-device-esp.nu @@ -420,6 +420,16 @@ def test_no_esp_failure [] { } def main [] { + # This test requires a UEFI-booted host because it creates ESP partitions + # and expects bootupd to install a UEFI bootloader. On BIOS systems, + # bootupd would try to install GRUB for i386-pc which needs a BIOS Boot + # Partition instead of an ESP. + if not ("/sys/firmware/efi" | path exists) { + print "SKIP: multi-device ESP test requires UEFI boot" + tap ok + return + } + # This test exercises bootupd-based bootloader installation which only # supports GRUB today. Skip when the image uses systemd-boot. if (tap is_composefs) { From 15d28e1e70ce428de115b053454e8260afade80c Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Wed, 1 Apr 2026 17:55:14 +0000 Subject: [PATCH 6/9] bwrap: Add set_default_path() helper for standard PATH Extract the repeated PATH environment variable string into a set_default_path() method on BwrapCmd. The bwrap environment may not have a complete PATH, causing tools like bootupctl or sfdisk to not be found. This consolidates the workaround into one place. Assisted-by: OpenCode (Claude Opus 4) Signed-off-by: Colin Walters --- crates/lib/src/bootloader.rs | 21 ++++++------------- crates/utils/src/bwrap.rs | 12 +++++++++++ ...e-esp.fmf => test-41-multi-device-esp.fmf} | 0 3 files changed, 18 insertions(+), 15 deletions(-) rename tmt/tests/{test-39-multi-device-esp.fmf => test-41-multi-device-esp.fmf} (100%) diff --git a/crates/lib/src/bootloader.rs b/crates/lib/src/bootloader.rs index fafad4915..6544d643e 100644 --- a/crates/lib/src/bootloader.rs +++ b/crates/lib/src/bootloader.rs @@ -1,7 +1,7 @@ use std::fs::create_dir_all; use std::process::Command; -use anyhow::{Context, Result, anyhow, bail}; +use anyhow::{anyhow, bail, Context, Result}; use bootc_utils::{BwrapCmd, CommandRunExt}; use camino::Utf8Path; use cap_std_ext::cap_std::fs::Dir; @@ -10,8 +10,8 @@ use fn_error_context::context; use bootc_mount as mount; -use crate::bootc_composefs::boot::{SecurebootKeys, mount_esp}; -use crate::utils; +use crate::bootc_composefs::boot::{mount_esp, SecurebootKeys}; +use crate::{discoverable_partition_specification, utils}; /// The name of the mountpoint for efi (as a subdirectory of /boot, or at the toplevel) pub(crate) const EFI_DIR: &str = "efi"; @@ -86,10 +86,7 @@ fn bootupd_supports_filesystem(rootfs: &Utf8Path, deployment_path: Option<&str>) let output = if let Some(deploy) = deployment_path { let target_root = rootfs.join(deploy); BwrapCmd::new(&target_root) - .setenv( - "PATH", - "/bin:/usr/bin:/sbin:/usr/sbin:/usr/local/bin:/usr/local/sbin", - ) + .set_default_path() .run_get_string(help_args)? } else { Command::new("bootupctl") @@ -206,14 +203,8 @@ pub(crate) fn install_via_bootupd( } // The $PATH in the bwrap env is not complete enough for some images - // so we inject a reasonnable default. - // This is causing bootupctl and/or sfdisk binaries - // to be not found with fedora 43. - cmd.setenv( - "PATH", - "/bin:/usr/bin:/sbin:/usr/sbin:/usr/local/bin:/usr/local/sbin", - ) - .run(bwrap_args) + // so we inject a reasonable default. + cmd.set_default_path().run(bwrap_args) } else { // Running directly without chroot Command::new("bootupctl") diff --git a/crates/utils/src/bwrap.rs b/crates/utils/src/bwrap.rs index 3d3069ead..1f0d0a07c 100644 --- a/crates/utils/src/bwrap.rs +++ b/crates/utils/src/bwrap.rs @@ -59,6 +59,18 @@ impl<'a> BwrapCmd<'a> { self } + /// Set $PATH to a reasonable default for finding system binaries. + /// + /// The bwrap environment may not have a complete $PATH, causing + /// tools like bootupctl or sfdisk to not be found. This sets a + /// default that covers the standard binary directories. + pub fn set_default_path(self) -> Self { + self.setenv( + "PATH", + "/bin:/usr/bin:/sbin:/usr/sbin:/usr/local/bin:/usr/local/sbin", + ) + } + /// Build the bwrap `Command` with all bind mounts, env vars, and args. fn build_command>(&self, args: impl IntoIterator) -> Command { let mut cmd = Command::new("bwrap"); diff --git a/tmt/tests/test-39-multi-device-esp.fmf b/tmt/tests/test-41-multi-device-esp.fmf similarity index 100% rename from tmt/tests/test-39-multi-device-esp.fmf rename to tmt/tests/test-41-multi-device-esp.fmf From 11ddd188dc7c804c46748a24689cc0fc9f80a225 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Wed, 1 Apr 2026 19:23:02 +0000 Subject: [PATCH 7/9] blockdev: Clean up ESP discovery code Several improvements to ESP partition discovery: Add find_partition_of_esp_optional() returning Result> to cleanly separate three outcomes: found, absent, and genuinely unexpected errors (like unsupported partition table types). The existing find_partition_of_esp() is now a thin wrapper that converts None to Err. Add find_first_colocated_esp() helper to replace a 10-line pattern that was repeated verbatim 5 times across boot.rs and store/mod.rs. Deduplicate roots in find_all_roots() using a seen-set: in complex topologies like multipath, multiple parent branches can converge on the same physical disk. find_colocated_esps() now uses the optional variant to properly propagate real errors while treating absence normally. Also extract the match-on-if-else in setup_composefs_bls_boot into a let binding for readability. Assisted-by: OpenCode (Claude Opus 4) Signed-off-by: Colin Walters Signed-off-by: Chris Kyrouac --- crates/blockdev/src/blockdev.rs | 83 +++++++++++++++++--------- crates/lib/src/bootc_composefs/boot.rs | 46 ++------------ crates/lib/src/bootloader.rs | 24 +++++--- crates/lib/src/store/mod.rs | 11 +--- 4 files changed, 75 insertions(+), 89 deletions(-) diff --git a/crates/blockdev/src/blockdev.rs b/crates/blockdev/src/blockdev.rs index f544aa0be..ebcc25138 100644 --- a/crates/blockdev/src/blockdev.rs +++ b/crates/blockdev/src/blockdev.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::env; use std::path::Path; use std::process::{Command, Stdio}; @@ -123,15 +124,26 @@ impl Device { /// Calls find_all_roots() to discover physical disks, then searches each for an ESP. /// Returns None if no ESPs are found. pub fn find_colocated_esps(&self) -> Result>> { - let esps: Vec<_> = self - .find_all_roots()? - .iter() - .flat_map(|root| root.find_partition_of_esp().ok()) - .cloned() - .collect(); + let mut esps = Vec::new(); + for root in &self.find_all_roots()? { + if let Some(esp) = root.find_partition_of_esp_optional()? { + esps.push(esp.clone()); + } + } Ok((!esps.is_empty()).then_some(esps)) } + /// Find a single ESP partition among all root devices backing this device. + /// + /// Walks the parent chain to find all backing disks, then looks for ESP + /// partitions on each. Returns the first ESP found. This is the common + /// case for composefs/UKI boot paths where exactly one ESP is expected. + pub fn find_first_colocated_esp(&self) -> Result { + self.find_colocated_esps()? + .and_then(|mut v| Some(v.remove(0))) + .ok_or_else(|| anyhow!("No ESP partition found among backing devices")) + } + /// Find all BIOS boot partitions across all root devices backing this device. /// Calls find_all_roots() to discover physical disks, then searches each for a BIOS boot partition. /// Returns None if no BIOS boot partitions are found. @@ -159,34 +171,41 @@ impl Device { /// /// For GPT disks, this matches by the ESP partition type GUID. /// For MBR (dos) disks, this matches by the MBR partition type IDs (0x06 or 0xEF). - pub fn find_partition_of_esp(&self) -> Result<&Device> { - let children = self - .children - .as_ref() - .ok_or_else(|| anyhow!("Device has no children"))?; + /// + /// Returns `Ok(None)` when there are no children or no ESP partition + /// is present. Returns `Err` only for genuinely unexpected conditions + /// (e.g. an unsupported partition table type). + pub fn find_partition_of_esp_optional(&self) -> Result> { + let Some(children) = self.children.as_ref() else { + return Ok(None); + }; match self.pttype.as_deref() { - Some("dos") => children - .iter() - .find(|child| { - child - .parttype - .as_ref() - .and_then(|pt| { - let pt = pt.strip_prefix("0x").unwrap_or(pt); - u8::from_str_radix(pt, 16).ok() - }) - .is_some_and(|pt| ESP_ID_MBR.contains(&pt)) - }) - .ok_or_else(|| anyhow!("ESP not found in MBR partition table")), + Some("dos") => Ok(children.iter().find(|child| { + child + .parttype + .as_ref() + .and_then(|pt| { + let pt = pt.strip_prefix("0x").unwrap_or(pt); + u8::from_str_radix(pt, 16).ok() + }) + .is_some_and(|pt| ESP_ID_MBR.contains(&pt)) + })), // When pttype is None (e.g. older lsblk or partition devices), default // to GPT UUID matching which will simply not match MBR hex types. - Some("gpt") | None => self - .find_partition_of_type(ESP) - .ok_or_else(|| anyhow!("ESP not found in GPT partition table")), + Some("gpt") | None => Ok(self.find_partition_of_type(ESP)), Some(other) => Err(anyhow!("Unsupported partition table type: {other}")), } } + /// Find the EFI System Partition (ESP) among children, or error if absent. + /// + /// This is a convenience wrapper around [`Self::find_partition_of_esp_optional`] + /// for callers that require an ESP to be present. + pub fn find_partition_of_esp(&self) -> Result<&Device> { + self.find_partition_of_esp_optional()? + .ok_or_else(|| anyhow!("ESP partition not found on {}", self.path())) + } + /// Find a child partition by partition number (1-indexed). pub fn find_device_by_partno(&self, partno: u32) -> Result<&Device> { self.children @@ -308,6 +327,7 @@ impl Device { }; let mut roots = Vec::new(); + let mut seen = HashSet::new(); let mut queue = parents; while let Some(mut device) = queue.pop() { match device.children.take() { @@ -315,8 +335,13 @@ impl Device { queue.extend(grandparents); } _ => { - // Found a root; re-query to populate its actual children - roots.push(list_dev(Utf8Path::new(&device.path()))?); + // Deduplicate: in complex topologies (e.g. multipath) + // multiple branches can converge on the same physical disk. + let name = device.name.clone(); + if seen.insert(name) { + // Found a new root; re-query to populate its actual children + roots.push(list_dev(Utf8Path::new(&device.path()))?); + } } } } diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 1ab10c6f9..4e945c065 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -549,17 +549,7 @@ pub(crate) fn setup_composefs_bls_boot( } // Locate ESP partition device by walking up to the root disk(s) - let esp_part = root_setup - .device_info - .find_colocated_esps()? - .and_then(|mut v| { - if v.is_empty() { - None - } else { - Some(v.remove(0)) - } - }) - .ok_or_else(|| anyhow::anyhow!("ESP partition not found"))?; + let esp_part = root_setup.device_info.find_first_colocated_esp()?; ( root_setup.physical_root_path.clone(), @@ -598,16 +588,7 @@ pub(crate) fn setup_composefs_bls_boot( // Locate ESP partition device by walking up to the root disk(s) let root_dev = bootc_blockdev::list_dev_by_dir(&storage.physical_root)?; - let esp_dev = root_dev - .find_colocated_esps()? - .and_then(|mut v| { - if v.is_empty() { - None - } else { - Some(v.remove(0)) - } - }) - .ok_or_else(|| anyhow::anyhow!("ESP partition not found"))?; + let esp_dev = root_dev.find_first_colocated_esp()?; ( Utf8PathBuf::from("/sysroot"), @@ -1116,17 +1097,7 @@ pub(crate) fn setup_composefs_uki_boot( state.require_no_kargs_for_uki()?; // Locate ESP partition device by walking up to the root disk(s) - let esp_part = root_setup - .device_info - .find_colocated_esps()? - .and_then(|mut v| { - if v.is_empty() { - None - } else { - Some(v.remove(0)) - } - }) - .ok_or_else(|| anyhow::anyhow!("ESP partition not found"))?; + let esp_part = root_setup.device_info.find_first_colocated_esp()?; ( root_setup.physical_root_path.clone(), @@ -1143,16 +1114,7 @@ pub(crate) fn setup_composefs_uki_boot( // Locate ESP partition device by walking up to the root disk(s) let root_dev = bootc_blockdev::list_dev_by_dir(&storage.physical_root)?; - let esp_dev = root_dev - .find_colocated_esps()? - .and_then(|mut v| { - if v.is_empty() { - None - } else { - Some(v.remove(0)) - } - }) - .ok_or_else(|| anyhow::anyhow!("ESP partition not found"))?; + let esp_dev = root_dev.find_first_colocated_esp()?; ( sysroot, diff --git a/crates/lib/src/bootloader.rs b/crates/lib/src/bootloader.rs index 6544d643e..f1987dc9e 100644 --- a/crates/lib/src/bootloader.rs +++ b/crates/lib/src/bootloader.rs @@ -1,7 +1,7 @@ use std::fs::create_dir_all; use std::process::Command; -use anyhow::{anyhow, bail, Context, Result}; +use anyhow::{Context, Result, anyhow, bail}; use bootc_utils::{BwrapCmd, CommandRunExt}; use camino::Utf8Path; use cap_std_ext::cap_std::fs::Dir; @@ -10,8 +10,8 @@ use fn_error_context::context; use bootc_mount as mount; -use crate::bootc_composefs::boot::{mount_esp, SecurebootKeys}; -use crate::{discoverable_partition_specification, utils}; +use crate::bootc_composefs::boot::{SecurebootKeys, mount_esp}; +use crate::utils; /// The name of the mountpoint for efi (as a subdirectory of /boot, or at the toplevel) pub(crate) const EFI_DIR: &str = "efi"; @@ -53,13 +53,17 @@ pub(crate) fn mount_esp_part(root: &Dir, root_path: &Utf8Path, is_ostree: bool) let roots = bootc_blockdev::list_dev_by_dir(physical_root)?.find_all_roots()?; for dev in &roots { - if let Some(esp_dev) = dev.find_partition_of_type(bootc_blockdev::ESP) { + if let Some(esp_dev) = dev.find_partition_of_esp_optional()? { let esp_path = esp_dev.path(); bootc_mount::mount(&esp_path, &root_path.join(&efi_path))?; tracing::debug!("Mounted {esp_path} at /boot/efi"); return Ok(()); } } + tracing::debug!( + "No ESP partition found among {} root device(s)", + roots.len() + ); Ok(()) } @@ -228,10 +232,14 @@ pub(crate) fn install_systemd_boot( autoenroll: Option, ) -> Result<()> { let roots = device.find_all_roots()?; - let esp_part = roots - .iter() - .find_map(|root| root.find_partition_of_type(discoverable_partition_specification::ESP)) - .ok_or_else(|| anyhow::anyhow!("ESP partition not found"))?; + let mut esp_part = None; + for root in &roots { + if let Some(esp) = root.find_partition_of_esp_optional()? { + esp_part = Some(esp); + break; + } + } + let esp_part = esp_part.ok_or_else(|| anyhow::anyhow!("ESP partition not found"))?; let esp_mount = mount_esp(&esp_part.path()).context("Mounting ESP")?; let esp_path = Utf8Path::from_path(esp_mount.dir.path()) diff --git a/crates/lib/src/store/mod.rs b/crates/lib/src/store/mod.rs index ce715d59a..4f0cf4190 100644 --- a/crates/lib/src/store/mod.rs +++ b/crates/lib/src/store/mod.rs @@ -200,16 +200,7 @@ impl BootedStorage { // Locate ESP by walking up to the root disk(s) let root_dev = bootc_blockdev::list_dev_by_dir(&physical_root)?; - let esp_dev = root_dev - .find_colocated_esps()? - .and_then(|mut v| { - if v.is_empty() { - None - } else { - Some(v.remove(0)) - } - }) - .ok_or_else(|| anyhow::anyhow!("ESP partition not found"))?; + let esp_dev = root_dev.find_first_colocated_esp()?; let esp_mount = mount_esp(&esp_dev.path())?; let boot_dir = match get_bootloader()? { From 3ec055ff452fcb388cdb3616cc5e1905956c52bd Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Wed, 1 Apr 2026 21:21:35 +0000 Subject: [PATCH 8/9] tests: Validate ESP-related error in no-ESP failure test The no-ESP test only checked for a non-zero exit code, which would also pass if podman itself failed for unrelated reasons. Check that the output contains "ESP" to confirm the right failure mode. Assisted-by: OpenCode (Claude Opus 4) Signed-off-by: Colin Walters --- tmt/tests/booted/test-multi-device-esp.nu | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tmt/tests/booted/test-multi-device-esp.nu b/tmt/tests/booted/test-multi-device-esp.nu index 95f18e2fd..b3f69fcf3 100644 --- a/tmt/tests/booted/test-multi-device-esp.nu +++ b/tmt/tests/booted/test-multi-device-esp.nu @@ -404,6 +404,9 @@ def test_no_esp_failure [] { } | complete) assert ($result.exit_code != 0) "Expected install to fail with no ESP partitions" + # Verify the failure is ESP-related, not an unrelated podman/runtime error + let combined = $"($result.stdout)\n($result.stderr)" + assert ($combined | str contains "ESP") $"Expected ESP-related error message, got: ($combined | str substring 0..200)" print $"Install failed as expected with exit code ($result.exit_code)" } catch {|e| cleanup $vg_name [$loop1, $loop2] $mountpoint From f3d2d4e385e953aa91402174f73b747f31082e1a Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Mon, 6 Apr 2026 09:23:04 -0400 Subject: [PATCH 9/9] sysext: Add fast path dev flow For most work on `bootc upgrade` or `bootc switch`, rebuilding the full container image is unnecessary. This adds a fast-path workflow that builds just the bootc binary into a systemd-sysext and overlays it onto /usr in a persistent bcvk VM via virtiofs. For more info see the updated Justfile. There's still some TODOs here - e.g. we need to support running the TMT tests this way, etc. Assisted-by: OpenCode (claude-opus-4-6) Signed-off-by: Colin Walters --- CONTRIBUTING.md | 25 ++ Dockerfile | 17 ++ Justfile | 19 ++ bcvk.just | 84 ++++++ contrib/packaging/build-container-stage | 40 +++ crates/xtask/src/sysext.rs | 350 ++++++++++++++++++++++++ crates/xtask/src/xtask.rs | 38 +++ 7 files changed, 573 insertions(+) create mode 100644 bcvk.just create mode 100755 contrib/packaging/build-container-stage create mode 100644 crates/xtask/src/sysext.rs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 31d217f4c..85b45d7cf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -104,6 +104,31 @@ For the local case, check out [cstor-dist](https://github.com/cgwalters/cstor-di Another alternative is mounting via virtiofs (see e.g. [this PR to bcvk](https://github.com/bootc-dev/bcvk/pull/16)). If you're using libvirt, see [this document](https://libvirt.org/kbase/virtiofs.html). +#### Using sysext for fast iteration + +For the fastest development cycle when working on the bootc client +(e.g. `bootc upgrade`, `bootc switch`), you can use the sysext-based +workflow. This builds the bootc binary via a container, shares it into +a persistent VM via virtiofs, and overlays it onto `/usr` using +systemd-sysext (~30s rebuild cycle): + +```bash +# Build sysext and launch a persistent dev VM +just bcvk up + +# After editing code, rebuild and refresh the overlay (~30s) +just bcvk sync + +# SSH into the VM — bootc is your dev build +just bcvk ssh bootc status + +# When done +just bcvk down +``` + +The sysext overlay means `bootc` on the VM's PATH is your dev build. +Run `just bcvk` to list all available commands. + #### Running bootc against a live environment If your development environment host is also a bootc system (e.g. a diff --git a/Dockerfile b/Dockerfile index c050877a6..b228de578 100644 --- a/Dockerfile +++ b/Dockerfile @@ -99,6 +99,23 @@ ENV SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH} # Build RPM directly from source, using cached target directory RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp --mount=type=cache,target=/src/target --mount=type=cache,target=/var/roothome RPM_VERSION="${pkgversion}" /src/contrib/packaging/build-rpm +# Build a systemd-sysext containing just the bootc binary. +# Skips RPM machinery entirely for fast incremental rebuilds. +FROM buildroot as sysext +RUN --network=none --mount=type=tmpfs,target=/run --mount=type=tmpfs,target=/tmp \ + --mount=type=cache,target=/src/target --mount=type=cache,target=/var/roothome < /out/bootc/usr/lib/extension-release.d/extension-release.bootc < cargo xtask # -------------------------------------------------------------------- +mod bcvk 'bcvk.just' + # Configuration variables (override via environment or command line) # Example: BOOTC_base=quay.io/fedora/fedora-bootc:42 just build @@ -336,6 +338,16 @@ build-units: eval $(just _git-build-vars) podman build {{base_buildargs}} --build-arg=SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH} --build-arg=pkgversion=${VERSION} --target units -t localhost/bootc-units . +# ============================================================================ +# Development VM workflow (sysext-based) +# ============================================================================ + +# Build a systemd-sysext via the container build (binary only, for fast iteration) +[group('dev')] +sysext: + contrib/packaging/build-container-stage sysext target/sysext \ + {{base_buildargs}} $(just _local-deps-args) + # ============================================================================ # Internal helpers (prefixed with _) # ============================================================================ @@ -359,6 +371,13 @@ _git-build-vars: echo "SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH}" echo "VERSION=${VERSION}" +_local-deps-args: + #!/bin/bash + set -euo pipefail + if [[ -z "{{no_auto_local_deps}}" ]]; then + cargo xtask local-rust-deps + fi + _keygen: ./hack/generate-secureboot-keys diff --git a/bcvk.just b/bcvk.just new file mode 100644 index 000000000..ca20603d4 --- /dev/null +++ b/bcvk.just @@ -0,0 +1,84 @@ +# bcvk development VM management +# +# The dev binary is overlaid onto /usr via systemd-sysext. After +# rebuilding with `just sysext`, run `just bcvk sync` to +# refresh the overlay (~30s total cycle). +# +# Usage: +# just bcvk up # Build sysext + launch persistent VM +# just bcvk sync # Rebuild sysext + refresh overlay (~30s) +# just bcvk ssh # SSH into the VM +# just bcvk ephemeral # Ephemeral VM (full image, destroyed on exit) + +base_img := env("BOOTC_base_img", "localhost/bootc") + +# List available recipes +[private] +default: + @just --list bcvk + +# Run an ephemeral VM from the latest build and SSH in (destroyed on exit) +[group('ephemeral')] +ephemeral: + just build + bcvk ephemeral run-ssh {{base_img}} + +# Launch persistent development VM with sysext +[group('vm')] +up: + just sysext + cargo xtask bcvk vm + +# Rebuild sysext and verify the new binary in the running VM +[group('vm')] +sync: + just sysext + cargo xtask bcvk sync + +# SSH into development VM (interactive shell if no args given) +[group('vm')] +ssh *ARGS: + cargo xtask bcvk ssh {{ARGS}} + +# Stop and remove development VM +[group('vm')] +down: + cargo xtask bcvk down + +# Show development VM status +[group('vm')] +status: + cargo xtask bcvk status + +# Watch development VM logs +[group('vm')] +logs: + cargo xtask bcvk logs + +# Show sysext status in development VM +[group('vm')] +sysext-status: + cargo xtask bcvk ssh systemd-sysext status + +# Restart development VM +[group('vm')] +restart: + #!/bin/bash + set -euo pipefail + echo "Restarting development VM..." + cargo xtask bcvk ssh -- sudo systemctl reboot || true + sleep 5 + echo "Waiting for VM to come back up..." + for i in {1..30}; do + if cargo xtask bcvk ssh -- echo "VM is up" 2>/dev/null; then + echo "VM is back online!" + break + fi + echo "Waiting... (attempt $i/30)" + sleep 2 + done + +# Clean up all development resources (VM + sysext) +[group('vm')] +clean: + cargo xtask bcvk clean diff --git a/contrib/packaging/build-container-stage b/contrib/packaging/build-container-stage new file mode 100755 index 000000000..750eb6e13 --- /dev/null +++ b/contrib/packaging/build-container-stage @@ -0,0 +1,40 @@ +#!/bin/bash +# Build a Dockerfile stage and extract /out to a versioned subdirectory. +# Usage: build-container-stage [podman-build-args...] +# +# Each build creates a new timestamped directory inside +# (e.g. output-dir/bootc-1234567890/) and prints the version name to +# stdout on the last line. A "current" symlink is updated to point at +# the new version. Old versions are pruned (keeping the 2 most recent) +# so the previous version remains valid for any active overlay. +set -euo pipefail + +stage="${1:?Usage: build-container-stage [podman-build-args...]}" +output_dir="${2:?Usage: build-container-stage [podman-build-args...]}" +shift 2 + +image_tag="localhost/bootc-${stage}" + +podman build -t "${image_tag}" --target="${stage}" "$@" . + +mkdir -p "${output_dir}" + +# Extract into a versioned subdirectory, using the image build timestamp +# so the version name reflects when the binary was actually built. +version="bootc-$(podman inspect --format '{{.Created.Unix}}' "${image_tag}")" +version_dir="${output_dir}/${version}" +mkdir -p "${version_dir}" +podman run --rm "${image_tag}" tar -C /out -cf - . | tar -C "${version_dir}" -xvf - +chmod -R a+rX "${version_dir}" + +# Update the "current" symlink atomically +ln -sfn "${version}" "${output_dir}/current.tmp" +mv -Tf "${output_dir}/current.tmp" "${output_dir}/current" + +# Prune old versions, keeping the 2 most recent +ls -1dt "${output_dir}"/bootc-[0-9]* 2>/dev/null | tail -n +3 | while read -r old; do + rm -rf "${old}" +done + +# Print the version name so callers (xtask) can use it +echo "sysext-version=${version}" diff --git a/crates/xtask/src/sysext.rs b/crates/xtask/src/sysext.rs new file mode 100644 index 000000000..940dfa748 --- /dev/null +++ b/crates/xtask/src/sysext.rs @@ -0,0 +1,350 @@ +//! Development VM management with systemd-sysext overlay +//! +//! This module manages a persistent bcvk development VM where the bootc +//! binary is overlaid onto /usr via systemd-sysext. +//! +//! The `target/sysext/` directory is shared with the VM via virtiofs. +//! Inside it, each build creates a versioned subdirectory (e.g. +//! `bootc-1712345678/`) with a `current` symlink pointing at the latest. +//! Inside the VM, `/run/extensions/bootc` is a symlink into the virtiofs +//! mount that follows `current`. +//! +//! On sync, the host builds a new version, then the VM swaps its symlink +//! and runs `systemd-sysext refresh`. The old version's inodes stay +//! valid until the overlay is torn down during refresh. +//! +//! The development cycle is: +//! 1. `just bcvk up` — build sysext, launch VM, set up overlay +//! 2. Edit code +//! 3. `just bcvk sync` — rebuild + refresh overlay (~30s) +//! 4. Repeat from 2 + +use std::fs; +use std::process::Command; + +use anyhow::{Context, Result, bail}; +use camino::Utf8Path; +use fn_error_context::context; +use xshell::{Shell, cmd}; + +const SYSEXT_DIR: &str = "target/sysext"; +const DEV_VM_NAME: &str = "bootc-dev"; +const DEV_VM_LABEL: &str = "bootc.dev=1"; +/// Virtiofs mount point inside the VM. We avoid /run/extensions to +/// prevent systemd-sysext from auto-merging during early boot. +const VM_SYSEXT_MNT: &str = "/run/virtiofs-bootc-sysext"; +/// Symlink in the VM that points to the current sysext version. +const VM_EXTENSION_LINK: &str = "/run/extensions/bootc"; + +/// Read the current sysext version from the `current` symlink. +fn current_version() -> Result { + let link = Utf8Path::new(SYSEXT_DIR).join("current"); + let target = fs::read_link(&link) + .with_context(|| format!("No current sysext version (missing {})", link))?; + let version = target + .to_str() + .context("current symlink target is not UTF-8")? + .to_string(); + Ok(version) +} + +/// Launch or sync development VM +#[context("Managing bcvk VM")] +pub(crate) fn bcvk_vm(sh: &Shell) -> Result<()> { + check_vm_deps()?; + // Verify sysext exists + current_version().context("Run 'just sysext' first")?; + + if vm_exists()? { + println!("Development VM '{}' exists, syncing...", DEV_VM_NAME); + bcvk_vm_sync(sh) + } else { + println!("Creating development VM '{}'...", DEV_VM_NAME); + create_vm(sh) + } +} + +/// Rebuild the sysext and refresh the overlay in the VM. +#[context("Syncing to VM")] +pub(crate) fn bcvk_vm_sync(sh: &Shell) -> Result<()> { + check_vm_deps()?; + + if !vm_is_running()? { + bail!( + "Development VM '{}' is not running. Use 'just bcvk vm' to start it.", + DEV_VM_NAME + ); + } + + let version = current_version()?; + let target = format!("{}/{}/bootc", VM_SYSEXT_MNT, version); + + // Swap the extension symlink to the new version, then refresh. + // The old overlay still references valid inodes (the old versioned + // directory hasn't been deleted). systemd-sysext refresh will + // unmerge (dropping the old overlay) then re-merge (following the + // new symlink). + // + // We use systemd-run --no-block so that the SSH session returns + // immediately while systemd handles the unmerge→merge cycle + // asynchronously. + println!("Switching to sysext version: {}", version); + cmd!( + sh, + "bcvk libvirt ssh {DEV_VM_NAME} -- ln -sfn {target} {VM_EXTENSION_LINK}" + ) + .run() + .context("Failed to update extension symlink")?; + + println!("Refreshing sysext overlay..."); + cmd!( + sh, + "bcvk libvirt ssh {DEV_VM_NAME} -- systemd-run --no-block systemd-sysext refresh" + ) + .run() + .context("Failed to trigger sysext refresh")?; + + // Wait for the overlay merge to complete so the new bootc is in place. + poll( + "bootc available after sysext refresh", + std::time::Duration::from_secs(5), + || Ok(cmd!(sh, "bcvk libvirt ssh {DEV_VM_NAME} -- bootc --version").run()?), + )?; + + Ok(()) +} + +/// Stop and remove development VM +#[context("Stopping development VM")] +pub(crate) fn bcvk_vm_down(sh: &Shell) -> Result<()> { + check_vm_deps()?; + + if vm_exists()? { + cmd!(sh, "bcvk libvirt rm --stop --force {DEV_VM_NAME}") + .run() + .context("Failed to stop VM")?; + println!("Development VM '{}' stopped and removed", DEV_VM_NAME); + } else { + println!("No development VM '{}' found, nothing to do", DEV_VM_NAME); + } + Ok(()) +} + +/// SSH into development VM. +/// +/// Uses `std::process::Command` with inherited stdio so that interactive +/// sessions get a proper TTY. When args are given after `--`, they are +/// passed as a remote command; otherwise an interactive shell is opened. +#[context("SSH to development VM")] +pub(crate) fn bcvk_vm_ssh(_sh: &Shell, args: &[String]) -> Result<()> { + check_vm_deps()?; + + let mut cmd = Command::new("bcvk"); + cmd.args(["libvirt", "ssh", DEV_VM_NAME]); + if !args.is_empty() { + cmd.arg("--"); + cmd.args(args); + } + let status = cmd + .stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .status() + .context("Failed to run bcvk ssh")?; + if !status.success() { + bail!("ssh command failed with status {status}"); + } + Ok(()) +} + +/// Show VM status +#[context("Getting VM status")] +pub(crate) fn bcvk_vm_status(sh: &Shell) -> Result<()> { + check_vm_deps()?; + + if vm_exists()? { + cmd!(sh, "bcvk libvirt list {DEV_VM_NAME}") + .run() + .context("Failed to get VM status")?; + } else { + println!( + "No development VM '{}' found. Use 'just bcvk vm' to create one.", + DEV_VM_NAME + ); + } + + Ok(()) +} + +/// Watch VM logs +#[context("Watching VM logs")] +pub(crate) fn bcvk_vm_logs(sh: &Shell) -> Result<()> { + check_vm_deps()?; + + cmd!(sh, "bcvk libvirt ssh {DEV_VM_NAME} -- journalctl -f") + .run() + .context("Failed to watch logs")?; + + Ok(()) +} + +/// Clean all development resources +#[context("Cleaning development resources")] +pub(crate) fn bcvk_vm_clean(sh: &Shell) -> Result<()> { + bcvk_vm_down(sh).unwrap_or_else(|e| eprintln!("Warning: {}", e)); + + let sysext_dir = Utf8Path::new(SYSEXT_DIR); + if sysext_dir.exists() { + sh.remove_path(sysext_dir)?; + } + + println!("Cleaned up development VM and sysext files"); + Ok(()) +} + +// Helper functions + +#[context("Checking VM dependencies")] +fn check_vm_deps() -> Result<()> { + if Command::new("bcvk").arg("--version").output().is_err() { + bail!( + "bcvk is required for VM operations.\n\ + Install it from: https://github.com/bootc-dev/bcvk" + ); + } + + Ok(()) +} + +/// Query bcvk for a VM by name. +fn query_vm() -> Result> { + let output = Command::new("bcvk") + .args(["libvirt", "list", DEV_VM_NAME, "--format=json"]) + .output() + .context("Failed to run bcvk list")?; + + if !output.status.success() { + return Ok(None); + } + + let stdout = + String::from_utf8(output.stdout).context("Failed to parse bcvk output as UTF-8")?; + let val: serde_json::Value = + serde_json::from_str(&stdout).context("Failed to parse bcvk JSON output")?; + + match &val { + serde_json::Value::Object(_) => Ok(Some(val)), + serde_json::Value::Array(arr) => Ok(arr.first().cloned()), + _ => Ok(None), + } +} + +#[context("Checking if VM exists")] +fn vm_exists() -> Result { + Ok(query_vm()?.is_some()) +} + +#[context("Checking if VM is running")] +fn vm_is_running() -> Result { + Ok(query_vm()? + .as_ref() + .and_then(|v| v.get("state")) + .and_then(|s| s.as_str()) + == Some("running")) +} + +#[context("Creating development VM")] +fn create_vm(sh: &Shell) -> Result<()> { + let sysext_path = + fs::canonicalize(SYSEXT_DIR).context("Failed to get absolute path for sysext directory")?; + let sysext_path = sysext_path.to_string_lossy(); + + let version = current_version()?; + + let base_img = std::env::var("BOOTC_BASE_IMAGE") + .or_else(|_| std::env::var("BOOTC_base")) + .unwrap_or_else(|_| "quay.io/centos-bootc/centos-bootc:stream10".to_string()); + let bind_mount = format!("{}:{}", sysext_path, VM_SYSEXT_MNT); + + let variant = std::env::var("BOOTC_variant").unwrap_or_else(|_| "ostree".to_string()); + let mut bcvk_cmd = cmd!( + sh, + "bcvk libvirt run --name={DEV_VM_NAME} --replace --label={DEV_VM_LABEL} --bind={bind_mount}" + ); + + let seal_state = std::env::var("BOOTC_seal_state").unwrap_or_else(|_| "unsealed".to_string()); + if variant == "composefs" && seal_state == "sealed" { + let secureboot_dir = Utf8Path::new("target/test-secureboot"); + if !secureboot_dir.exists() { + println!("Generating secure boot keys for sealed variant..."); + cmd!(sh, "./hack/generate-secureboot-keys") + .run() + .context("Failed to generate secure boot keys")?; + } + bcvk_cmd = bcvk_cmd.arg("--secure-boot-keys=target/test-secureboot"); + } + + bcvk_cmd = bcvk_cmd.args(["--ssh-wait", &base_img]); + bcvk_cmd.run().context("Failed to create VM")?; + + // Set up the sysext: create a symlink from /run/extensions/bootc + // into the virtiofs-mounted versioned directory. + let target = format!("{}/{}/bootc", VM_SYSEXT_MNT, version); + println!("Setting up sysext overlay (version: {})...", version); + cmd!( + sh, + "bcvk libvirt ssh {DEV_VM_NAME} -- mkdir -p /run/extensions" + ) + .run() + .context("Failed to create /run/extensions")?; + + cmd!( + sh, + "bcvk libvirt ssh {DEV_VM_NAME} -- ln -sfn {target} {VM_EXTENSION_LINK}" + ) + .run() + .context("Failed to create extension symlink")?; + + cmd!(sh, "bcvk libvirt ssh {DEV_VM_NAME} -- systemd-sysext merge") + .run() + .context("Failed to merge sysext")?; + + cmd!( + sh, + "bcvk libvirt ssh {DEV_VM_NAME} -- systemd-sysext status" + ) + .run() + .context("Failed to get sysext status")?; + + println!(); + println!("Development VM is ready! bootc is overlaid on /usr via sysext."); + println!(" Rebuild+sync: just bcvk sync"); + println!(" SSH: just bcvk ssh"); + println!(" Test: just bcvk ssh bootc status"); + println!(" Stop: just bcvk down"); + + Ok(()) +} + +/// Poll a closure until it succeeds or the timeout elapses. +/// +/// Calls `f` repeatedly with a 1-second interval. Returns the first +/// `Ok` value, or the last error if the timeout is reached. +fn poll( + condition: &str, + timeout: std::time::Duration, + mut f: impl FnMut() -> Result, +) -> Result { + let start = std::time::Instant::now(); + let mut last_err = None; + while start.elapsed() < timeout { + match f() { + Ok(v) => return Ok(v), + Err(e) => { + last_err = Some(e); + std::thread::sleep(std::time::Duration::from_secs(1)); + } + } + } + Err(last_err.unwrap_or_else(|| anyhow::anyhow!("timed out waiting for: {condition}"))) +} diff --git a/crates/xtask/src/xtask.rs b/crates/xtask/src/xtask.rs index 4bb584c82..0332dba5d 100644 --- a/crates/xtask/src/xtask.rs +++ b/crates/xtask/src/xtask.rs @@ -18,6 +18,7 @@ use xshell::{Shell, cmd}; mod buildsys; mod man; +mod sysext; mod tmt; const NAME: &str = "bootc"; @@ -62,6 +63,34 @@ enum Commands { ValidateComposefsDigest(ValidateComposefsDigestArgs), /// Print podman bind mount arguments for local path dependencies LocalRustDeps(LocalRustDepsArgs), + /// Development VM management via bcvk + systemd-sysext + Bcvk { + #[command(subcommand)] + command: BcvkCommands, + }, +} + +/// Subcommands for development VM management +#[derive(Debug, Subcommand)] +enum BcvkCommands { + /// Launch or sync persistent development VM with sysext + Vm, + /// Sync sysext to running development VM + Sync, + /// Stop and remove development VM + Down, + /// SSH into development VM (interactive shell if no command given) + Ssh { + /// Command to run in the VM (omit for interactive shell) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Show development VM status + Status, + /// Watch development VM logs + Logs, + /// Clean all development resources + Clean, } /// Arguments for validate-composefs-digest command @@ -250,6 +279,15 @@ fn try_main() -> Result<()> { Commands::CheckBuildsys => buildsys::check_buildsys(&sh, "Dockerfile".into()), Commands::ValidateComposefsDigest(args) => validate_composefs_digest(&sh, &args), Commands::LocalRustDeps(args) => local_rust_deps(&sh, &args), + Commands::Bcvk { command } => match command { + BcvkCommands::Vm => sysext::bcvk_vm(&sh), + BcvkCommands::Sync => sysext::bcvk_vm_sync(&sh), + BcvkCommands::Down => sysext::bcvk_vm_down(&sh), + BcvkCommands::Ssh { args } => sysext::bcvk_vm_ssh(&sh, &args), + BcvkCommands::Status => sysext::bcvk_vm_status(&sh), + BcvkCommands::Logs => sysext::bcvk_vm_logs(&sh), + BcvkCommands::Clean => sysext::bcvk_vm_clean(&sh), + }, } }