From fcfe732a0fdd217e65c71d8c3480ed739b08842b 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 | 17 ++++++++++++++--- crates/system-reinstall-bootc/src/podman.rs | 4 +++- 4 files changed, 32 insertions(+), 5 deletions(-) diff --git a/crates/lib/src/bootloader.rs b/crates/lib/src/bootloader.rs index 08a208d281..c965400874 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 03eeb22172..e2112399e5 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -2496,7 +2496,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 fd0d613b7b..df5f08b91b 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`. @@ -106,7 +107,16 @@ 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 +125,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 8bf2fa14f8d79045c471ae63b92e7ad570245eac 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 e2112399e5..1db305c7ea 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::{ @@ -1675,12 +1675,21 @@ 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, mut target_opts: InstallTargetOpts, mut composefs_options: InstallComposefsOpts, target_fs: Option, + replace_mode: Option, ) -> Result> { tracing::trace!("Preparing install"); let allow_missing_verity_explicit = composefs_options.allow_missing_verity; @@ -1880,6 +1889,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)?; @@ -2370,6 +2385,7 @@ pub(crate) async fn install_to_disk(mut opts: InstallToDiskOpts) -> Result<()> { opts.target_opts, opts.composefs_opts, block_opts.filesystem, + None, ) .await?; @@ -2777,6 +2793,7 @@ pub(crate) async fn install_to_filesystem( opts.target_opts, opts.composefs_opts, Some(inspect.fstype.as_str().try_into()?), + fsopts.replace, ) .await?; @@ -2850,18 +2867,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 dbda828197..e7c763c9e7 100644 --- a/crates/lib/src/store/mod.rs +++ b/crates/lib/src/store/mod.rs @@ -148,7 +148,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 @@ -842,6 +848,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 501253441381b1dcb46b3e1fb3b859538e183608 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 | 89 +++++++++++++++++++++++++++++++-------- crates/lib/src/utils.rs | 4 +- 2 files changed, 74 insertions(+), 19 deletions(-) diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 1db305c7ea..ec4c563fe5 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}; @@ -716,10 +716,14 @@ 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, + + /// 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 @@ -1891,8 +1895,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. @@ -1997,6 +2006,7 @@ async fn prepare_install( .map(|fs| fs.supports_fsverity()) .unwrap_or(true), allow_missing_verity_explicit, + host_bootloader, }); Ok(state) @@ -2004,19 +2014,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 utils::have_executable("bootupctl") { + 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, @@ -2868,21 +2911,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 df5f08b91b..9aa6967c09 100644 --- a/crates/lib/src/utils.rs +++ b/crates/lib/src/utils.rs @@ -107,7 +107,9 @@ 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 { - 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 576edb3d039a851b30d996c323b7b35149218391 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 | 8 + .../booted/test-system-reinstall-composefs.nu | 165 ++++++++++++++++++ tmt/tests/tests.fmf | 5 + 4 files changed, 178 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 2042e3cdb2..1c43f24924 100644 --- a/tmt/plans/integration.fmf +++ b/tmt/plans/integration.fmf @@ -363,4 +363,12 @@ execute: test: - /tmt/tests/tests/test-60-install-composefs-native extra-fixme_skip_if_uki: true + +/plan-61-system-reinstall-composefs: + summary: Test system-reinstall with composefs backend and bootloader compatibility + discover: + how: fmf + test: + - /tmt/tests/tests/test-61-system-reinstall-composefs + 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..9b45cd05a6 --- /dev/null +++ b/tmt/tests/booted/test-system-reinstall-composefs.nu @@ -0,0 +1,165 @@ +# number: 61 +# tmt: +# summary: Test system-reinstall with composefs backend and bootloader compatibility +# duration: 30m +# 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 c78d42d7d0..ab4a6e0679 100644 --- a/tmt/tests/tests.fmf +++ b/tmt/tests/tests.fmf @@ -229,3 +229,8 @@ check: summary: Test that composefs-native images default to the composefs backend duration: 45m test: nu booted/test-install-composefs-native.nu + +/test-61-system-reinstall-composefs: + summary: Test system-reinstall with composefs backend and bootloader compatibility + duration: 30m + test: nu booted/test-system-reinstall-composefs.nu From a007476f822b086f813044d93136786d1107f760 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 ec4c563fe5..192f1433a0 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 @@ -2047,9 +2047,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 } @@ -2575,8 +2575,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")?; @@ -2593,7 +2593,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; } @@ -2627,7 +2627,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")?; @@ -2638,7 +2643,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 @@ -2649,6 +2654,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(()) } @@ -2797,17 +2821,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}"))?; @@ -2852,9 +2890,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)?, } @@ -2916,8 +2957,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 d97c8f865a1dda41229732bac8588c2bb7a94920 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 192f1433a0..2db69c37cc 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -2940,13 +2940,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 }