From a0dba3d6276a77d76d88a68555b18f42694d86fa Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Fri, 17 Jul 2026 15:02:34 +0530 Subject: [PATCH 1/5] system-reinstall: Propagate boot mount in container systemd gpt-auto-generator has automount for /boot which we need to propagate inside the container for the service to mount the ESP at /boot on access Also, before remounting `/boot` as rw, check if it's readonly so we don't fail the re-mount operation Signed-off-by: Pragyan Poudyal --- crates/lib/src/bootloader.rs | 13 +++++++++++++ crates/lib/src/install.rs | 6 ++++-- crates/lib/src/utils.rs | 17 ++++++++++++++--- crates/system-reinstall-bootc/src/podman.rs | 4 +++- 4 files changed, 34 insertions(+), 6 deletions(-) diff --git a/crates/lib/src/bootloader.rs b/crates/lib/src/bootloader.rs index 0a2836f88f..e9b47b7f08 100644 --- a/crates/lib/src/bootloader.rs +++ b/crates/lib/src/bootloader.rs @@ -10,6 +10,7 @@ use cap_std_ext::dirext::CapStdExtDirExt; use fn_error_context::context; use bootc_mount as mount; +use rustix::fs::statfs; use crate::bootc_composefs::boot::{MountedImageRoot, SecurebootKeys}; use crate::utils; @@ -46,6 +47,18 @@ const BOOTCTL_RANDOM_SEED_MIN_VERSION: u32 = 257; /// 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<()> { + // systemd-gpt-auto-generator automounts ESP at /boot + let is_boot_mountpoint = root.is_mountpoint("boot")?; + + if matches!(is_boot_mountpoint, Some(true)) { + let statfs = statfs(root_path.join("boot").as_std_path())?; + + // We probably don't need to be this thorough, but no harm done + if statfs.f_type == libc::MSDOS_SUPER_MAGIC { + return Ok(()); + } + } + let efi_path = Utf8Path::new("boot").join(crate::bootloader::EFI_DIR); let Some(esp_fd) = root .open_dir_optional(&efi_path) diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 3c5182fd75..5fc4118a0a 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -2301,12 +2301,14 @@ fn remove_all_in_dir_no_xdev(d: &Dir, mount_err: bool) -> Result<()> { if etype == FileType::dir() { if let Some(subdir) = d.open_dir_noxdev(&name)? { remove_all_in_dir_no_xdev(&subdir, mount_err)?; - d.remove_dir(&name)?; + d.remove_dir(&name) + .with_context(|| format!("Removing dir {name:?}"))?; } else if mount_err { anyhow::bail!("Found unexpected mount point {name:?}"); } } else { - d.remove_file_optional(&name)?; + d.remove_file_optional(&name) + .with_context(|| format!("Removing {name:?}"))?; } } anyhow::Ok(()) diff --git a/crates/lib/src/utils.rs b/crates/lib/src/utils.rs index 876b2e9f03..bebf50c42b 100644 --- a/crates/lib/src/utils.rs +++ b/crates/lib/src/utils.rs @@ -1,6 +1,6 @@ use std::future::Future; use std::io::Write; -use std::os::fd::BorrowedFd; +use std::os::fd::{AsFd, BorrowedFd}; use std::path::{Component, Path, PathBuf}; use std::process::Command; use std::time::Duration; @@ -17,6 +17,7 @@ use libsystemd::logging::journal_print; use ostree::glib; use ostree_ext::container::SignatureSource; use ostree_ext::ostree; +use rustix::fs::StatVfsMountFlags; /// Try to look for keys injected by e.g. rpm-ostree requesting machine-local /// changes; if any are present, return `true`. @@ -83,7 +84,16 @@ pub fn have_executable(name: &str) -> Result { /// Given a target directory, if it's a read-only mount, then remount it writable #[context("Opening {target} with writable mount")] pub(crate) fn open_dir_remount_rw(root: &Dir, target: &Utf8Path) -> Result { - if matches!(root.is_mountpoint(target), Ok(Some(true))) { + let target_dir = root.open_dir(target).with_context(|| format!("Opening {target}"))?; + + if matches!(target_dir.is_mountpoint("."), Ok(Some(true))) { + let st = rustix::fs::fstatvfs(target_dir.as_fd()) + .with_context(|| format!("Getting filesystem info for {target}"))?; + + if !st.f_flag.contains(StatVfsMountFlags::RDONLY) { + return Ok(target_dir); + }; + tracing::debug!("Target {target} is a mountpoint, remounting rw"); let st = Command::new("mount") .args(["-o", "remount,rw", target.as_str()]) @@ -92,7 +102,8 @@ pub(crate) fn open_dir_remount_rw(root: &Dir, target: &Utf8Path) -> Result anyhow::ensure!(st.success(), "Failed to remount: {st:?}"); } - root.open_dir(target).map_err(anyhow::Error::new) + + Ok(target_dir) } /// Given a target path, remove its immutability if present diff --git a/crates/system-reinstall-bootc/src/podman.rs b/crates/system-reinstall-bootc/src/podman.rs index 7a9e104f8e..b5367afeb6 100644 --- a/crates/system-reinstall-bootc/src/podman.rs +++ b/crates/system-reinstall-bootc/src/podman.rs @@ -50,7 +50,9 @@ pub(crate) fn reinstall_command( "--security-opt", "label=type:unconfined_t", "-v", - "/:/target", + // systemd gpt-auto-generator has boot.mount autofs mounted on /boot + // We want to propage that inside the container + "/:/target:rslave", ] .map(String::from) .to_vec(); From 87ea25bd3a5883e2701096380bd90ff0b4b27d41 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Fri, 17 Jul 2026 15:37:08 +0530 Subject: [PATCH 2/5] composefs: Fix `install to-existing-root` Fix a few issues with composefs path for `install to-existing-root`. 1. Composefs repository initialization Since we mount `/sysroot:ro` at `/target`, composefs repository initialization would fail with `Read only filesystem`. Fix it by remounting `/target/sysroot` read-write 2. Handle bootloader Get the current bootloader by reading `LoaderInfo` and try to install the same one Signed-off-by: Pragyan Poudyal --- crates/lib/src/bootc_composefs/repo.rs | 1 + crates/lib/src/install.rs | 43 +++++++++++++++------ crates/lib/src/store/mod.rs | 7 ++++ crates/system-reinstall-bootc/src/podman.rs | 2 + 4 files changed, 42 insertions(+), 11 deletions(-) diff --git a/crates/lib/src/bootc_composefs/repo.rs b/crates/lib/src/bootc_composefs/repo.rs index 4d9fe068df..f96289d3bc 100644 --- a/crates/lib/src/bootc_composefs/repo.rs +++ b/crates/lib/src/bootc_composefs/repo.rs @@ -76,6 +76,7 @@ pub(crate) fn open_composefs_repo(rootfs_dir: &Dir) -> Result, + replace_mode: Option, ) -> Result> { tracing::trace!("Preparing install"); let rootfs = cap_std::fs::Dir::open_ambient_dir("/", cap_std::ambient_authority()) @@ -1698,6 +1707,12 @@ async fn prepare_install( setup_sys_mount("efivarfs", EFIVARFS)?; + // Read efivars to get the bootloader + // Only if the operation is to replace the existing installation + if let Some(..) = replace_mode { + config_opts.bootloader = Some(get_bootloader().context("Determining existing bootloader")?) + }; + // Now, deal with SELinux state. let selinux_state = reexecute_self_for_selinux_if_needed(&source, config_opts.disable_selinux)?; tracing::debug!("SELinux state: {selinux_state:?}"); @@ -2175,6 +2190,7 @@ pub(crate) async fn install_to_disk(mut opts: InstallToDiskOpts) -> Result<()> { opts.target_opts, opts.composefs_opts, block_opts.filesystem, + None, ) .await?; @@ -2564,6 +2580,7 @@ pub(crate) async fn install_to_filesystem( opts.target_opts, opts.composefs_opts, Some(inspect.fstype.as_str().try_into()?), + fsopts.replace, ) .await?; @@ -2637,18 +2654,22 @@ pub(crate) async fn install_to_filesystem( false } }; + // Find the UUID of /boot because we need it for GRUB. - let boot_uuid = if boot_is_mount { - let boot_path = target_root_path.join(BOOT); - tracing::debug!("boot_path={boot_path}"); - let u = bootc_mount::inspect_filesystem(&boot_path) - .with_context(|| format!("Inspecting /{BOOT}"))? - .uuid - .ok_or_else(|| anyhow!("No UUID found for /{BOOT}"))?; - Some(u) - } else { - None + let boot_uuid = match state.config_opts.bootloader { + Some(Bootloader::Grub) | None if boot_is_mount => { + let boot_path = target_root_path.join(BOOT); + tracing::debug!("boot_path={boot_path}"); + let u = bootc_mount::inspect_filesystem(&boot_path) + .with_context(|| format!("Inspecting /{BOOT}"))? + .uuid + .ok_or_else(|| anyhow!("No UUID found for /{BOOT}"))?; + Some(u) + } + + _ => None, }; + tracing::debug!("boot UUID: {boot_uuid:?}"); // Find the real underlying backing device for the root. This is currently just required diff --git a/crates/lib/src/store/mod.rs b/crates/lib/src/store/mod.rs index d417b17dec..c1e705d21a 100644 --- a/crates/lib/src/store/mod.rs +++ b/crates/lib/src/store/mod.rs @@ -137,7 +137,13 @@ pub(crate) const COMPOSEFS_MODE: Mode = Mode::from_raw_mode(0o700); /// Ensure the composefs directory exists in the given physical root /// with the correct permissions (mode 0700). +#[context("Ensuring composefs directory")] pub(crate) fn ensure_composefs_dir(physical_root: &Dir) -> Result<()> { + // Usually the case with bootc install to-existing-root + if matches!(physical_root.is_mountpoint("."), Ok(Some(true))) { + crate::utils::open_dir_remount_rw(physical_root, ".".into())?; + } + let mut db = DirBuilder::new(); db.mode(COMPOSEFS_MODE.as_raw_mode()); physical_root @@ -695,6 +701,7 @@ impl Storage { /// whatever EROFS format version it was created with (e.g. V2 from an older /// composefs-rs). A fresh repository is only initialized when no `meta.json` /// is found, using the current default format version from composefs-rs. + #[context("Ensuring composefs")] pub(crate) fn get_ensure_composefs(&self) -> Result> { if let Some(composefs) = self.composefs.get() { return Ok(Arc::clone(composefs)); diff --git a/crates/system-reinstall-bootc/src/podman.rs b/crates/system-reinstall-bootc/src/podman.rs index b5367afeb6..85a0837872 100644 --- a/crates/system-reinstall-bootc/src/podman.rs +++ b/crates/system-reinstall-bootc/src/podman.rs @@ -30,6 +30,8 @@ pub(crate) fn reinstall_command( ssh_key_file: &str, has_clean: bool, ) -> Result { + // NOTE: We mount /sys/firmware/efi/efivars during installation setup in + // [`bootc_lib::prepare_install`] so we don't explicitly need it here let mut podman_command_and_args = [ // We use podman to run the bootc container. This might change in the future to remove the // podman dependency. From 7829665a7c6c482c367c35e76dd1f5a2fcf55710 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Fri, 17 Jul 2026 16:07:46 +0530 Subject: [PATCH 3/5] cfs/to-existing-root: Bootloader support To figure out which bootloader to install, get the host's bootloader (read from LoaderInfo from efivars) and store it in state. Before finalizing the bootloader, check if the detected bootloader is compatible with the current image (as the bootloader on host and the one in the new image might differ) Signed-off-by: Pragyan Poudyal --- crates/lib/src/install.rs | 87 +++++++++++++++++++++++++++++++-------- crates/lib/src/utils.rs | 4 +- 2 files changed, 73 insertions(+), 18 deletions(-) diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index e5385ff1c6..b9223baa52 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -642,8 +642,12 @@ pub(crate) struct State { #[allow(dead_code)] pub(crate) composefs_required: bool, - // If Some, then --composefs_native is passed + /// If Some, then --composefs-backend is passed pub(crate) composefs_options: InstallComposefsOpts, + + /// The bootloader on the host (determined by reading LoaderInfo from efivars) + /// Only Some when bootc is invoked with `install to-existing-root` + pub(crate) host_bootloader: Option, } // Shared read-only global state @@ -1709,8 +1713,13 @@ async fn prepare_install( // Read efivars to get the bootloader // Only if the operation is to replace the existing installation - if let Some(..) = replace_mode { - config_opts.bootloader = Some(get_bootloader().context("Determining existing bootloader")?) + let host_bootloader = match replace_mode { + Some(..) => { + let host_bootloader = get_bootloader().context("Determining existing bootloader")?; + println!("Detected bootloader on host: {host_bootloader}"); + Some(host_bootloader) + } + None => None, }; // Now, deal with SELinux state. @@ -1819,6 +1828,7 @@ async fn prepare_install( host_is_container, composefs_required, composefs_options, + host_bootloader, }); Ok(state) @@ -1826,19 +1836,52 @@ async fn prepare_install( impl PostFetchState { pub(crate) fn new(state: &State, d: &Dir) -> Result { + let supports_bootupd = crate::bootloader::supports_bootupd(d)?; + // Determine bootloader type for the target system // Priority: user-specified > bootupd availability > systemd-boot fallback let detected_bootloader = { if let Some(bootloader) = state.config_opts.bootloader.clone() { bootloader } else { - if crate::bootloader::supports_bootupd(d)? { + // TODO(Johan-Liebert1): The new release of bootupd would support all + if supports_bootupd { crate::spec::Bootloader::Grub } else { crate::spec::Bootloader::Systemd } } }; + + // If this exists it means we're replacing the current root + // or installing alongside it. The new image may or may not have + // the same bootloader as the host + // + // We could simply throw an error here, but at this point we'd have + // already nuked the boot or ESP so we should try our best to figure + // out what to install + let detected_bootloader = match state.host_bootloader { + Some(b) => match b { + Bootloader::Grub | Bootloader::GrubCC => { + if supports_bootupd { + b + } else { + Bootloader::Systemd + } + } + Bootloader::Systemd => match crate::bootloader::bootctl_systemd_version() { + Ok(_) => Bootloader::Systemd, + Err(_) => { + println!("Could not find bootctl, defaulting to Grub"); + Bootloader::Grub + } + }, + Bootloader::None => Bootloader::None, + }, + + None => detected_bootloader, + }; + println!("Bootloader: {detected_bootloader}"); let r = Self { detected_bootloader, @@ -2655,21 +2698,31 @@ pub(crate) async fn install_to_filesystem( } }; - // Find the UUID of /boot because we need it for GRUB. - let boot_uuid = match state.config_opts.bootloader { - Some(Bootloader::Grub) | None if boot_is_mount => { - let boot_path = target_root_path.join(BOOT); - tracing::debug!("boot_path={boot_path}"); - let u = bootc_mount::inspect_filesystem(&boot_path) - .with_context(|| format!("Inspecting /{BOOT}"))? - .uuid - .ok_or_else(|| anyhow!("No UUID found for /{BOOT}"))?; - Some(u) - } - - _ => None, + let mut boot_uuid = None; + + let get_boot_uuid = || -> Result> { + let boot_path = target_root_path.join(BOOT); + tracing::debug!("boot_path={boot_path}"); + let u = bootc_mount::inspect_filesystem(&boot_path) + .with_context(|| format!("Inspecting /{BOOT}"))? + .uuid + .ok_or_else(|| anyhow!("No UUID found for /{BOOT}"))?; + + Ok(Some(u)) }; + // Find the UUID of /boot because we need it for GRUB. + if boot_is_mount { + if matches!(state.host_bootloader, Some(Bootloader::Grub)) { + boot_uuid = get_boot_uuid().context("Getting boot uuid")?; + } + + // If not set by `host_bootloader` + if boot_uuid.is_none() && matches!(state.config_opts.bootloader, Some(Bootloader::Grub)) { + boot_uuid = get_boot_uuid().context("Getting boot uuid")?; + } + } + tracing::debug!("boot UUID: {boot_uuid:?}"); // Find the real underlying backing device for the root. This is currently just required diff --git a/crates/lib/src/utils.rs b/crates/lib/src/utils.rs index bebf50c42b..5086e6aac4 100644 --- a/crates/lib/src/utils.rs +++ b/crates/lib/src/utils.rs @@ -84,7 +84,9 @@ pub fn have_executable(name: &str) -> Result { /// Given a target directory, if it's a read-only mount, then remount it writable #[context("Opening {target} with writable mount")] pub(crate) fn open_dir_remount_rw(root: &Dir, target: &Utf8Path) -> Result { - let target_dir = root.open_dir(target).with_context(|| format!("Opening {target}"))?; + let target_dir = root + .open_dir(target) + .with_context(|| format!("Opening {target}"))?; if matches!(target_dir.is_mountpoint("."), Ok(Some(true))) { let st = rustix::fs::fstatvfs(target_dir.as_fd()) From 53a3cee3b47c7054bd609849081b4bbd5cb22383 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Fri, 17 Jul 2026 18:20:35 +0530 Subject: [PATCH 4/5] tmt: Add tests for composefs system-reinstall Mostly generated by LLM with some tweaks by me Do not remove /usr/lib/bootupd/updates even when going down the systemd-boot installation path as we require the metadata for testing system reinstall Signed-off-by: Pragyan Poudyal --- contrib/packaging/switch-to-sdboot | 1 - tmt/plans/integration.fmf | 7 + .../booted/test-system-reinstall-composefs.nu | 168 ++++++++++++++++++ tmt/tests/tests.fmf | 5 + 4 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 tmt/tests/booted/test-system-reinstall-composefs.nu diff --git a/contrib/packaging/switch-to-sdboot b/contrib/packaging/switch-to-sdboot index 6a6c130bcf..7f722ac49b 100755 --- a/contrib/packaging/switch-to-sdboot +++ b/contrib/packaging/switch-to-sdboot @@ -27,7 +27,6 @@ done if [ "${#pkgs_to_remove[@]}" -gt 0 ]; then rpm -e "${pkgs_to_remove[@]}" fi -rm -vrf /usr/lib/bootupd/updates # First install the unsigned systemd-boot RPM to get the package in place rpm -Uvh --replacepkgs "${src}"/*.rpm diff --git a/tmt/plans/integration.fmf b/tmt/plans/integration.fmf index 271d862efe..df34a63e1f 100644 --- a/tmt/plans/integration.fmf +++ b/tmt/plans/integration.fmf @@ -300,4 +300,11 @@ execute: how: fmf test: - /tmt/tests/tests/test-48-composefs-uki-dumpfile + +/plan-49-system-reinstall-composefs: + summary: Test system-reinstall with composefs backend and bootloader compatibility + discover: + how: fmf + test: + - /tmt/tests/tests/test-49-system-reinstall-composefs # END GENERATED PLANS diff --git a/tmt/tests/booted/test-system-reinstall-composefs.nu b/tmt/tests/booted/test-system-reinstall-composefs.nu new file mode 100644 index 0000000000..4f14c1e794 --- /dev/null +++ b/tmt/tests/booted/test-system-reinstall-composefs.nu @@ -0,0 +1,168 @@ +# number: 49 +# tmt: +# summary: Test system-reinstall with composefs backend and bootloader compatibility +# duration: 60m +# +# Tests that system-reinstall-bootc works with composefs backend by running +# `bootc install to-existing-root --composefs-backend` on the live root, +# matching the invocation pattern of system-reinstall-bootc. +# +# Three scenarios across three reboot cycles: +# +# Reboot 0: +# Same bootloader: image supports the host's bootloader +# → that bootloader should be installed +# +# Reboot 1: +# Verify same-bootloader result, then: +# Bootloader mismatch: image lacks support for the host's bootloader +# → fallback bootloader should be installed +# (skipped for UKI boot type where switching bootloaders is unsafe) +# +# Reboot 2: +# Verify mismatch fallback result +# +use std assert +use tap.nu + +if not (tap is_composefs) { + exit 0 +} + +let st = bootc status --json | from json + +# Run bootc install to-existing-root --composefs-backend via podman, +# matching the system-reinstall-bootc invocation from podman.rs. +# Key details tested by these flags: +# /:/target:rslave — propagates boot automount (commit 3bf1acba) +# --composefs-backend — exercises composefs repo init fix (commit fa18de18) +# efivars read — bootloader detection (commits fa18de18, 4cb1e63b) +def run_reinstall [image: string] { + ( + podman run \ + --rm \ + --privileged \ + --pid=host \ + --user=root:root \ + -v /var/lib/containers:/var/lib/containers \ + -v /dev:/dev \ + --security-opt label=type:unconfined_t \ + -v /:/target:rslave \ + localhost/bootc-reinstall-same \ + bootc install to-existing-root \ + --acknowledge-destructive \ + --skip-fetch-check \ + --composefs-backend \ + --disable-selinux + ) +} + +def first_boot [] { + tap begin "system-reinstall composefs + bootloader compatibility" + + let bootloader = ($st.status.booted.composefs.bootloader | str downcase) + let boot_type = ($st.status.booted.composefs.bootType | str downcase) + + # Persist host state for verification across reboots + $bootloader | save /var/host-bootloader + $boot_type | save /var/host-boot-type + + print $"Host bootloader: ($bootloader), boot type: ($boot_type)" + + bootc image copy-to-storage + + # Build derived image preserving the same bootloader support as the host. + # make_uki_containerfile appends UKI sealing stages when running on UKI. + let containerfile = (tap make_uki_containerfile " + FROM localhost/bootc as base + RUN rm -rf /usr/lib/bootc/bound-images.d + RUN touch /usr/share/testing-reinstall-same-bl + ") + + let td = mktemp -d + cd $td + $containerfile | save Dockerfile + podman build -t localhost/bootc-reinstall-same . + + print "Running to-existing-root --composefs-backend (same bootloader)" + run_reinstall localhost/bootc-reinstall-same + + tmt-reboot +} + +def second_boot [] { + print "Verifying reinstall with same bootloader" + + assert (tap is_composefs) "composefs should be active after reinstall" + assert ("/usr/share/testing-reinstall-same-bl" | path exists) "same-bootloader marker should exist" + + let orig_bootloader = (open /var/host-bootloader | str trim) + let current_bootloader = ($st.status.booted.composefs.bootloader | str downcase) + + print $"Original bootloader: ($orig_bootloader), Current: ($current_bootloader)" + assert equal $current_bootloader $orig_bootloader "bootloader should match host after same-bootloader reinstall" + + # Build image that lacks the host's bootloader, forcing a fallback: + # host=systemd-boot -> remove bootctl -> falls back to grub + # host=grub/grub-cc -> remove bootupd -> falls back to systemd-boot + let containerfile = if $orig_bootloader == "systemd" { + print "Building image without systemd-boot support (removing bootctl)" + " + FROM localhost/bootc as base + # Not rpm -e systemd-boot-unsigned as sytsemd-ukify requires that + RUN rm -rf /usr/lib/systemd/boot + RUN rm -f /usr/bin/bootctl + RUN rm -rf /usr/lib/bootc/bound-images.d + RUN dnf install -y bootupd + RUN touch /usr/share/testing-reinstall-mismatch + " + } else { + print "Building image without grub/bootupd support" + " + FROM localhost/bootc as base + RUN rpm -e bootupd + RUN rm -rf /usr/lib/bootc/bound-images.d + RUN dnf install -y systemd-boot-unsigned + RUN touch /usr/share/testing-reinstall-mismatch + " + } + + let td = mktemp -d + cd $td + $containerfile | save Dockerfile + podman build -t localhost/bootc-reinstall-mismatch . + + print "Running to-existing-root --composefs-backend (mismatched bootloader)" + run_reinstall localhost/bootc-reinstall-mismatch + + tmt-reboot +} + +def third_boot [] { + print "Verifying reinstall with mismatched bootloader" + + assert (tap is_composefs) "composefs should be active after mismatch reinstall" + assert ("/usr/share/testing-reinstall-mismatch" | path exists) "mismatch marker should exist" + + let orig_bootloader = (open /var/host-bootloader | str trim) + let current_bootloader = ($st.status.booted.composefs.bootloader | str downcase) + + print $"Original host bootloader: ($orig_bootloader), Installed: ($current_bootloader)" + + if $orig_bootloader == "systemd" { + assert equal $current_bootloader "grub" "should fall back to grub when image lacks systemd-boot" + } else { + assert equal $current_bootloader "systemd" "should fall back to systemd-boot when image lacks grub" + } + + tap ok +} + +def main [] { + match $env.TMT_REBOOT_COUNT? { + null | "0" => first_boot, + "1" => second_boot, + "2" => third_boot, + $o => { error make { msg: $"Invalid TMT_REBOOT_COUNT ($o)" } }, + } +} diff --git a/tmt/tests/tests.fmf b/tmt/tests/tests.fmf index 486e96cfac..4d7d99bf78 100644 --- a/tmt/tests/tests.fmf +++ b/tmt/tests/tests.fmf @@ -188,3 +188,8 @@ check: summary: Test composefs garbage collection for UKI duration: 30m test: nu booted/test-composefs-uki-dumpfile.nu + +/test-49-system-reinstall-composefs: + summary: Test system-reinstall with composefs backend and bootloader compatibility + duration: 60m + test: nu booted/test-system-reinstall-composefs.nu From 7ca781edcd68b5299f7515e996e5bfcb897acf71 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Wed, 22 Jul 2026 14:17:59 +0530 Subject: [PATCH 5/5] install/replace: Handle composefs booted systems For Grub booted compoesfs systems we have boot entries stored in `/sysroot/boot` which also needs cleaning whenever we clean up anything boot related Signed-off-by: Pragyan Poudyal --- crates/lib/src/install.rs | 96 +++++++++++++++++++++++++++++++++------ 1 file changed, 81 insertions(+), 15 deletions(-) diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index b9223baa52..a7a8e33493 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -194,6 +194,7 @@ use crate::bootc_composefs::{ }; use crate::bootc_kargs::{INITRD_ARG_PREFIX, ROOTFLAGS_KEY}; use crate::boundimage::{BoundImage, ResolvedBoundImage}; +use crate::composefs_consts::COMPOSEFS_CMDLINE; use crate::containerenv::ContainerExecutionInfo; use crate::deploy::{MergeState, PreparedPullResult, prepare_for_pull, pull_from_prepared}; use crate::install::config::Filesystem as FilesystemEnum; @@ -203,7 +204,7 @@ use crate::spec::{Bootloader, ImageReference}; use crate::store::Storage; use crate::task::Task; use crate::utils::sigpolicy_from_opt; -use bootc_mount::Filesystem; +use bootc_mount::{Filesystem, run_findmnt}; use composefs_ctl::composefs::repository::RepositoryConfig; use linux_kernel_cmdline::{bytes, utf8}; @@ -2373,8 +2374,8 @@ fn remove_all_in_dir_no_xdev(d: &Dir, mount_err: bool) -> Result<()> { anyhow::Ok(()) } -#[context("Removing boot directory content except loader dir on ostree")] -fn remove_all_except_loader_dirs(bootdir: &Dir, is_ostree: bool) -> Result<()> { +#[context("Removing boot directory content except loader dir")] +fn remove_all_except_loader_dirs(bootdir: &Dir, remove_loader_dir: bool) -> Result<()> { let entries = bootdir .entries() .context("Reading boot directory entries")?; @@ -2391,7 +2392,7 @@ fn remove_all_except_loader_dirs(bootdir: &Dir, is_ostree: bool) -> Result<()> { // TODO: Preserve basically everything (including the bootloader entries // on non-ostree) by default until the very end of the install. And ideally // make the "commit" phase an optional step after. - if is_ostree && file_name.starts_with("loader") { + if !remove_loader_dir && file_name.starts_with("loader") { continue; } @@ -2413,7 +2414,12 @@ fn remove_all_except_loader_dirs(bootdir: &Dir, is_ostree: bool) -> Result<()> { } #[context("Removing boot directory content")] -fn clean_boot_directories(rootfs: &Dir, rootfs_path: &Utf8Path, is_ostree: bool) -> Result<()> { +fn clean_boot_directories( + rootfs: &Dir, + rootfs_path: &Utf8Path, + is_ostree: bool, + is_composefs: bool, +) -> Result<()> { let bootdir = crate::utils::open_dir_remount_rw(rootfs, BOOT.into()).context("Opening /boot")?; @@ -2424,7 +2430,7 @@ fn clean_boot_directories(rootfs: &Dir, rootfs_path: &Utf8Path, is_ostree: bool) } // This should not remove /boot/efi note. - remove_all_except_loader_dirs(&bootdir, is_ostree).context("Emptying /boot")?; + remove_all_except_loader_dirs(&bootdir, is_ostree || is_composefs).context("Emptying /boot")?; // TODO: we should also support not wiping the ESP. if ARCH_USES_EFI { @@ -2436,6 +2442,25 @@ fn clean_boot_directories(rootfs: &Dir, rootfs_path: &Utf8Path, is_ostree: bool) } } + // If the system is a composefs system, also wipe /sysroot/boot + if is_composefs { + // This might or not might not have stuff depending upon Grub or SystemdBoot/GrubCC + // respectively + let bootdir = rootfs + .open_dir_optional("sysroot/boot") + .context("Opening /boot")?; + + let Some(bootdir) = bootdir else { + return Ok(()); + }; + + crate::utils::open_dir_remount_rw(rootfs, &Utf8Path::new("sysroot")) + .context("Re-opening sysroot as rw")?; + + remove_all_except_loader_dirs(&bootdir, is_ostree || is_composefs) + .context("Emptying sysroot/boot")?; + }; + Ok(()) } @@ -2584,17 +2609,36 @@ pub(crate) async fn install_to_filesystem( // the deployment root. let possible_physical_root = fsopts.root_path.join("sysroot"); let possible_ostree_dir = possible_physical_root.join("ostree"); - let is_already_ostree = possible_ostree_dir.exists(); - if is_already_ostree { + let mut is_already_ostree = possible_ostree_dir.exists(); + + let is_already_composefs = { + let cmdline = Cmdline::from_proc().context("Generating cmdline from /proc/cmdline")?; + cmdline + .find(COMPOSEFS_CMDLINE) + .is_some_and(|kv| kv.value().is_some()) + }; + + // These two mostly serve the same purpose, i.e. using /sysroot as the rootfs instead + // of '/', but we have some difference when it comes to handling /boot and /sysroot/boot + if is_already_composefs { + is_already_ostree = false; + } + + if is_already_ostree || is_already_composefs { tracing::debug!( - "ostree detected in {possible_ostree_dir}, assuming target is a deployment root and using {possible_physical_root}" + "{} detected, assuming target is a deployment root and using {possible_physical_root}", + if is_already_ostree { + "ostree" + } else { + "composefs" + } ); fsopts.root_path = possible_physical_root; }; // Get a file descriptor for the root path // It will be /target/sysroot on ostree OS, or will be /target - let rootfs_fd = if is_already_ostree { + let rootfs_fd = if is_already_ostree || is_already_composefs { let root_path = &fsopts.root_path; let rootfs_fd = Dir::open_ambient_dir(&fsopts.root_path, cap_std::ambient_authority()) .with_context(|| format!("Opening target root directory {root_path}"))?; @@ -2639,9 +2683,12 @@ pub(crate) async fn install_to_filesystem( tokio::task::spawn_blocking(move || remove_all_in_dir_no_xdev(&rootfs_fd, true)) .await??; } - Some(ReplaceMode::Alongside) => { - clean_boot_directories(&target_rootfs_fd, &target_root_path, is_already_ostree)? - } + Some(ReplaceMode::Alongside) => clean_boot_directories( + &target_rootfs_fd, + &target_root_path, + is_already_ostree, + is_already_composefs, + )?, None => require_empty_rootdir(&rootfs_fd)?, } @@ -2703,8 +2750,27 @@ pub(crate) async fn install_to_filesystem( let get_boot_uuid = || -> Result> { let boot_path = target_root_path.join(BOOT); tracing::debug!("boot_path={boot_path}"); - let u = bootc_mount::inspect_filesystem(&boot_path) - .with_context(|| format!("Inspecting /{BOOT}"))? + + let filesystems = + run_findmnt(&["--mountpoint"], None, Some(boot_path.as_str()))?.filesystems; + + let is_systemd_automount = filesystems + .iter() + .any(|fs| fs.source.contains("systemd") && fs.fstype == "autofs"); + let is_boot_esp = filesystems.iter().any(|fs| fs.fstype == "vfat"); + + // /boot is mounted as ESP manually, or via systemd's boot.automount + if is_systemd_automount || is_boot_esp { + tracing::debug!( + "Boot is systemd_automount: {is_systemd_automount}, is ESP: {is_boot_esp}" + ); + return Ok(None); + } + + let u = filesystems + .into_iter() + .next() + .ok_or_else(|| anyhow!("findmnt returned no data for {boot_path}"))? .uuid .ok_or_else(|| anyhow!("No UUID found for /{BOOT}"))?;