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/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..2db69c37cc 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::{ @@ -204,13 +204,13 @@ 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 bootc_mount::Filesystem; +use crate::{lsm, utils}; +use bootc_mount::{Filesystem, run_findmnt}; use linux_kernel_cmdline::{bytes, utf8}; /// The toplevel boot directory @@ -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 @@ -1675,12 +1679,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 +1893,17 @@ 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 + 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. let selinux_state = reexecute_self_for_selinux_if_needed(&source, config_opts.disable_selinux, &reexec_env)?; @@ -1982,6 +2006,7 @@ async fn prepare_install( .map(|fs| fs.supports_fsverity()) .unwrap_or(true), allow_missing_verity_explicit, + host_bootloader, }); Ok(state) @@ -1989,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("bootctl") { + Ok(true) => Bootloader::Systemd, + Ok(false) | 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, @@ -2370,6 +2428,7 @@ pub(crate) async fn install_to_disk(mut opts: InstallToDiskOpts) -> Result<()> { opts.target_opts, opts.composefs_opts, block_opts.filesystem, + None, ) .await?; @@ -2496,7 +2555,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(()) @@ -2515,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")?; @@ -2533,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; } @@ -2567,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")?; @@ -2578,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 @@ -2589,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(()) } @@ -2737,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}"))?; @@ -2776,6 +2874,7 @@ pub(crate) async fn install_to_filesystem( opts.target_opts, opts.composefs_opts, Some(inspect.fstype.as_str().try_into()?), + fsopts.replace, ) .await?; @@ -2791,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)?, } @@ -2838,29 +2940,68 @@ 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 } }; - // Find the UUID of /boot because we need it for GRUB. - let boot_uuid = if boot_is_mount { + + 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}"))? + + 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}"))?; - Some(u) - } else { - None + + 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/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/lib/src/utils.rs b/crates/lib/src/utils.rs index fd0d613b7b..9aa6967c09 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,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..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. @@ -50,7 +52,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(); 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