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/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 cec6ecc37..4e945c065 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -548,8 +548,8 @@ 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_first_colocated_esp()?; ( root_setup.physical_root_path.clone(), @@ -586,10 +586,9 @@ 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_first_colocated_esp()?; ( Utf8PathBuf::from("/sysroot"), @@ -1097,7 +1096,8 @@ 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_first_colocated_esp()?; ( root_setup.physical_root_path.clone(), @@ -1112,10 +1112,9 @@ 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_first_colocated_esp()?; ( sysroot, diff --git a/crates/lib/src/bootloader.rs b/crates/lib/src/bootloader.rs index 0c19cc04f..f1987dc9e 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,12 +51,19 @@ 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_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(()) } @@ -67,6 +80,45 @@ 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) + .set_default_path() + .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 +143,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 +157,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 +187,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,20 +195,20 @@ 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 - // 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") @@ -145,6 +218,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,9 +231,15 @@ 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) - .ok_or_else(|| anyhow::anyhow!("ESP partition not found"))?; + let roots = device.find_all_roots()?; + 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/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/lib/src/store/mod.rs b/crates/lib/src/store/mod.rs index 8fd09d826..4f0cf4190 100644 --- a/crates/lib/src/store/mod.rs +++ b/crates/lib/src/store/mod.rs @@ -198,10 +198,9 @@ 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_first_colocated_esp()?; let esp_mount = mount_esp(&esp_dev.path())?; let boot_dir = match get_bootloader()? { diff --git a/crates/utils/src/bwrap.rs b/crates/utils/src/bwrap.rs index 353edb10d..1f0d0a07c 100644 --- a/crates/utils/src/bwrap.rs +++ b/crates/utils/src/bwrap.rs @@ -59,8 +59,20 @@ impl<'a> BwrapCmd<'a> { self } - /// Run the specified command inside the container. - pub fn run>(self, args: impl IntoIterator) -> Result<()> { + /// 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"); // Bind the root filesystem @@ -92,6 +104,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/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), + }, } } 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 <| 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 diff --git a/tmt/tests/booted/test-multi-device-esp.nu b/tmt/tests/booted/test-multi-device-esp.nu new file mode 100644 index 000000000..b3f69fcf3 --- /dev/null +++ b/tmt/tests/booted/test-multi-device-esp.nu @@ -0,0 +1,454 @@ +# number: 32 +# tmt: +# summary: Test multi-device ESP detection for to-existing-root +# duration: 60m +# +# Test that bootc install to-existing-root can find and use ESP partitions +# when the root filesystem spans multiple backing devices (e.g., LVM across disks). +# +# Five scenarios are tested across three reboot cycles: +# +# Reboot 0: +# 1. Single ESP: Only one of two backing devices has an ESP partition +# +# Reboot 1: +# 2. Dual ESP: Both backing devices have ESP partitions +# 3. Three devices, partial ESP: Three disks, ESP on disk1+disk3 only +# +# Reboot 2: +# 4. Single device (no LVM): ESP + root partition on a single disk +# 5. No ESP anywhere: Two disks with no ESP; install should fail gracefully +# +# This validates the fix for https://github.com/bootc-dev/bootc/issues/481 + +use std assert +use tap.nu + +const target_image = "localhost/bootc" + +# ESP partition type GUID +const ESP_TYPE = "C12A7328-F81F-11D2-BA4B-00A0C93EC93B" +# Linux LVM partition type GUID +const LVM_TYPE = "E6D6D379-F507-44C2-A23C-238F2A3DF928" +# Linux root (x86-64) partition type GUID +const ROOT_TYPE = "4F68BCE3-E8CD-4DB1-96E7-FBCAF984B709" + +# Cleanup function for LVM and loop devices +def cleanup [vg_name: string, loops: list, 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" + # 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 + 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 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) { + 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-41-multi-device-esp.fmf b/tmt/tests/test-41-multi-device-esp.fmf new file mode 100644 index 000000000..415a2a537 --- /dev/null +++ b/tmt/tests/test-41-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