From 0301d2e504651f8ef3a5a8830b45fa03e9a009ad Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Fri, 17 Jul 2026 15:02:34 +0530 Subject: [PATCH 1/6] 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 | 3 ++- crates/lib/src/utils.rs | 19 ++++++++++++++++--- crates/system-reinstall-bootc/src/podman.rs | 4 +++- 4 files changed, 34 insertions(+), 5 deletions(-) diff --git a/crates/lib/src/bootloader.rs b/crates/lib/src/bootloader.rs index 111627c4bc..1772dfd65e 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(crate::install::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 00d642f862..b38e74d7d0 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -2586,7 +2586,8 @@ fn remove_all_in_dir_no_xdev(d: &Dir, mount_err: bool) -> Result<()> { if etype == FileType::dir() { remove_dir_no_xdev(d, &name, mount_err)?; } 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 7941606a1c..b63b093a1b 100644 --- a/crates/lib/src/utils.rs +++ b/crates/lib/src/utils.rs @@ -1,6 +1,6 @@ use std::future::Future; use std::io::{Read, 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`. @@ -106,7 +107,18 @@ pub fn have_executable_in_root(root: &Dir, 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()]) @@ -115,7 +127,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..90ed3a428c 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 propagate that inside the container + "/:/target:rslave", ] .map(String::from) .to_vec(); From 8292acb0feba11c25cb44a9b1864e5e5ea492f0b Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Fri, 17 Jul 2026 15:37:08 +0530 Subject: [PATCH 2/6] 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/install.rs | 43 +++++++++++++++------ crates/lib/src/store/mod.rs | 7 ++++ crates/system-reinstall-bootc/src/podman.rs | 2 + 3 files changed, 41 insertions(+), 11 deletions(-) diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index b38e74d7d0..b28222757b 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -187,7 +187,7 @@ use serde::{Deserialize, Serialize}; #[cfg(feature = "install-to-disk")] use self::baseline::InstallBlockDeviceOpts; -use crate::bootc_composefs::status::ComposefsCmdline; +use crate::bootc_composefs::status::{ComposefsCmdline, get_bootloader}; use crate::bootc_composefs::{ boot::setup_composefs_boot, repo::{ @@ -1726,6 +1726,14 @@ async fn verify_target_fetch( const ROOT_SSH_AUTHORIZED_KEYS_ENV: &str = "_BOOTC_ROOT_SSH_AUTHORIZED_KEYS"; /// Preparation for an install; validates and prepares some (thereafter immutable) global state. +/// +/// # Parameters +/// - `config_opts`: Installation configuration options (root user setup, generic image, etc.) +/// - `source_opts`: Source image reference; if `None`, assumes running inside a container +/// - `target_opts`: Target image reference and root path options +/// - `composefs_options`: composefs-related settings for the installation +/// - `target_fs`: Target filesystem type; used for `install to-filesystem` +/// - `replace_mode`: If `Some`, indicates an `install to-filesystem` or `install to-existing-root` with the given replacement mode async fn prepare_install( mut config_opts: InstallConfigOpts, source_opts: InstallSourceOpts, @@ -1733,6 +1741,7 @@ async fn prepare_install( mut composefs_options: InstallComposefsOpts, target_fs: Option, disk_install: bool, + replace_mode: Option, ) -> Result> { tracing::trace!("Preparing install"); @@ -1955,6 +1964,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, &reexec_env)?; @@ -2460,6 +2475,7 @@ pub(crate) async fn install_to_disk(mut opts: InstallToDiskOpts) -> Result<()> { opts.composefs_opts, block_opts.filesystem, true, + None, ) .await?; @@ -2868,6 +2884,7 @@ pub(crate) async fn install_to_filesystem( opts.composefs_opts, Some(inspect.fstype.as_str().try_into()?), false, + fsopts.replace, ) .await?; @@ -2941,18 +2958,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 5b89a415ea..60581cbc6b 100644 --- a/crates/lib/src/store/mod.rs +++ b/crates/lib/src/store/mod.rs @@ -152,7 +152,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 @@ -965,6 +971,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 90ed3a428c..a8196d1cf1 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 cb1c2b7161b3bbad39f94831b930d6eb80fac81b Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Fri, 17 Jul 2026 16:07:46 +0530 Subject: [PATCH 3/6] 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 | 85 +++++++++++++++++++++++++++++++-------- 1 file changed, 68 insertions(+), 17 deletions(-) diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index b28222757b..c1fa6c7be3 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -204,12 +204,12 @@ use crate::deploy::{ retry_pull_operation, }; use crate::install::config::Filesystem as FilesystemEnum; -use crate::lsm; use crate::progress_jsonl::ProgressWriter; use crate::spec::{Bootloader, ImageReference}; use crate::store::Storage; use crate::task::Task; use crate::utils::sigpolicy_from_opt; +use crate::{lsm, utils}; use bootc_mount::Filesystem; use linux_kernel_cmdline::{bytes, utf8}; @@ -761,12 +761,16 @@ 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, pub(crate) composefs_fsverity_supported: bool, pub(crate) allow_missing_verity_explicit: bool, #[cfg(feature = "install-to-disk")] pub(crate) aboot_disk_layout: Option, + + /// 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 @@ -1966,8 +1970,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. @@ -2074,6 +2083,7 @@ async fn prepare_install( allow_missing_verity_explicit, #[cfg(feature = "install-to-disk")] aboot_disk_layout, + host_bootloader, }); Ok(state) @@ -2089,6 +2099,37 @@ impl PostFetchState { // Priority: user-specified > image-required > bootupd availability > systemd-boot fallback let detected_bootloader = select_bootloader(state.config_opts.bootloader, image_bootloader, d)?; + + // 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 crate::bootloader::supports_bootupd(d)? { + b + } else { + Bootloader::Systemd + } + } + Bootloader::Systemd => match utils::have_executable("bootupctl") { + Ok(_) => Bootloader::Systemd, + Err(_) => { + println!("Could not find bootctl, defaulting to Grub"); + Bootloader::Grub + } + }, + Bootloader::Ukiboot => Bootloader::Ukiboot, + Bootloader::None => Bootloader::None, + }, + + None => detected_bootloader, + }; + println!("Bootloader: {detected_bootloader}"); let r = Self { detected_bootloader, @@ -2959,21 +3000,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 From fd1cf9ef856e368e71f03af9d6a0789b059ae988 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Fri, 17 Jul 2026 18:20:35 +0530 Subject: [PATCH 4/6] 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 | 12 ++ .../booted/test-system-reinstall-composefs.nu | 169 ++++++++++++++++++ tmt/tests/tests.fmf | 9 + 4 files changed, 190 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 d4d13eb469..8e420421c7 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 bc150a0b70..537e3accd6 100644 --- a/tmt/plans/integration.fmf +++ b/tmt/plans/integration.fmf @@ -428,4 +428,16 @@ execute: test: - /tmt/tests/tests/test-63-install-shared-propagation extra-fixme_skip_if_composefs: true + +/plan-64-system-reinstall-composefs: + summary: Test system-reinstall with composefs backend and bootloader compatibility + discover: + how: fmf + test: + - /tmt/tests/tests/test-64-system-reinstall-composefs + adjust: + - when: distro != fedora + enabled: false + because: we don't have new enough bootupd in centos + extra-skip_if_ostree: true # 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..b4ed86cdee --- /dev/null +++ b/tmt/tests/booted/test-system-reinstall-composefs.nu @@ -0,0 +1,169 @@ +# number: 64 +# tmt: +# summary: Test system-reinstall with composefs backend and bootloader compatibility +# duration: 30m +# adjust: +# - when: distro != fedora +# enabled: false +# because: we don't have new enough bootupd in centos +# extra: +# skip_if_ostree: true +# +# 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 (Grub) should be installed +# +# Reboot 2: +# Verify mismatch fallback result + +use std assert +use tap.nu + +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 + ($image) + 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 = " + 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 + (tap make_uki_containerfile $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 systemd-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 grub2 shim 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 + (tap make_uki_containerfile $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 7b7c804992..26a64b0465 100644 --- a/tmt/tests/tests.fmf +++ b/tmt/tests/tests.fmf @@ -276,3 +276,12 @@ check: summary: Install from a shared mount namespace without leaking the storage bind mount duration: 30m test: nu booted/test-install-shared-propagation.nu + +/test-64-system-reinstall-composefs: + summary: Test system-reinstall with composefs backend and bootloader compatibility + duration: 30m + adjust: + - when: distro != fedora + enabled: false + because: we don't have new enough bootupd in centos + test: nu booted/test-system-reinstall-composefs.nu From e0b09a75ae0ad123cbc88eba29debb8ebf677e6f Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Wed, 22 Jul 2026 14:17:59 +0530 Subject: [PATCH 5/6] 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, 78 insertions(+), 18 deletions(-) diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index c1fa6c7be3..0f27a6e4d7 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -210,7 +210,7 @@ use crate::store::Storage; use crate::task::Task; use crate::utils::sigpolicy_from_opt; use crate::{lsm, utils}; -use bootc_mount::Filesystem; +use bootc_mount::{Filesystem, run_findmnt}; use linux_kernel_cmdline::{bytes, utf8}; /// The toplevel boot directory @@ -2116,9 +2116,9 @@ impl PostFetchState { Bootloader::Systemd } } - Bootloader::Systemd => match utils::have_executable("bootupctl") { - Ok(_) => Bootloader::Systemd, - Err(_) => { + Bootloader::Systemd => match utils::have_executable("bootctl") { + Ok(true) => Bootloader::Systemd, + Ok(false) | Err(_) => { println!("Could not find bootctl, defaulting to Grub"); Bootloader::Grub } @@ -2663,8 +2663,8 @@ fn remove_dir_no_xdev(d: &Dir, name: impl AsRef, mount_err: bool) -> Resul 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")?; @@ -2681,7 +2681,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; } @@ -2715,7 +2715,12 @@ fn clean_esp_bootloader_dirs(efidir: &Dir) -> 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")?; @@ -2726,7 +2731,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")?; if ARCH_USES_EFI { if let Some(efidir) = bootdir @@ -2737,6 +2742,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(()) } @@ -2885,17 +2909,31 @@ 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 = possible_physical_root.join("composefs").exists(); + + // 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}"))?; @@ -2941,9 +2979,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)?, } @@ -3005,8 +3046,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}"))?; From 8ab0427f3f51339ef1e768c9571eee1895f672eb Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Wed, 7 Oct 2026 11:46:23 +0530 Subject: [PATCH 6/6] install: Update /boot mount check Check if /boot is an actual mountpoint by checking if something is mounted there instead of simply comparing the device number against the root fs Signed-off-by: Pragyan Poudyal --- crates/lib/src/install.rs | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 0f27a6e4d7..7cf77efd36 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -3029,13 +3029,19 @@ pub(crate) async fn install_to_filesystem( }; tracing::debug!("Root mount: {} {:?}", root_info.mount_spec, root_info.kargs); - let boot_is_mount = { - if let Some(boot_metadata) = target_rootfs_fd.symlink_metadata_optional(BOOT)? { - let root_dev = rootfs_fd.dir_metadata()?.dev(); - let boot_dev = boot_metadata.dev(); - tracing::debug!("root_dev={root_dev} boot_dev={boot_dev}"); - root_dev != boot_dev - } else { + let boot_is_mount = match target_rootfs_fd + .open_dir_optional(BOOT) + .context("Opening /boot")? + { + Some(boot_dir) => { + matches!( + boot_dir + .is_mountpoint(".") + .context("Checking if /boot is a mountpoint")?, + Some(true) + ) + } + None => { tracing::debug!("No /{BOOT} directory found"); false }