From d8a3c52f52896fad1d65f2014c2563498d7bd591 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Thu, 29 Jan 2026 13:07:28 +0530 Subject: [PATCH 1/8] composefs/gc: Introduce `dry_run` command With accordance to the new GC API, Introduce a dry_run parameter. Also, remove custom object GC code and call the composefs-rs implementation Signed-off-by: Pragyan Poudyal --- crates/lib/src/bootc_composefs/delete.rs | 68 ++++++----------- crates/lib/src/bootc_composefs/gc.rs | 96 ++++++++++-------------- crates/lib/src/cli.rs | 18 +++++ 3 files changed, 82 insertions(+), 100 deletions(-) diff --git a/crates/lib/src/bootc_composefs/delete.rs b/crates/lib/src/bootc_composefs/delete.rs index c6ee676ce..c21c11777 100644 --- a/crates/lib/src/bootc_composefs/delete.rs +++ b/crates/lib/src/bootc_composefs/delete.rs @@ -1,15 +1,13 @@ -use std::{collections::HashSet, io::Write, path::Path}; +use std::{io::Write, path::Path}; use anyhow::{Context, Result}; use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt}; -use composefs::fsverity::Sha512HashValue; use composefs_boot::bootloader::{EFI_ADDON_DIR_EXT, EFI_EXT}; use crate::{ bootc_composefs::{ boot::{BootType, SYSTEMD_UKI_DIR, find_vmlinuz_initrd_duplicates, get_efi_uuid_source}, gc::composefs_gc, - repo::open_composefs_repo, rollback::{composefs_rollback, rename_exchange_user_cfg}, status::{get_composefs_status, get_sorted_grub_uki_boot_entries}, }, @@ -246,57 +244,38 @@ fn delete_depl_boot_entries( } } -#[fn_error_context::context("Getting image objects")] -pub(crate) fn get_image_objects(sysroot: &Dir) -> Result> { - let repo = open_composefs_repo(&sysroot)?; - - let images_dir = sysroot - .open_dir("composefs/images") - .context("Opening images dir")?; - - let image_entries = images_dir - .entries_utf8() - .context("Reading entries in images dir")?; - - let mut object_refs = HashSet::new(); - - for image in image_entries { - let image = image?; - - let img_name = image.file_name().context("Getting image name")?; - - let objects = repo - .objects_for_image(&img_name) - .with_context(|| format!("Getting objects for image {img_name}"))?; - - object_refs.extend(objects); - } - - Ok(object_refs) -} - #[fn_error_context::context("Deleting image for deployment {}", deployment_id)] -pub(crate) fn delete_image(sysroot: &Dir, deployment_id: &str) -> Result<()> { +pub(crate) fn delete_image(sysroot: &Dir, deployment_id: &str, dry_run: bool) -> Result<()> { let img_path = Path::new("composefs").join("images").join(deployment_id); tracing::debug!("Deleting EROFS image: {:?}", img_path); - sysroot - .remove_file(&img_path) - .context("Deleting EROFS image") + + if !dry_run { + sysroot + .remove_file(&img_path) + .context("Deleting EROFS image")?; + } + + Ok(()) } #[fn_error_context::context("Deleting state directory for deployment {}", deployment_id)] -pub(crate) fn delete_state_dir(sysroot: &Dir, deployment_id: &str) -> Result<()> { +pub(crate) fn delete_state_dir(sysroot: &Dir, deployment_id: &str, dry_run: bool) -> Result<()> { let state_dir = Path::new(STATE_DIR_RELATIVE).join(deployment_id); tracing::debug!("Deleting state directory: {:?}", state_dir); - sysroot - .remove_dir_all(&state_dir) - .with_context(|| format!("Removing dir {state_dir:?}")) + + if !dry_run { + sysroot + .remove_dir_all(&state_dir) + .with_context(|| format!("Removing dir {state_dir:?}"))?; + } + + Ok(()) } #[fn_error_context::context("Deleting staged deployment")] -pub(crate) fn delete_staged(staged: &Option) -> Result<()> { +pub(crate) fn delete_staged(staged: &Option, dry_run: bool) -> Result<()> { if staged.is_none() { tracing::debug!("No staged deployment"); return Ok(()); @@ -304,7 +283,10 @@ pub(crate) fn delete_staged(staged: &Option) -> Result<()> { let file = Path::new(COMPOSEFS_TRANSIENT_STATE_DIR).join(COMPOSEFS_STAGED_DEPLOYMENT_FNAME); tracing::debug!("Deleting staged deployment file: {file:?}"); - std::fs::remove_file(file).context("Removing staged file")?; + + if !dry_run { + std::fs::remove_file(file).context("Removing staged file")?; + } Ok(()) } @@ -368,7 +350,7 @@ pub(crate) async fn delete_composefs_deployment( delete_depl_boot_entries(&depl_to_del, &storage, deleting_staged)?; - composefs_gc(storage, booted_cfs).await?; + composefs_gc(storage, booted_cfs, false).await?; Ok(()) } diff --git a/crates/lib/src/bootc_composefs/gc.rs b/crates/lib/src/bootc_composefs/gc.rs index b277e522b..c79809354 100644 --- a/crates/lib/src/bootc_composefs/gc.rs +++ b/crates/lib/src/bootc_composefs/gc.rs @@ -5,14 +5,13 @@ //! - We delete bootloader + image but fail to delete the state/unrefenced objects etc use anyhow::{Context, Result}; -use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt}; -use composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; +use cap_std_ext::cap_std::fs::Dir; use crate::{ bootc_composefs::{ - delete::{delete_image, delete_staged, delete_state_dir, get_image_objects}, + delete::{delete_image, delete_staged, delete_state_dir}, status::{ - get_bootloader, get_composefs_status, get_sorted_grub_uki_boot_entries, + get_bootloader, get_composefs_status, get_imginfo, get_sorted_grub_uki_boot_entries, get_sorted_type1_boot_entries, }, }, @@ -108,52 +107,6 @@ fn list_state_dirs(sysroot: &Dir) -> Result> { Ok(dirs) } -/// Deletes objects in sysroot/composefs/objects that are not being referenced by any of the -/// present EROFS images -/// -/// We do not delete streams though -#[fn_error_context::context("Garbage collecting objects")] -// TODO(Johan-Liebert1): This will be moved to composefs-rs -pub(crate) fn gc_objects(sysroot: &Dir) -> Result<()> { - tracing::debug!("Running garbage collection on unreferenced objects"); - - // Get all the objects referenced by all available images - let obj_refs = get_image_objects(sysroot)?; - - // List all objects in the objects directory - let objects_dir = sysroot - .open_dir("composefs/objects") - .context("Opening objects dir")?; - - for dir_name in 0x0..=0xff { - let dir = objects_dir - .open_dir_optional(dir_name.to_string()) - .with_context(|| format!("Opening {dir_name}"))?; - - let Some(dir) = dir else { - continue; - }; - - for entry in dir.entries_utf8()? { - let entry = entry?; - let filename = entry.file_name()?; - - let id = Sha512HashValue::from_object_dir_and_basename(dir_name, filename.as_bytes())?; - - // If this object is not referenced by any image, delete it - if !obj_refs.contains(&id) { - tracing::trace!("Deleting unreferenced object: {filename}"); - - entry - .remove_file() - .with_context(|| format!("Removing object {filename}"))?; - } - } - } - - Ok(()) -} - /// 1. List all bootloader entries /// 2. List all EROFS images /// 3. List all state directories @@ -165,7 +118,11 @@ pub(crate) fn gc_objects(sysroot: &Dir) -> Result<()> { /// Similarly if EROFS image B1 doesn't exist, but state dir does, then delete the state dir and /// perform GC #[fn_error_context::context("Running composefs garbage collection")] -pub(crate) async fn composefs_gc(storage: &Storage, booted_cfs: &BootedComposefs) -> Result<()> { +pub(crate) async fn composefs_gc( + storage: &Storage, + booted_cfs: &BootedComposefs, + dry_run: bool, +) -> Result<()> { const COMPOSEFS_GC_JOURNAL_ID: &str = "3b2a1f0e9d8c7b6a5f4e3d2c1b0a9f8e7"; tracing::info!( @@ -201,9 +158,9 @@ pub(crate) async fn composefs_gc(storage: &Storage, booted_cfs: &BootedComposefs for verity in &img_bootloader_diff { tracing::debug!("Cleaning up orphaned image: {verity}"); - delete_staged(staged)?; - delete_image(&sysroot, verity)?; - delete_state_dir(&sysroot, verity)?; + delete_staged(staged, dry_run)?; + delete_image(&sysroot, verity, dry_run)?; + delete_state_dir(&sysroot, verity, dry_run)?; } let state_dirs = list_state_dirs(&sysroot)?; @@ -216,12 +173,37 @@ pub(crate) async fn composefs_gc(storage: &Storage, booted_cfs: &BootedComposefs .collect::>(); for verity in &state_img_diff { - delete_staged(staged)?; - delete_state_dir(&sysroot, verity)?; + delete_staged(staged, dry_run)?; + delete_state_dir(&sysroot, verity, dry_run)?; } + let booted_image = get_imginfo(storage, &booted_cfs_status.verity, None).await?; + + let stream = format!("oci-config-{}", booted_image.manifest.config().digest()); + let additional_roots = vec![booted_cfs_status.verity.as_str(), &stream]; + // Run garbage collection on objects after deleting images - gc_objects(&sysroot)?; + let gc_result = if dry_run { + booted_cfs.repo.gc_dry_run(&additional_roots)? + } else { + booted_cfs.repo.gc(&additional_roots)? + }; + + if dry_run { + println!("Dry run (no files deleted):"); + } + + println!( + "Objects: {} removed ({} bytes)", + gc_result.objects_removed, gc_result.objects_bytes + ); + + if gc_result.images_pruned > 0 || gc_result.streams_pruned > 0 { + println!( + "Pruned symlinks: {} images, {} streams", + gc_result.images_pruned, gc_result.streams_pruned + ); + } Ok(()) } diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index ddac010b0..b1db1dd07 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -34,6 +34,7 @@ use schemars::schema_for; use serde::{Deserialize, Serialize}; use crate::bootc_composefs::delete::delete_composefs_deployment; +use crate::bootc_composefs::gc::composefs_gc; use crate::bootc_composefs::soft_reboot::{prepare_soft_reboot_composefs, reset_soft_reboot}; use crate::bootc_composefs::{ digest::{compute_composefs_digest, new_temp_composefs_repo}, @@ -652,6 +653,10 @@ pub(crate) enum InternalsOpts { #[clap(long, conflicts_with = "reboot")] reset: bool, }, + ComposefsGC { + #[clap(long)] + dry_run: bool, + }, } #[derive(Debug, clap::Subcommand, PartialEq, Eq)] @@ -1901,6 +1906,19 @@ async fn run_from_opt(opt: Opt) -> Result<()> { } } } + InternalsOpts::ComposefsGC { dry_run } => { + let storage = &get_storage().await?; + + match storage.kind()? { + BootedStorageKind::Ostree(..) => { + anyhow::bail!("composefs-gc only works for composefs backend"); + } + + BootedStorageKind::Composefs(booted_cfs) => { + composefs_gc(storage, &booted_cfs, dry_run).await + } + } + } }, Opt::State(opts) => match opts { StateOpts::WipeOstree => { From 14c684868cf35caa8476c307d4bc0e08e3c34974 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Tue, 10 Feb 2026 10:33:42 +0530 Subject: [PATCH 2/8] composefs/gc: Fix orphan detection and staged deployment cleanup Update "roots" to include all images/oci-configs we want to keep especially the staged deployment if there is any Signed-off-by: Pragyan Poudyal --- crates/lib/src/bootc_composefs/delete.rs | 21 +++++-- crates/lib/src/bootc_composefs/digest.rs | 17 ++++-- crates/lib/src/bootc_composefs/gc.rs | 77 +++++++++++++++++------- 3 files changed, 82 insertions(+), 33 deletions(-) diff --git a/crates/lib/src/bootc_composefs/delete.rs b/crates/lib/src/bootc_composefs/delete.rs index c21c11777..68f3dbbaa 100644 --- a/crates/lib/src/bootc_composefs/delete.rs +++ b/crates/lib/src/bootc_composefs/delete.rs @@ -87,6 +87,8 @@ fn delete_type1_entry(depl: &DeploymentEntry, boot_dir: &Dir, deleting_staged: b if should_del_kernel { delete_kernel_initrd(&bls_config.cfg_type, boot_dir)?; + } else { + tracing::debug!("Kernel/Initrd being used by other deployments. Won't delete"); } break; @@ -248,7 +250,7 @@ fn delete_depl_boot_entries( pub(crate) fn delete_image(sysroot: &Dir, deployment_id: &str, dry_run: bool) -> Result<()> { let img_path = Path::new("composefs").join("images").join(deployment_id); - tracing::debug!("Deleting EROFS image: {:?}", img_path); + tracing::debug!("Will delete EROFS image: {:?}", img_path); if !dry_run { sysroot @@ -263,7 +265,7 @@ pub(crate) fn delete_image(sysroot: &Dir, deployment_id: &str, dry_run: bool) -> pub(crate) fn delete_state_dir(sysroot: &Dir, deployment_id: &str, dry_run: bool) -> Result<()> { let state_dir = Path::new(STATE_DIR_RELATIVE).join(deployment_id); - tracing::debug!("Deleting state directory: {:?}", state_dir); + tracing::debug!("Will delete state directory: {:?}", state_dir); if !dry_run { sysroot @@ -275,12 +277,21 @@ pub(crate) fn delete_state_dir(sysroot: &Dir, deployment_id: &str, dry_run: bool } #[fn_error_context::context("Deleting staged deployment")] -pub(crate) fn delete_staged(staged: &Option, dry_run: bool) -> Result<()> { - if staged.is_none() { +pub(crate) fn delete_staged( + staged: &Option, + cleanup_list: &Vec<&String>, + dry_run: bool, +) -> Result<()> { + let Some(staged_depl) = staged else { tracing::debug!("No staged deployment"); return Ok(()); }; + if !cleanup_list.contains(&&staged_depl.require_composefs()?.verity) { + tracing::debug!("Staged deployment not in cleanup list"); + return Ok(()); + } + let file = Path::new(COMPOSEFS_TRANSIENT_STATE_DIR).join(COMPOSEFS_STAGED_DEPLOYMENT_FNAME); tracing::debug!("Deleting staged deployment file: {file:?}"); @@ -346,7 +357,7 @@ pub(crate) async fn delete_composefs_deployment( "" }; - tracing::info!("Deleting {kind}deployment '{deployment_id}'"); + tracing::debug!("Deleting {kind}deployment '{deployment_id}'"); delete_depl_boot_entries(&depl_to_del, &storage, deleting_staged)?; diff --git a/crates/lib/src/bootc_composefs/digest.rs b/crates/lib/src/bootc_composefs/digest.rs index 0ddef9631..ecf12ed93 100644 --- a/crates/lib/src/bootc_composefs/digest.rs +++ b/crates/lib/src/bootc_composefs/digest.rs @@ -19,6 +19,7 @@ use crate::store::ComposefsRepository; /// /// Returns the TempDir guard (must be kept alive for the repo to remain valid) /// and the repository wrapped in Arc. +#[fn_error_context::context("Creating new temp composefs repo")] pub(crate) fn new_temp_composefs_repo() -> Result<(TempDir, Arc)> { let td_guard = tempfile::tempdir_in("/var/tmp")?; let td_path = td_guard.path(); @@ -49,6 +50,7 @@ pub(crate) fn new_temp_composefs_repo() -> Result<(TempDir, Arc, @@ -61,7 +63,8 @@ pub(crate) fn compute_composefs_digest( // Read filesystem from path, transform for boot, compute digest let mut fs = - composefs::fs::read_container_root(rustix::fs::CWD, path.as_std_path(), Some(&repo))?; + composefs::fs::read_container_root(rustix::fs::CWD, path.as_std_path(), Some(&repo)) + .context("Reading container root")?; fs.transform_for_boot(&repo).context("Preparing for boot")?; let id = fs.compute_image_id(); let digest = id.to_hex(); @@ -143,10 +146,12 @@ mod tests { fn test_compute_composefs_digest_rejects_root() { let result = compute_composefs_digest(Utf8Path::new("/"), None); assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!( - err.contains("Cannot operate on active root filesystem"), - "Unexpected error message: {err}" - ); + let err = result.unwrap_err(); + let found = err.chain().any(|e| { + e.to_string() + .contains("Cannot operate on active root filesystem") + }); + + assert!(found, "Unexpected error chain: {err:?}"); } } diff --git a/crates/lib/src/bootc_composefs/gc.rs b/crates/lib/src/bootc_composefs/gc.rs index c79809354..568fec8f4 100644 --- a/crates/lib/src/bootc_composefs/gc.rs +++ b/crates/lib/src/bootc_composefs/gc.rs @@ -12,7 +12,7 @@ use crate::{ delete::{delete_image, delete_staged, delete_state_dir}, status::{ get_bootloader, get_composefs_status, get_imginfo, get_sorted_grub_uki_boot_entries, - get_sorted_type1_boot_entries, + get_sorted_staged_type1_boot_entries, get_sorted_type1_boot_entries, }, }, composefs_consts::{STATE_DIR_RELATIVE, USER_CFG}, @@ -37,6 +37,21 @@ fn list_erofs_images(sysroot: &Dir) -> Result> { Ok(images) } +fn list_type1_entries(boot_dir: &Dir) -> Result> { + // Type1 Entry + let boot_entries = get_sorted_type1_boot_entries(boot_dir, true)?; + + // We wouldn't want to delete the staged deployment if the GC runs when a + // deployment is staged + let staged_boot_entries = get_sorted_staged_type1_boot_entries(boot_dir, true)?; + + boot_entries + .into_iter() + .chain(staged_boot_entries) + .map(|entry| entry.get_verity()) + .collect::, _>>() +} + /// Get all Type1/Type2 bootloader entries /// /// # Returns @@ -51,8 +66,8 @@ fn list_bootloader_entries(storage: &Storage) -> Result> { // Grub entries are always in boot let grub_dir = boot_dir.open_dir("grub2").context("Opening grub dir")?; + // Grub UKI if grub_dir.exists(USER_CFG) { - // Grub UKI let mut s = String::new(); let boot_entries = get_sorted_grub_uki_boot_entries(boot_dir, &mut s)?; @@ -61,24 +76,11 @@ fn list_bootloader_entries(storage: &Storage) -> Result> { .map(|entry| entry.get_verity()) .collect::, _>>()? } else { - // Type1 Entry - let boot_entries = get_sorted_type1_boot_entries(boot_dir, true)?; - - boot_entries - .into_iter() - .map(|entry| entry.get_verity()) - .collect::, _>>()? + list_type1_entries(boot_dir)? } } - Bootloader::Systemd => { - let boot_entries = get_sorted_type1_boot_entries(boot_dir, true)?; - - boot_entries - .into_iter() - .map(|entry| entry.get_verity()) - .collect::, _>>()? - } + Bootloader::Systemd => list_type1_entries(boot_dir)?, Bootloader::None => unreachable!("Checked at install time"), }; @@ -117,6 +119,12 @@ fn list_state_dirs(sysroot: &Dir) -> Result> { /// /// Similarly if EROFS image B1 doesn't exist, but state dir does, then delete the state dir and /// perform GC +// +// Cases +// - BLS Entries +// - On upgrade/switch, if only two are left, the staged and the current, then no GC +// - If there are three, rollback, booted and staged, GC the rollback, so the current +// becomes rollback #[fn_error_context::context("Running composefs garbage collection")] pub(crate) async fn composefs_gc( storage: &Storage, @@ -141,11 +149,15 @@ pub(crate) async fn composefs_gc( let images = list_erofs_images(&sysroot)?; // Collect the deployments that have an image but no bootloader entry + // and vice versa let img_bootloader_diff = images .iter() .filter(|i| !bootloader_entries.contains(i)) + .chain(bootloader_entries.iter().filter(|b| !images.contains(b))) .collect::>(); + println!("img_bootloader_diff: {img_bootloader_diff:#?}"); + let staged = &host.status.staged; if img_bootloader_diff.contains(&&booted_cfs_status.verity) { @@ -158,7 +170,7 @@ pub(crate) async fn composefs_gc( for verity in &img_bootloader_diff { tracing::debug!("Cleaning up orphaned image: {verity}"); - delete_staged(staged, dry_run)?; + delete_staged(staged, &img_bootloader_diff, dry_run)?; delete_image(&sysroot, verity, dry_run)?; delete_state_dir(&sysroot, verity, dry_run)?; } @@ -173,14 +185,35 @@ pub(crate) async fn composefs_gc( .collect::>(); for verity in &state_img_diff { - delete_staged(staged, dry_run)?; + delete_staged(staged, &state_img_diff, dry_run)?; delete_state_dir(&sysroot, verity, dry_run)?; } - let booted_image = get_imginfo(storage, &booted_cfs_status.verity, None).await?; + let mut additional_roots = vec![]; + + for deployment in host + .status + .staged + .iter() + .chain(host.status.booted.iter()) + .chain(host.status.rollback.iter()) + .chain(host.status.other_deployments.iter()) + { + let verity = &deployment.require_composefs()?.verity; + + let image = get_imginfo(storage, verity, None).await?; + let stream = format!("oci-config-{}", image.manifest.config().digest()); + + additional_roots.push(verity.clone()); + additional_roots.push(stream); + } + + println!("additional_roots: {additional_roots:#?}"); - let stream = format!("oci-config-{}", booted_image.manifest.config().digest()); - let additional_roots = vec![booted_cfs_status.verity.as_str(), &stream]; + let additional_roots = additional_roots + .iter() + .map(|x| x.as_str()) + .collect::>(); // Run garbage collection on objects after deleting images let gc_result = if dry_run { From 0317115ecb5a02bd76f15c2c0c3ce43b728e66b7 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Wed, 18 Feb 2026 12:55:00 +0530 Subject: [PATCH 3/8] composefs: Use boot entries as the source of truth Up until now we were using the state directory as the source of truth for what all deployments we have. This doesn't work in the case a GC operation deletes the bootloader entries, but fails before deleting the state directory. This would leave the output of `bootc status` command in an undefined state. Even worse, we use kernel args, hence bootloader entries to check for soft reboot capabilities, which would just brick as we would have the state dir (so we'd assume the deployment is active), but won't have the actual bootloader entry Signed-off-by: Pragyan Poudyal --- crates/lib/src/bootc_composefs/gc.rs | 61 +----------- crates/lib/src/bootc_composefs/soft_reboot.rs | 6 +- crates/lib/src/bootc_composefs/status.rs | 93 ++++++++++++++----- 3 files changed, 75 insertions(+), 85 deletions(-) diff --git a/crates/lib/src/bootc_composefs/gc.rs b/crates/lib/src/bootc_composefs/gc.rs index 568fec8f4..9e1479641 100644 --- a/crates/lib/src/bootc_composefs/gc.rs +++ b/crates/lib/src/bootc_composefs/gc.rs @@ -10,13 +10,9 @@ use cap_std_ext::cap_std::fs::Dir; use crate::{ bootc_composefs::{ delete::{delete_image, delete_staged, delete_state_dir}, - status::{ - get_bootloader, get_composefs_status, get_imginfo, get_sorted_grub_uki_boot_entries, - get_sorted_staged_type1_boot_entries, get_sorted_type1_boot_entries, - }, + status::{get_composefs_status, get_imginfo, list_bootloader_entries}, }, - composefs_consts::{STATE_DIR_RELATIVE, USER_CFG}, - spec::Bootloader, + composefs_consts::STATE_DIR_RELATIVE, store::{BootedComposefs, Storage}, }; @@ -37,57 +33,6 @@ fn list_erofs_images(sysroot: &Dir) -> Result> { Ok(images) } -fn list_type1_entries(boot_dir: &Dir) -> Result> { - // Type1 Entry - let boot_entries = get_sorted_type1_boot_entries(boot_dir, true)?; - - // We wouldn't want to delete the staged deployment if the GC runs when a - // deployment is staged - let staged_boot_entries = get_sorted_staged_type1_boot_entries(boot_dir, true)?; - - boot_entries - .into_iter() - .chain(staged_boot_entries) - .map(|entry| entry.get_verity()) - .collect::, _>>() -} - -/// Get all Type1/Type2 bootloader entries -/// -/// # Returns -/// The fsverity of EROFS images corresponding to boot entries -#[fn_error_context::context("Listing bootloader entries")] -fn list_bootloader_entries(storage: &Storage) -> Result> { - let bootloader = get_bootloader()?; - let boot_dir = storage.require_boot_dir()?; - - let entries = match bootloader { - Bootloader::Grub => { - // Grub entries are always in boot - let grub_dir = boot_dir.open_dir("grub2").context("Opening grub dir")?; - - // Grub UKI - if grub_dir.exists(USER_CFG) { - let mut s = String::new(); - let boot_entries = get_sorted_grub_uki_boot_entries(boot_dir, &mut s)?; - - boot_entries - .into_iter() - .map(|entry| entry.get_verity()) - .collect::, _>>()? - } else { - list_type1_entries(boot_dir)? - } - } - - Bootloader::Systemd => list_type1_entries(boot_dir)?, - - Bootloader::None => unreachable!("Checked at install time"), - }; - - Ok(entries) -} - #[fn_error_context::context("Listing state directories")] fn list_state_dirs(sysroot: &Dir) -> Result> { let state = sysroot @@ -208,8 +153,6 @@ pub(crate) async fn composefs_gc( additional_roots.push(stream); } - println!("additional_roots: {additional_roots:#?}"); - let additional_roots = additional_roots .iter() .map(|x| x.as_str()) diff --git a/crates/lib/src/bootc_composefs/soft_reboot.rs b/crates/lib/src/bootc_composefs/soft_reboot.rs index b9e8a6e53..ce69eb5fb 100644 --- a/crates/lib/src/bootc_composefs/soft_reboot.rs +++ b/crates/lib/src/bootc_composefs/soft_reboot.rs @@ -1,7 +1,5 @@ use crate::{ - bootc_composefs::{ - service::start_finalize_stated_svc, status::composefs_deployment_status_from, - }, + bootc_composefs::{service::start_finalize_stated_svc, status::get_composefs_status}, cli::SoftRebootMode, composefs_consts::COMPOSEFS_CMDLINE, store::{BootedComposefs, Storage}, @@ -81,7 +79,7 @@ pub(crate) async fn prepare_soft_reboot_composefs( } // We definitely need to re-query the state as some deployment might've been staged - let host = composefs_deployment_status_from(storage, booted_cfs.cmdline).await?; + let host = get_composefs_status(storage, &booted_cfs).await?; let all_deployments = host.all_composefs_deployments()?; diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index 72ea5a58d..1e06802ba 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -224,6 +224,57 @@ fn get_sorted_type1_boot_entries_helper( Ok(all_configs) } +fn list_type1_entries(boot_dir: &Dir) -> Result> { + // Type1 Entry + let boot_entries = get_sorted_type1_boot_entries(boot_dir, true)?; + + // We wouldn't want to delete the staged deployment if the GC runs when a + // deployment is staged + let staged_boot_entries = get_sorted_staged_type1_boot_entries(boot_dir, true)?; + + boot_entries + .into_iter() + .chain(staged_boot_entries) + .map(|entry| entry.get_verity()) + .collect::, _>>() +} + +/// Get all Type1/Type2 bootloader entries +/// +/// # Returns +/// The fsverity of EROFS images corresponding to boot entries +#[fn_error_context::context("Listing bootloader entries")] +pub(crate) fn list_bootloader_entries(storage: &Storage) -> Result> { + let bootloader = get_bootloader()?; + let boot_dir = storage.require_boot_dir()?; + + let entries = match bootloader { + Bootloader::Grub => { + // Grub entries are always in boot + let grub_dir = boot_dir.open_dir("grub2").context("Opening grub dir")?; + + // Grub UKI + if grub_dir.exists(USER_CFG) { + let mut s = String::new(); + let boot_entries = get_sorted_grub_uki_boot_entries(boot_dir, &mut s)?; + + boot_entries + .into_iter() + .map(|entry| entry.get_verity()) + .collect::, _>>()? + } else { + list_type1_entries(boot_dir)? + } + } + + Bootloader::Systemd => list_type1_entries(boot_dir)?, + + Bootloader::None => unreachable!("Checked at install time") + }; + + Ok(entries) +} + /// imgref = transport:image_name #[context("Getting container info")] pub(crate) async fn get_container_manifest_and_config( @@ -323,7 +374,7 @@ pub(crate) async fn get_imginfo( async fn boot_entry_from_composefs_deployment( storage: &Storage, origin: tini::Ini, - verity: String, + verity: &str, ) -> Result { let image = match origin.get::("origin", ORIGIN_CONTAINER) { Some(img_name_from_config) => { @@ -368,11 +419,11 @@ async fn boot_entry_from_composefs_deployment( cached_update: None, incompatible: false, pinned: false, - download_only: false, // Not yet supported for composefs backend + download_only: false, // Set later on store: None, ostree: None, composefs: Some(crate::spec::BootEntryComposefs { - verity, + verity: verity.into(), boot_type, bootloader: get_bootloader()?, boot_digest, @@ -600,7 +651,7 @@ fn set_reboot_capable_uki_deployments( } #[context("Getting composefs deployment status")] -pub(crate) async fn composefs_deployment_status_from( +async fn composefs_deployment_status_from( storage: &Storage, cmdline: &ComposefsCmdline, ) -> Result { @@ -608,10 +659,13 @@ pub(crate) async fn composefs_deployment_status_from( let boot_dir = storage.require_boot_dir()?; - let deployments = storage + // This is our source of truth + let bootloader_entry_verity = list_bootloader_entries(storage)?; + + let state_dir = storage .physical_root - .read_dir(STATE_DIR_RELATIVE) - .with_context(|| format!("Reading sysroot {STATE_DIR_RELATIVE}"))?; + .open_dir(STATE_DIR_RELATIVE) + .with_context(|| format!("Opening {STATE_DIR_RELATIVE}"))?; let host_spec = HostSpec { image: None, @@ -640,24 +694,19 @@ pub(crate) async fn composefs_deployment_status_from( // Rollback deployment is in here, but may also contain stale deployment entries let mut extra_deployment_boot_entries: Vec = Vec::new(); - for depl in deployments { - let depl = depl?; - - let depl_file_name = depl.file_name(); - let depl_file_name = depl_file_name.to_string_lossy(); - + for verity_digest in bootloader_entry_verity { // read the origin file - let config = depl - .open_dir() - .with_context(|| format!("Failed to open {depl_file_name}"))? - .read_to_string(format!("{depl_file_name}.origin")) - .with_context(|| format!("Reading file {depl_file_name}.origin"))?; + let config = state_dir + .open_dir(&verity_digest) + .with_context(|| format!("Failed to open {verity_digest}"))? + .read_to_string(format!("{verity_digest}.origin")) + .with_context(|| format!("Reading file {verity_digest}.origin"))?; let ini = tini::Ini::from_string(&config) - .with_context(|| format!("Failed to parse file {depl_file_name}.origin as ini"))?; + .with_context(|| format!("Failed to parse file {verity_digest}.origin as ini"))?; let mut boot_entry = - boot_entry_from_composefs_deployment(storage, ini, depl_file_name.to_string()).await?; + boot_entry_from_composefs_deployment(storage, ini, &verity_digest).await?; // SAFETY: boot_entry.composefs will always be present let boot_type_from_origin = boot_entry.composefs.as_ref().unwrap().boot_type; @@ -674,7 +723,7 @@ pub(crate) async fn composefs_deployment_status_from( } }; - if depl.file_name() == booted_composefs_digest.as_ref() { + if verity_digest == booted_composefs_digest.as_ref() { host.spec.image = boot_entry.image.as_ref().map(|x| x.image.clone()); host.status.booted = Some(boot_entry); continue; @@ -683,7 +732,7 @@ pub(crate) async fn composefs_deployment_status_from( if let Some(staged_deployment) = &staged_deployment { let staged_depl = serde_json::from_str::(&staged_deployment)?; - if depl_file_name == staged_depl.depl_id { + if verity_digest == staged_depl.depl_id { boot_entry.download_only = staged_depl.finalization_locked; host.status.staged = Some(boot_entry); continue; From ca784bd12ccec897dc632b1444791a8d3fc79d26 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Thu, 19 Feb 2026 13:22:44 +0530 Subject: [PATCH 4/8] composefs/gc: GC bootloader binaries Instead of deleting bootloader binaries in `delete_deployment` function, add logic to GC them along with everything else. So our source or truth becomes the bootloader entries which is also what we use for getting the status. The flow now becomes when deleting a deployment: - Delete the bootloader entries (.conf files and user.cfg for grub) - GC the bootloader binaries + images + state dir + composefs objects This way the GC remains idempotent and we handle in-between deletion failures gracefully. Also, this gives us the opportunity to retrieve the deleted bootloader entries using the data (binaries) from the /boot or /esp in the future Signed-off-by: Pragyan Poudyal refactor: Add helper methods Add `require_esp` method to Storage struct Add `list_all_deployments` method to Host struct Signed-off-by: Pragyan Poudyal --- crates/lib/src/bootc_composefs/boot.rs | 4 +- crates/lib/src/bootc_composefs/delete.rs | 145 +++------------ crates/lib/src/bootc_composefs/export.rs | 9 +- crates/lib/src/bootc_composefs/finalize.rs | 5 +- crates/lib/src/bootc_composefs/gc.rs | 206 +++++++++++++++++++-- crates/lib/src/bootc_composefs/utils.rs | 7 +- crates/lib/src/cli.rs | 6 +- crates/lib/src/spec.rs | 11 ++ crates/lib/src/store/mod.rs | 7 + 9 files changed, 250 insertions(+), 150 deletions(-) diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index f64540c5b..650e2228b 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -126,8 +126,8 @@ pub(crate) const EFI_LINUX: &str = "EFI/Linux"; const SYSTEMD_TIMEOUT: &str = "timeout 5"; const SYSTEMD_LOADER_CONF_PATH: &str = "loader/loader.conf"; -const INITRD: &str = "initrd"; -const VMLINUZ: &str = "vmlinuz"; +pub(crate) const INITRD: &str = "initrd"; +pub(crate) const VMLINUZ: &str = "vmlinuz"; const BOOTC_AUTOENROLL_PATH: &str = "usr/lib/bootc/install/secureboot-keys"; diff --git a/crates/lib/src/bootc_composefs/delete.rs b/crates/lib/src/bootc_composefs/delete.rs index 68f3dbbaa..04e495c86 100644 --- a/crates/lib/src/bootc_composefs/delete.rs +++ b/crates/lib/src/bootc_composefs/delete.rs @@ -2,11 +2,10 @@ use std::{io::Write, path::Path}; use anyhow::{Context, Result}; use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt}; -use composefs_boot::bootloader::{EFI_ADDON_DIR_EXT, EFI_EXT}; use crate::{ bootc_composefs::{ - boot::{BootType, SYSTEMD_UKI_DIR, find_vmlinuz_initrd_duplicates, get_efi_uuid_source}, + boot::{BootType, get_efi_uuid_source}, gc::composefs_gc, rollback::{composefs_rollback, rename_exchange_user_cfg}, status::{get_composefs_status, get_sorted_grub_uki_boot_entries}, @@ -22,7 +21,11 @@ use crate::{ }; #[fn_error_context::context("Deleting Type1 Entry {}", depl.deployment.verity)] -fn delete_type1_entry(depl: &DeploymentEntry, boot_dir: &Dir, deleting_staged: bool) -> Result<()> { +fn delete_type1_conf_file( + depl: &DeploymentEntry, + boot_dir: &Dir, + deleting_staged: bool, +) -> Result<()> { let entries_dir_path = if deleting_staged { TYPE1_ENT_PATH_STAGED } else { @@ -33,15 +36,6 @@ fn delete_type1_entry(depl: &DeploymentEntry, boot_dir: &Dir, deleting_staged: b .open_dir(entries_dir_path) .context("Opening entries dir")?; - // We reuse kernel + initrd if they're the same for two deployments - // We don't want to delete the (being deleted) deployment's kernel + initrd - // if it's in use by any other deployment - let should_del_kernel = match depl.deployment.boot_digest.as_ref() { - Some(digest) => find_vmlinuz_initrd_duplicates(digest)? - .is_some_and(|vec| vec.iter().any(|digest| *digest != depl.deployment.verity)), - None => false, - }; - for entry in entries_dir.entries_utf8()? { let entry = entry?; let file_name = entry.file_name()?; @@ -68,7 +62,6 @@ fn delete_type1_entry(depl: &DeploymentEntry, boot_dir: &Dir, deleting_staged: b // Boot dir in case of EFI will be the ESP tracing::debug!("Deleting EFI .conf file: {}", file_name); entry.remove_file().context("Removing .conf file")?; - delete_uki(&depl.deployment.verity, boot_dir)?; break; } @@ -85,12 +78,6 @@ fn delete_type1_entry(depl: &DeploymentEntry, boot_dir: &Dir, deleting_staged: b tracing::debug!("Deleting non-EFI .conf file: {}", file_name); entry.remove_file().context("Removing .conf file")?; - if should_del_kernel { - delete_kernel_initrd(&bls_config.cfg_type, boot_dir)?; - } else { - tracing::debug!("Kernel/Initrd being used by other deployments. Won't delete"); - } - break; } @@ -103,6 +90,7 @@ fn delete_type1_entry(depl: &DeploymentEntry, boot_dir: &Dir, deleting_staged: b "Deleting staged entries directory: {}", TYPE1_ENT_PATH_STAGED ); + boot_dir .remove_dir_all(TYPE1_ENT_PATH_STAGED) .context("Removing staged entries dir")?; @@ -111,69 +99,6 @@ fn delete_type1_entry(depl: &DeploymentEntry, boot_dir: &Dir, deleting_staged: b Ok(()) } -#[fn_error_context::context("Deleting kernel and initrd")] -fn delete_kernel_initrd(bls_config: &BLSConfigType, boot_dir: &Dir) -> Result<()> { - let BLSConfigType::NonEFI { linux, initrd, .. } = bls_config else { - anyhow::bail!("Found EFI config") - }; - - // "linux" and "initrd" are relative to the boot_dir in our config files - tracing::debug!("Deleting kernel: {:?}", linux); - boot_dir - .remove_file(linux) - .with_context(|| format!("Removing {linux:?}"))?; - - for ird in initrd { - tracing::debug!("Deleting initrd: {:?}", ird); - boot_dir - .remove_file(ird) - .with_context(|| format!("Removing {ird:?}"))?; - } - - // Remove the directory if it's empty - // - // This shouldn't ever error as we'll never have these in root - let dir = linux - .parent() - .ok_or_else(|| anyhow::anyhow!("Bad path for vmlinuz {linux}"))?; - - let kernel_parent_dir = boot_dir.open_dir(&dir)?; - - if kernel_parent_dir.entries().iter().len() == 0 { - // We don't have anything other than kernel and initrd in this directory for now - // So this directory should *always* be empty, for now at least - tracing::debug!("Deleting empty kernel directory: {:?}", dir); - kernel_parent_dir.remove_open_dir()?; - }; - - Ok(()) -} - -/// Deletes the UKI `uki_id` and any addons specific to it -#[fn_error_context::context("Deleting UKI and UKI addons {uki_id}")] -fn delete_uki(uki_id: &str, esp_mnt: &Dir) -> Result<()> { - // TODO: We don't delete global addons here - let ukis = esp_mnt.open_dir(SYSTEMD_UKI_DIR)?; - - for entry in ukis.entries_utf8()? { - let entry = entry?; - let entry_name = entry.file_name()?; - - // The actual UKI PE binary - if entry_name == format!("{}{}", uki_id, EFI_EXT) { - tracing::debug!("Deleting UKI: {}", entry_name); - entry.remove_file().context("Deleting UKI")?; - } else if entry_name == format!("{}{}", uki_id, EFI_ADDON_DIR_EXT) { - // Addons dir - tracing::debug!("Deleting UKI addons directory: {}", entry_name); - ukis.remove_dir_all(entry_name) - .context("Deleting UKI addons dir")?; - } - } - - Ok(()) -} - #[fn_error_context::context("Removing Grub Menuentry")] fn remove_grub_menucfg_entry(id: &str, boot_dir: &Dir, deleting_staged: bool) -> Result<()> { let grub_dir = boot_dir.open_dir("grub2").context("Opening grub2")?; @@ -209,6 +134,9 @@ fn remove_grub_menucfg_entry(id: &str, boot_dir: &Dir, deleting_staged: bool) -> rename_exchange_user_cfg(&grub_dir) } +/// Deletes the .conf files in case for systemd-boot and Type1 bootloader entries for Grub +/// or removes the corresponding menuentry from Grub's user.cfg in case for grub UKI +/// Does not delete the actual boot binaries #[fn_error_context::context("Deleting boot entries for deployment {}", deployment.deployment.verity)] fn delete_depl_boot_entries( deployment: &DeploymentEntry, @@ -219,27 +147,15 @@ fn delete_depl_boot_entries( match deployment.deployment.bootloader { Bootloader::Grub => match deployment.deployment.boot_type { - BootType::Bls => delete_type1_entry(deployment, boot_dir, deleting_staged), - + BootType::Bls => delete_type1_conf_file(deployment, boot_dir, deleting_staged), BootType::Uki => { - let esp = storage - .esp - .as_ref() - .ok_or_else(|| anyhow::anyhow!("ESP not found"))?; - - remove_grub_menucfg_entry( - &deployment.deployment.verity, - boot_dir, - deleting_staged, - )?; - - delete_uki(&deployment.deployment.verity, &esp.fd) + remove_grub_menucfg_entry(&deployment.deployment.verity, boot_dir, deleting_staged) } }, Bootloader::Systemd => { // For Systemd UKI as well, we use .conf files - delete_type1_entry(deployment, boot_dir, deleting_staged) + delete_type1_conf_file(deployment, boot_dir, deleting_staged) } Bootloader::None => unreachable!("Checked at install time"), @@ -249,31 +165,29 @@ fn delete_depl_boot_entries( #[fn_error_context::context("Deleting image for deployment {}", deployment_id)] pub(crate) fn delete_image(sysroot: &Dir, deployment_id: &str, dry_run: bool) -> Result<()> { let img_path = Path::new("composefs").join("images").join(deployment_id); + tracing::debug!("Deleting EROFS image: {:?}", img_path); - tracing::debug!("Will delete EROFS image: {:?}", img_path); - - if !dry_run { - sysroot - .remove_file(&img_path) - .context("Deleting EROFS image")?; + if dry_run { + return Ok(()); } - Ok(()) + sysroot + .remove_file(&img_path) + .context("Deleting EROFS image") } #[fn_error_context::context("Deleting state directory for deployment {}", deployment_id)] pub(crate) fn delete_state_dir(sysroot: &Dir, deployment_id: &str, dry_run: bool) -> Result<()> { let state_dir = Path::new(STATE_DIR_RELATIVE).join(deployment_id); + tracing::debug!("Deleting state directory: {:?}", state_dir); - tracing::debug!("Will delete state directory: {:?}", state_dir); - - if !dry_run { - sysroot - .remove_dir_all(&state_dir) - .with_context(|| format!("Removing dir {state_dir:?}"))?; + if dry_run { + return Ok(()); } - Ok(()) + sysroot + .remove_dir_all(&state_dir) + .with_context(|| format!("Removing dir {state_dir:?}")) } #[fn_error_context::context("Deleting staged deployment")] @@ -293,9 +207,9 @@ pub(crate) fn delete_staged( } let file = Path::new(COMPOSEFS_TRANSIENT_STATE_DIR).join(COMPOSEFS_STAGED_DEPLOYMENT_FNAME); - tracing::debug!("Deleting staged deployment file: {file:?}"); - if !dry_run { + if !dry_run && file.exists() { + tracing::debug!("Deleting staged deployment file: {file:?}"); std::fs::remove_file(file).context("Removing staged file")?; } @@ -307,6 +221,7 @@ pub(crate) async fn delete_composefs_deployment( deployment_id: &str, storage: &Storage, booted_cfs: &BootedComposefs, + dry_run: bool, ) -> Result<()> { const COMPOSEFS_DELETE_JOURNAL_ID: &str = "2a1f0e9d8c7b6a5f4e3d2c1b0a9f8e7d6"; @@ -357,11 +272,11 @@ pub(crate) async fn delete_composefs_deployment( "" }; - tracing::debug!("Deleting {kind}deployment '{deployment_id}'"); + tracing::info!("Deleting {kind}deployment '{deployment_id}'"); delete_depl_boot_entries(&depl_to_del, &storage, deleting_staged)?; - composefs_gc(storage, booted_cfs, false).await?; + composefs_gc(storage, booted_cfs, dry_run).await?; Ok(()) } diff --git a/crates/lib/src/bootc_composefs/export.rs b/crates/lib/src/bootc_composefs/export.rs index f86392421..5cea0ddb9 100644 --- a/crates/lib/src/bootc_composefs/export.rs +++ b/crates/lib/src/bootc_composefs/export.rs @@ -28,14 +28,7 @@ pub async fn export_repo_to_image( let mut depl_verity = None; - for depl in host - .status - .booted - .iter() - .chain(host.status.staged.iter()) - .chain(host.status.rollback.iter()) - .chain(host.status.other_deployments.iter()) - { + for depl in host.list_deployments() { let img = &depl.image.as_ref().unwrap().image; // Not checking transport here as we'll be pulling from the repo anyway diff --git a/crates/lib/src/bootc_composefs/finalize.rs b/crates/lib/src/bootc_composefs/finalize.rs index 61f602fee..960534b06 100644 --- a/crates/lib/src/bootc_composefs/finalize.rs +++ b/crates/lib/src/bootc_composefs/finalize.rs @@ -123,10 +123,7 @@ pub(crate) async fn composefs_backend_finalize( let boot_dir = storage.require_boot_dir()?; - let esp_mount = storage - .esp - .as_ref() - .ok_or_else(|| anyhow::anyhow!("ESP not found"))?; + let esp_mount = storage.require_esp()?; // NOTE: Assuming here we won't have two bootloaders at the same time match booted_composefs.bootloader { diff --git a/crates/lib/src/bootc_composefs/gc.rs b/crates/lib/src/bootc_composefs/gc.rs index 9e1479641..b8f348b08 100644 --- a/crates/lib/src/bootc_composefs/gc.rs +++ b/crates/lib/src/bootc_composefs/gc.rs @@ -4,11 +4,16 @@ //! - We delete the bootloader entry but fail to delete image //! - We delete bootloader + image but fail to delete the state/unrefenced objects etc +use std::os::fd::AsRawFd; + use anyhow::{Context, Result}; -use cap_std_ext::cap_std::fs::Dir; +use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt}; +use composefs_boot::bootloader::{EFI_ADDON_DIR_EXT, EFI_EXT}; +use rustix::fs::readlink; use crate::{ bootc_composefs::{ + boot::{BootType, SYSTEMD_UKI_DIR, VMLINUZ}, delete::{delete_image, delete_staged, delete_state_dir}, status::{get_composefs_status, get_imginfo, list_bootloader_entries}, }, @@ -54,6 +59,143 @@ fn list_state_dirs(sysroot: &Dir) -> Result> { Ok(dirs) } +type BootBinary = (BootType, String); + +/// Collect all BLS Type1 boot binaries and UKI binaries by scanning filesystem +/// +/// Returns a vector of binary type (UKI/Type1) + verity digest of all boot binaries +#[fn_error_context::context("Collecting boot binaries")] +fn collect_boot_binaries(storage: &Storage) -> Result> { + let mut boot_binaries = Vec::new(); + let boot_dir = storage.require_boot_dir()?; + let esp = storage.require_esp()?; + + // Scan for UKI binaries in EFI/Linux/bootc + collect_uki_binaries(&esp.fd, &mut boot_binaries)?; + + // Scan for Type1 boot binaries (kernels + initrds) in `boot_dir` + // depending upon whether systemd-boot is being used, or grub + collect_type1_boot_binaries(&boot_dir, &mut boot_binaries)?; + + Ok(boot_binaries) +} + +/// Scan for UKI binaries in EFI/Linux/bootc +#[fn_error_context::context("Collecting UKI binaries")] +fn collect_uki_binaries(boot_dir: &Dir, boot_binaries: &mut Vec) -> Result<()> { + let Ok(Some(efi_dir)) = boot_dir.open_dir_optional(SYSTEMD_UKI_DIR) else { + return Ok(()); + }; + + for entry in efi_dir.entries_utf8()? { + let entry = entry?; + let name = entry.file_name()?; + + if name.ends_with(EFI_EXT) { + boot_binaries.push((BootType::Uki, name)); + } + } + + Ok(()) +} + +/// Scan for Type1 boot binaries (kernels + initrds) by looking for directories with verity hashes +#[fn_error_context::context("Collecting Type1 boot binaries")] +fn collect_type1_boot_binaries(boot_dir: &Dir, boot_binaries: &mut Vec) -> Result<()> { + for entry in boot_dir.entries_utf8()? { + let entry = entry?; + let dir_path = entry.file_name()?; + + if !entry.file_type()?.is_dir() { + continue; + } + + // Check if the directory name looks like a verity hash + // 128 hex chars for SHA512, 64 hex chars for SHA256 + // TODO: There might be a better way to do this + let valid_len = matches!(dir_path.len(), 64 | 128); + let valid_hex = dir_path.bytes().all(|b| b.is_ascii_hexdigit()); + + if !(valid_len && valid_hex) { + continue; + } + + let verity_dir = boot_dir + .open_dir(&dir_path) + .with_context(|| format!("Opening {dir_path}"))?; + + // Scan inside this directory for kernel and initrd files + for file_entry in verity_dir.entries_utf8()? { + let file_entry = file_entry?; + let file_name = file_entry.file_name()?; + + // Look for kernel and initrd files + if file_name.starts_with(VMLINUZ) { + boot_binaries.push((BootType::Bls, dir_path.clone())); + } + } + } + + Ok(()) +} + +#[fn_error_context::context("Deleting kernel and initrd")] +fn delete_kernel_initrd(storage: &Storage, entry_to_delete: &str, dry_run: bool) -> Result<()> { + let boot_dir = storage.require_boot_dir()?; + + let path = readlink(format!("/proc/self/fd/{}", boot_dir.as_raw_fd()), []) + .context("Getting kernel path")?; + + tracing::debug!("Deleting {path:?}/{entry_to_delete}"); + + if dry_run { + return Ok(()); + } + + boot_dir + .remove_dir_all(entry_to_delete) + .with_context(|| anyhow::anyhow!("Deleting {entry_to_delete}")) +} + +/// Deletes the UKI `uki_id` and any addons specific to it +#[fn_error_context::context("Deleting UKI and UKI addons {uki_id}")] +fn delete_uki(storage: &Storage, uki_id: &str, dry_run: bool) -> Result<()> { + let esp_mnt = storage.require_esp()?; + + // NOTE: We don't delete global addons here + // Which is fine as global addons don't belong to any single deployment + let uki_dir = esp_mnt.fd.open_dir(SYSTEMD_UKI_DIR)?; + + for entry in uki_dir.entries_utf8()? { + let entry = entry?; + let entry_name = entry.file_name()?; + + // The actual UKI PE binary + if entry_name == format!("{}{}", uki_id, EFI_EXT) { + tracing::debug!("Deleting UKI: {}", entry_name); + + if dry_run { + continue; + } + + entry.remove_file().context("Deleting UKI")?; + } else if entry_name == format!("{}{}", uki_id, EFI_ADDON_DIR_EXT) { + // Addons dir + tracing::debug!("Deleting UKI addons directory: {}", entry_name); + + if dry_run { + continue; + } + + uki_dir + .remove_dir_all(entry_name) + .context("Deleting UKI addons dir")?; + } + } + + Ok(()) +} + /// 1. List all bootloader entries /// 2. List all EROFS images /// 3. List all state directories @@ -68,7 +210,7 @@ fn list_state_dirs(sysroot: &Dir) -> Result> { // Cases // - BLS Entries // - On upgrade/switch, if only two are left, the staged and the current, then no GC -// - If there are three, rollback, booted and staged, GC the rollback, so the current +// - If there are three - rollback, booted and staged, GC the rollback, so the current // becomes rollback #[fn_error_context::context("Running composefs garbage collection")] pub(crate) async fn composefs_gc( @@ -90,7 +232,46 @@ pub(crate) async fn composefs_gc( let sysroot = &storage.physical_root; - let bootloader_entries = list_bootloader_entries(&storage)?; + let bootloader_entries = list_bootloader_entries(storage)?; + let boot_binaries = collect_boot_binaries(storage)?; + + tracing::debug!("bootloader_entries: {bootloader_entries:?}"); + + // Bootloader entry is deleted, but the binary (UKI/kernel+initrd) still exists + let unrefenced_boot_binaries = boot_binaries + .iter() + .filter(|bin_path| { + // We reuse kernel + initrd if they're the same for two deployments + // We don't want to delete the (being deleted) deployment's kernel + initrd + // if it's in use by any other deployment + // + // filter the ones that are not referenced by any bootloader entry + !bootloader_entries + .iter() + .any(|boot_entry| bin_path.1.contains(boot_entry)) + }) + .collect::>(); + + tracing::debug!("unrefenced_boot_binaries: {unrefenced_boot_binaries:?}"); + + if unrefenced_boot_binaries + .iter() + .find(|be| be.1 == booted_cfs_status.verity) + .is_some() + { + anyhow::bail!( + "Inconsistent state. Booted binaries '{}' found for cleanup", + booted_cfs_status.verity + ) + } + + for (ty, verity) in unrefenced_boot_binaries { + match ty { + BootType::Bls => delete_kernel_initrd(storage, verity, dry_run)?, + BootType::Uki => delete_uki(storage, verity, dry_run)?, + } + } + let images = list_erofs_images(&sysroot)?; // Collect the deployments that have an image but no bootloader entry @@ -101,7 +282,7 @@ pub(crate) async fn composefs_gc( .chain(bootloader_entries.iter().filter(|b| !images.contains(b))) .collect::>(); - println!("img_bootloader_diff: {img_bootloader_diff:#?}"); + tracing::debug!("img_bootloader_diff: {img_bootloader_diff:#?}"); let staged = &host.status.staged; @@ -134,18 +315,17 @@ pub(crate) async fn composefs_gc( delete_state_dir(&sysroot, verity, dry_run)?; } + // Now we GC the unrefenced objects in composefs repo let mut additional_roots = vec![]; - for deployment in host - .status - .staged - .iter() - .chain(host.status.booted.iter()) - .chain(host.status.rollback.iter()) - .chain(host.status.other_deployments.iter()) - { + for deployment in host.list_deployments() { let verity = &deployment.require_composefs()?.verity; + // These need to be GC'd + if img_bootloader_diff.contains(&verity) || state_img_diff.contains(&verity) { + continue; + } + let image = get_imginfo(storage, verity, None).await?; let stream = format!("oci-config-{}", image.manifest.config().digest()); @@ -166,7 +346,7 @@ pub(crate) async fn composefs_gc( }; if dry_run { - println!("Dry run (no files deleted):"); + println!("Dry run (no files deleted)"); } println!( diff --git a/crates/lib/src/bootc_composefs/utils.rs b/crates/lib/src/bootc_composefs/utils.rs index 0c3c0bb8b..532e642f8 100644 --- a/crates/lib/src/bootc_composefs/utils.rs +++ b/crates/lib/src/bootc_composefs/utils.rs @@ -10,12 +10,7 @@ use bootc_kernel_cmdline::utf8::Cmdline; use fn_error_context::context; fn get_uki(storage: &Storage, deployment_verity: &str) -> Result> { - let uki_dir = storage - .esp - .as_ref() - .ok_or_else(|| anyhow::anyhow!("ESP not mounted"))? - .fd - .open_dir(SYSTEMD_UKI_DIR)?; + let uki_dir = storage.require_esp()?.fd.open_dir(SYSTEMD_UKI_DIR)?; let req_fname = format!("{deployment_verity}.efi"); diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index b1db1dd07..11c67996e 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -793,6 +793,8 @@ pub(crate) enum Opt { #[clap(hide = true)] DeleteDeployment { depl_id: String, + #[clap(long)] + dry_run: bool, }, } @@ -1953,14 +1955,14 @@ async fn run_from_opt(opt: Opt) -> Result<()> { } } - Opt::DeleteDeployment { depl_id } => { + Opt::DeleteDeployment { depl_id, dry_run } => { let storage = &get_storage().await?; match storage.kind()? { BootedStorageKind::Ostree(_) => { anyhow::bail!("DeleteDeployment is only supported for composefs backend") } BootedStorageKind::Composefs(booted_cfs) => { - delete_composefs_deployment(&depl_id, storage, &booted_cfs).await + delete_composefs_deployment(&depl_id, storage, &booted_cfs, dry_run).await } } } diff --git a/crates/lib/src/spec.rs b/crates/lib/src/spec.rs index e2426998b..bf920413f 100644 --- a/crates/lib/src/spec.rs +++ b/crates/lib/src/spec.rs @@ -430,6 +430,17 @@ impl Host { } } + /// Returns a vector of all deployments, i.e. staged, booted, rollback and other deployments + pub(crate) fn list_deployments(&self) -> Vec<&BootEntry> { + self.status + .staged + .iter() + .chain(self.status.booted.iter()) + .chain(self.status.rollback.iter()) + .chain(self.status.other_deployments.iter()) + .collect::>() + } + pub(crate) fn require_composefs_booted(&self) -> anyhow::Result<&BootEntryComposefs> { let cfs = self .status diff --git a/crates/lib/src/store/mod.rs b/crates/lib/src/store/mod.rs index a5f844256..778ce536c 100644 --- a/crates/lib/src/store/mod.rs +++ b/crates/lib/src/store/mod.rs @@ -368,6 +368,13 @@ impl Storage { .ok_or_else(|| anyhow::anyhow!("Boot dir not found")) } + /// Returns the mounted `esp` if it exists + pub(crate) fn require_esp(&self) -> Result<&TempMount> { + self.esp + .as_ref() + .ok_or_else(|| anyhow::anyhow!("ESP not found")) + } + /// Access the underlying ostree repository pub(crate) fn get_ostree(&self) -> Result<&SysrootLock> { self.ostree From 9353ed184b77fd1c861006f118cd2d73bf8fcb1e Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Tue, 18 Nov 2025 17:03:52 +0530 Subject: [PATCH 5/8] composefs/uki: Install all UKIs in EFI/Linux/bootc We were making a distinction based on the bootloader and installing UKIs in EFI/Linux for Grub and EFI/Linux/bootc for sd-boot. IMO it's better if we use the same directory for both bootloaders Signed-off-by: Pragyan Poudyal --- crates/lib/src/bootc_composefs/boot.rs | 16 ++++------------ crates/lib/src/bootc_composefs/gc.rs | 6 +++--- crates/lib/src/bootc_composefs/utils.rs | 4 ++-- crates/lib/src/parsers/grub_menuconfig.rs | 4 +++- 4 files changed, 12 insertions(+), 18 deletions(-) diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 650e2228b..4350495f2 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -137,7 +137,7 @@ const AUTH_EXT: &str = "auth"; /// directory specified by the BLS spec. We do this because we want systemd-boot to only look at /// our config files and not show the actual UKIs in the bootloader menu /// This is relative to the ESP -pub(crate) const SYSTEMD_UKI_DIR: &str = "EFI/Linux/bootc"; +pub(crate) const BOOTC_UKI_DIR: &str = "EFI/Linux/bootc"; pub(crate) enum BootSetupType<'a> { /// For initial setup, i.e. install to-disk @@ -793,7 +793,6 @@ fn write_pe_to_esp( uki_id: &Sha512HashValue, missing_fsverity_allowed: bool, mounted_efi: impl AsRef, - bootloader: &Bootloader, ) -> Result> { let efi_bin = read_file(file, &repo).context("Reading .efi binary")?; @@ -843,14 +842,8 @@ fn write_pe_to_esp( }); } - // Write the UKI to ESP - let efi_linux_path = mounted_efi.as_ref().join(match bootloader { - Bootloader::Grub => EFI_LINUX, - Bootloader::Systemd => SYSTEMD_UKI_DIR, - Bootloader::None => unreachable!("Checked at install time"), - }); - - create_dir_all(&efi_linux_path).context("Creating EFI/Linux")?; + let efi_linux_path = mounted_efi.as_ref().join(BOOTC_UKI_DIR); + create_dir_all(&efi_linux_path).context("Creating bootc UKI directory")?; let final_pe_path = match file_path.parent() { Some(parent) => { @@ -1000,7 +993,7 @@ fn write_systemd_uki_config( bls_conf .with_title(boot_label.boot_label) .with_cfg(BLSConfigType::EFI { - efi: format!("/{SYSTEMD_UKI_DIR}/{}{}", id.to_hex(), EFI_EXT).into(), + efi: format!("/{BOOTC_UKI_DIR}/{}{}", id.to_hex(), EFI_EXT).into(), }) .with_sort_key(primary_sort_key.clone()) .with_version(boot_label.version.unwrap_or_else(|| id.to_hex())); @@ -1143,7 +1136,6 @@ pub(crate) fn setup_composefs_uki_boot( &id, missing_fsverity_allowed, esp_mount.dir.path(), - &bootloader, )?; if let Some(label) = ret { diff --git a/crates/lib/src/bootc_composefs/gc.rs b/crates/lib/src/bootc_composefs/gc.rs index b8f348b08..20b02beb1 100644 --- a/crates/lib/src/bootc_composefs/gc.rs +++ b/crates/lib/src/bootc_composefs/gc.rs @@ -13,7 +13,7 @@ use rustix::fs::readlink; use crate::{ bootc_composefs::{ - boot::{BootType, SYSTEMD_UKI_DIR, VMLINUZ}, + boot::{BootType, BOOTC_UKI_DIR, VMLINUZ}, delete::{delete_image, delete_staged, delete_state_dir}, status::{get_composefs_status, get_imginfo, list_bootloader_entries}, }, @@ -83,7 +83,7 @@ fn collect_boot_binaries(storage: &Storage) -> Result> { /// Scan for UKI binaries in EFI/Linux/bootc #[fn_error_context::context("Collecting UKI binaries")] fn collect_uki_binaries(boot_dir: &Dir, boot_binaries: &mut Vec) -> Result<()> { - let Ok(Some(efi_dir)) = boot_dir.open_dir_optional(SYSTEMD_UKI_DIR) else { + let Ok(Some(efi_dir)) = boot_dir.open_dir_optional(BOOTC_UKI_DIR) else { return Ok(()); }; @@ -164,7 +164,7 @@ fn delete_uki(storage: &Storage, uki_id: &str, dry_run: bool) -> Result<()> { // NOTE: We don't delete global addons here // Which is fine as global addons don't belong to any single deployment - let uki_dir = esp_mnt.fd.open_dir(SYSTEMD_UKI_DIR)?; + let uki_dir = esp_mnt.fd.open_dir(BOOTC_UKI_DIR)?; for entry in uki_dir.entries_utf8()? { let entry = entry?; diff --git a/crates/lib/src/bootc_composefs/utils.rs b/crates/lib/src/bootc_composefs/utils.rs index 532e642f8..0eff05f3a 100644 --- a/crates/lib/src/bootc_composefs/utils.rs +++ b/crates/lib/src/bootc_composefs/utils.rs @@ -1,6 +1,6 @@ use crate::{ bootc_composefs::{ - boot::{SYSTEMD_UKI_DIR, compute_boot_digest_uki}, + boot::{BOOTC_UKI_DIR, compute_boot_digest_uki}, state::update_boot_digest_in_origin, }, store::Storage, @@ -10,7 +10,7 @@ use bootc_kernel_cmdline::utf8::Cmdline; use fn_error_context::context; fn get_uki(storage: &Storage, deployment_verity: &str) -> Result> { - let uki_dir = storage.require_esp()?.fd.open_dir(SYSTEMD_UKI_DIR)?; + let uki_dir = storage.require_esp()?.fd.open_dir(BOOTC_UKI_DIR)?; let req_fname = format!("{deployment_verity}.efi"); diff --git a/crates/lib/src/parsers/grub_menuconfig.rs b/crates/lib/src/parsers/grub_menuconfig.rs index 6e5d0dc56..a1502af3a 100644 --- a/crates/lib/src/parsers/grub_menuconfig.rs +++ b/crates/lib/src/parsers/grub_menuconfig.rs @@ -15,6 +15,8 @@ use nom::{ sequence::delimited, }; +use crate::bootc_composefs::boot::BOOTC_UKI_DIR; + /// Body content of a GRUB menuentry containing parsed commands. #[derive(Debug, PartialEq, Eq)] pub(crate) struct MenuentryBody<'a> { @@ -95,7 +97,7 @@ impl<'a> MenuEntry<'a> { title: format!("{boot_label}: ({uki_id})"), body: MenuentryBody { insmod: vec!["fat", "chain"], - chainloader: format!("/EFI/Linux/{uki_id}.efi"), + chainloader: format!("/{BOOTC_UKI_DIR}/{uki_id}.efi"), search: "--no-floppy --set=root --fs-uuid \"${EFI_PART_UUID}\"", version: 0, extra: vec![], From 549759e3d94c528f20ee26674f1dff9099887836 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Tue, 3 Mar 2026 20:03:29 +0530 Subject: [PATCH 6/8] composefs/gc: Call GC function at the end of update Call the garbage collection function at the end of `do_update` function which is the function that performs an update/switch. Make sure we also take into account the staged entries here, as we wouldn't want to delete the staged entry we just created by upgrading. Add a helper method to get staged grub UKI entries Signed-off-by: Pragyan Poudyal --- crates/lib/src/bootc_composefs/gc.rs | 2 +- crates/lib/src/bootc_composefs/status.rs | 47 +++++++++++++++++++++--- crates/lib/src/bootc_composefs/update.rs | 5 +++ 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/crates/lib/src/bootc_composefs/gc.rs b/crates/lib/src/bootc_composefs/gc.rs index 20b02beb1..4e7325034 100644 --- a/crates/lib/src/bootc_composefs/gc.rs +++ b/crates/lib/src/bootc_composefs/gc.rs @@ -13,7 +13,7 @@ use rustix::fs::readlink; use crate::{ bootc_composefs::{ - boot::{BootType, BOOTC_UKI_DIR, VMLINUZ}, + boot::{BOOTC_UKI_DIR, BootType, VMLINUZ}, delete::{delete_image, delete_staged, delete_state_dir}, status::{get_composefs_status, get_imginfo, list_bootloader_entries}, }, diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index 1e06802ba..4a1f142f8 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -16,6 +16,7 @@ use crate::{ }, composefs_consts::{ COMPOSEFS_CMDLINE, ORIGIN_KEY_BOOT_DIGEST, TYPE1_ENT_PATH, TYPE1_ENT_PATH_STAGED, USER_CFG, + USER_CFG_STAGED, }, install::EFI_LOADER_INFO, parsers::{ @@ -145,14 +146,45 @@ pub(crate) fn composefs_booted() -> Result> { Ok(r.as_ref()) } -// Need str to store lifetime +/// Get the staged grub UKI menuentries +pub(crate) fn get_sorted_grub_uki_boot_entries_staged<'a>( + boot_dir: &Dir, + str: &'a mut String, +) -> Result>> { + get_sorted_grub_uki_boot_entries_helper(boot_dir, str, true) +} + +/// Get the grub UKI menuentries pub(crate) fn get_sorted_grub_uki_boot_entries<'a>( boot_dir: &Dir, str: &'a mut String, ) -> Result>> { - let mut file = boot_dir - .open(format!("grub2/{USER_CFG}")) - .with_context(|| format!("Opening {USER_CFG}"))?; + get_sorted_grub_uki_boot_entries_helper(boot_dir, str, false) +} + +// Need str to store lifetime +fn get_sorted_grub_uki_boot_entries_helper<'a>( + boot_dir: &Dir, + str: &'a mut String, + staged: bool, +) -> Result>> { + let file = if staged { + boot_dir + // As the staged entry might not exist + .open_optional(format!("grub2/{USER_CFG_STAGED}")) + .with_context(|| format!("Opening {USER_CFG_STAGED}"))? + } else { + let f = boot_dir + .open(format!("grub2/{USER_CFG}")) + .with_context(|| format!("Opening {USER_CFG}"))?; + + Some(f) + }; + + let Some(mut file) = file else { + return Ok(Vec::new()); + }; + file.read_to_string(str)?; parse_grub_menuentry_file(str) } @@ -258,8 +290,13 @@ pub(crate) fn list_bootloader_entries(storage: &Storage) -> Result> let mut s = String::new(); let boot_entries = get_sorted_grub_uki_boot_entries(boot_dir, &mut s)?; + let mut staged = String::new(); + let boot_entries_staged = + get_sorted_grub_uki_boot_entries_staged(boot_dir, &mut staged)?; + boot_entries .into_iter() + .chain(boot_entries_staged) .map(|entry| entry.get_verity()) .collect::, _>>()? } else { @@ -269,7 +306,7 @@ pub(crate) fn list_bootloader_entries(storage: &Storage) -> Result> Bootloader::Systemd => list_type1_entries(boot_dir)?, - Bootloader::None => unreachable!("Checked at install time") + Bootloader::None => unreachable!("Checked at install time"), }; Ok(entries) diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index bd2b2b2ce..0a7f1d8ec 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -11,6 +11,7 @@ use ostree_ext::container::ManifestDiff; use crate::{ bootc_composefs::{ boot::{BootSetupType, BootType, setup_composefs_bls_boot, setup_composefs_uki_boot}, + gc::composefs_gc, repo::{get_imgref, pull_composefs_repo}, service::start_finalize_stated_svc, soft_reboot::prepare_soft_reboot_composefs, @@ -302,6 +303,10 @@ pub(crate) async fn do_upgrade( ) .await?; + // We take into account the staged bootloader entries so this won't remove + // the currently staged entry + composefs_gc(storage, booted_cfs, false).await?; + apply_upgrade(storage, booted_cfs, &id.to_hex(), opts).await } From 45d33d0ce5f36300a20ed58f0d33ddf2975079fd Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Tue, 3 Mar 2026 20:05:29 +0530 Subject: [PATCH 7/8] tests: Add test for composefs gc Disable `upgrade-preflight-disk-check` for composefs Signed-off-by: Pragyan Poudyal --- Justfile | 3 +- tmt/plans/integration.fmf | 17 +- tmt/tests/booted/test-composefs-gc.nu | 153 ++++++++++++++++++ .../test-upgrade-preflight-disk-check.nu | 2 + tmt/tests/tests.fmf | 9 +- 5 files changed, 180 insertions(+), 4 deletions(-) create mode 100644 tmt/tests/booted/test-composefs-gc.nu diff --git a/Justfile b/Justfile index a33e9640f..ca3fada2f 100644 --- a/Justfile +++ b/Justfile @@ -117,7 +117,7 @@ test-container: build build-units podman run --rm --env=BOOTC_variant={{variant}} --env=BOOTC_base={{base}} --env=BOOTC_boot_type={{boot_type}} {{base_img}} bootc-integration-tests container [group('core')] -test-composefs bootloader filesystem boot_type seal_state: +test-composefs bootloader filesystem boot_type seal_state *ARGS: @if [ "{{seal_state}}" = "sealed" ] && [ "{{filesystem}}" = "xfs" ]; then \ echo "Invalid combination: sealed requires filesystem that supports fs-verity (ext4, btrfs)"; \ exit 1; \ @@ -138,6 +138,7 @@ test-composefs bootloader filesystem boot_type seal_state: --filesystem={{filesystem}} \ --seal-state={{seal_state}} \ --boot-type={{boot_type}} \ + {{ARGS}} \ $(if [ "{{boot_type}}" = "uki" ]; then echo "readonly"; else echo "integration"; fi) # Run cargo fmt and clippy checks in container diff --git a/tmt/plans/integration.fmf b/tmt/plans/integration.fmf index ae6374690..9f88acdb6 100644 --- a/tmt/plans/integration.fmf +++ b/tmt/plans/integration.fmf @@ -182,8 +182,23 @@ execute: test: - /tmt/tests/tests/test-34-user-agent +/plan-35-composefs-gc: + summary: Test composefs garbage collection with same and different kernel+initrd + discover: + how: fmf + test: + - /tmt/tests/tests/test-35-composefs-gc + +/plan-35-upgrade-preflight-disk-check: + summary: Verify pre-flight disk space check rejects images with inflated layer sizes + discover: + how: fmf + test: + - /tmt/tests/tests/test-35-upgrade-preflight-disk-check + extra-fixme_skip_if_composefs: true + /plan-36-rollback: - summary: Test bootc rollback functionality through image switch and rollback cycle + summary: Test bootc rollback functionality discover: how: fmf test: diff --git a/tmt/tests/booted/test-composefs-gc.nu b/tmt/tests/booted/test-composefs-gc.nu new file mode 100644 index 000000000..e5f2b799d --- /dev/null +++ b/tmt/tests/booted/test-composefs-gc.nu @@ -0,0 +1,153 @@ +# number: 35 +# tmt: +# summary: Test composefs garbage collection with same and different kernel+initrd +# duration: 30m + +use std assert +use tap.nu + +# bootc status +let st = bootc status --json | from json +let booted = $st.status.booted.image + +if not (tap is_composefs) or ($st.status.booted.composefs.bootType | str downcase) == "uki" { + exit 0 +} + +# Create a large file in a new container image, then bootc switch to the image +def first_boot [] { + bootc image copy-to-storage + + echo $" + FROM localhost/bootc + RUN dd if=/dev/zero of=/usr/share/large-test-file bs=1k count=1337 + RUN echo 'large-file-marker' | dd of=/usr/share/large-test-file conv=notrunc + " | podman build -t localhost/bootc-derived . -f - + + let current_time = (date now) + + bootc switch --transport containers-storage localhost/bootc-derived + + # Find the large file's verity and save it + # nu has its own built in find which sucks, so we use the other one + # TODO: Replace this with some concrete API + # See: https://github.com/composefs/composefs-rs/pull/236 + let file_path = ( + /usr/bin/find /sysroot/composefs/objects -type f -size 1337k -newermt ($current_time | format date "%Y-%m-%d %H:%M:%S") + | xargs grep -lx "large-file-marker" + ) + + echo $file_path | save /var/large-file-marker-objpath + cat /var/large-file-marker-objpath + + echo $st.status.booted.composefs.verity | save /var/first-verity + + tmt-reboot +} + +# Create a container image derived from the first boot image, but update the initrd +def second_boot [] { + assert equal $booted.image.image "localhost/bootc-derived" + + let path = cat /var/large-file-marker-objpath + + echo "\$path" + echo $path + + assert ($path | path exists) + + # Create another image with a different initrd so we can test kernel + initrd cleanup + + echo " + FROM localhost/bootc + + RUN echo 'echo hello' > /usr/bin/hello + RUN chmod +x /usr/bin/hello + + RUN mkdir /usr/lib/dracut/modules.d/99something + + RUN cat <<-EOF > /usr/lib/dracut/modules.d/99something/module-setup.sh + #!/usr/bin/bash + + check() { + return 0 + } + + depends() { + return 0 + } + + install() { + inst '/usr/bin/hello' /bin/hello + } + EOF + + RUN set -x; kver=$(cd /usr/lib/modules && echo *); dracut -vf --add bootc /usr/lib/modules/$kver/initramfs.img $kver; + " | lines | each { str trim } | str join "\n" | podman build -t localhost/bootc-derived-initrd . -f - + + bootc switch --transport containers-storage localhost/bootc-derived-initrd + + tmt-reboot +} + +# The large file should've been GC'd as we switched to an image derived from the original one +def third_boot [] { + assert equal $booted.image.image "localhost/bootc-derived-initrd" + + let path = cat /var/large-file-marker-objpath + assert (not ($"/sysroot/composefs/objects/($path)" | path exists)) + + # Also assert we have two different kernel + initrd pairs + let booted_verity = (bootc status --json | from json).status.booted.composefs.verity + + let bootloader = (bootc status --json | from json).status.booted.composefs.bootloader + + let boot_dir = if ($bootloader | str downcase) == "systemd" { + # TODO: Some concrete API for this would be great + mkdir /var/tmp/efi + mount /dev/vda2 /var/tmp/efi + "/var/tmp/efi/EFI/Linux" + } else { + "/sysroot/boot" + } + + assert ($"($boot_dir)/($booted_verity)" | path exists) + + # This is for the rollback, but since the rollback and the very + # first boot have the same kernel + initrd pair, and this rollback + # was deployed after the first boot, we will still be using the very + # first verity for the boot binary name + assert ($"($boot_dir)/(cat /var/first-verity)" | path exists) + + echo $"($boot_dir)/($booted_verity)" | save /var/to-be-deleted-kernel + + # Now we create a new image derived from the current kernel + initrd + # Switching to this and rebooting should remove the old kernel + initrd + echo " + FROM localhost/bootc-derived-initrd + RUN echo 'another file' > /usr/share/another-one + " | podman build -t localhost/bootc-final . -f - + + + bootc switch --transport containers-storage localhost/bootc-final + + tmt-reboot +} + +def fourth_boot [] { + assert equal $booted.image.image "localhost/bootc-final" + assert (not ((cat /var/to-be-deleted-kernel | path exists))) + + tap ok +} + +def main [] { + match $env.TMT_REBOOT_COUNT? { + null | "0" => first_boot, + "1" => second_boot, + "2" => third_boot, + "3" => fourth_boot, + $o => { error make { msg: $"Invalid TMT_REBOOT_COUNT ($o)" } }, + } +} + diff --git a/tmt/tests/booted/test-upgrade-preflight-disk-check.nu b/tmt/tests/booted/test-upgrade-preflight-disk-check.nu index c3371193c..1fad2a8cf 100644 --- a/tmt/tests/booted/test-upgrade-preflight-disk-check.nu +++ b/tmt/tests/booted/test-upgrade-preflight-disk-check.nu @@ -2,6 +2,8 @@ # tmt: # summary: Verify pre-flight disk space check rejects images with inflated layer sizes # duration: 10m +# extra: +# fixme_skip_if_composefs: true # # This test does NOT require a reboot. # It constructs a minimal fake OCI image directory that claims to have an diff --git a/tmt/tests/tests.fmf b/tmt/tests/tests.fmf index 851d6b293..044376fae 100644 --- a/tmt/tests/tests.fmf +++ b/tmt/tests/tests.fmf @@ -102,13 +102,18 @@ duration: 10m test: python3 booted/test-user-agent.py +/test-35-composefs-gc: + summary: Test composefs garbage collection with same and different kernel+initrd + duration: 30m + test: nu booted/test-composefs-gc.nu + /test-35-upgrade-preflight-disk-check: summary: Verify pre-flight disk space check rejects images with inflated layer sizes - duration: 20m + duration: 10m test: nu booted/test-upgrade-preflight-disk-check.nu /test-36-rollback: - summary: Test bootc rollback functionality through image switch and rollback cycle + summary: Test bootc rollback functionality duration: 30m test: nu booted/test-rollback.nu From d8685e32864f4031848ff128c1a05497606334f0 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Fri, 6 Mar 2026 10:27:08 +0530 Subject: [PATCH 8/8] composefs/bls: Use custom prefix for BLS binaris dir Until now we were using only the deployment verity for the name of the directory where we store BLS Type1 boot binary (vmlinuz + initrd) which made it ambiguous when trying to seach for them while GC-ing. Introduce a custom prefix `bootc_composefs-` to all such directories which makes it easier for us to find them Also, update test to reflect this change Signed-off-by: Pragyan Poudyal composefs/gc: Refactor Remove `println!` from Non-CLI code and move it to the CLI Remove `dry_run` argument from DeleteDeployment because we delete the image anyway so it isn't really a "dry run" Signed-off-by: Pragyan Poudyal --- crates/lib/src/bootc_composefs/boot.rs | 37 ++++++++-- crates/lib/src/bootc_composefs/delete.rs | 3 +- crates/lib/src/bootc_composefs/gc.rs | 87 +++++++----------------- crates/lib/src/cli.rs | 26 +++++-- crates/lib/src/composefs_consts.rs | 5 +- tmt/tests/booted/test-composefs-gc.nu | 18 ++++- 6 files changed, 96 insertions(+), 80 deletions(-) diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 4350495f2..ba38179b3 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -91,7 +91,6 @@ use rustix::{mount::MountFlags, path::Arg}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use crate::bootc_composefs::state::{get_booted_bls, write_composefs_state}; use crate::parsers::bls_config::{BLSConfig, BLSConfigType}; use crate::task::Task; use crate::{ @@ -102,6 +101,10 @@ use crate::{ bootc_composefs::repo::open_composefs_repo, store::{ComposefsFilesystem, Storage}, }; +use crate::{ + bootc_composefs::state::{get_booted_bls, write_composefs_state}, + composefs_consts::TYPE1_BOOT_DIR_PREFIX, +}; use crate::{ bootc_composefs::status::get_container_manifest_and_config, bootc_kargs::compute_new_kargs, }; @@ -270,6 +273,11 @@ pub(crate) fn secondary_sort_key(os_id: &str) -> String { format!("bootc-{os_id}-{SORTKEY_PRIORITY_SECONDARY}") } +/// Returns the name of the directory where we store Type1 boot entries +pub(crate) fn get_type1_dir_name(depl_verity: &String) -> String { + format!("{TYPE1_BOOT_DIR_PREFIX}{depl_verity}") +} + /// Compute SHA256Sum of VMlinuz + Initrd /// /// # Arguments @@ -381,10 +389,10 @@ fn write_bls_boot_entries_to_disk( entry: &UsrLibModulesVmlinuz, repo: &crate::store::ComposefsRepository, ) -> Result<()> { - let id_hex = deployment_id.to_hex(); + let dir_name = get_type1_dir_name(&deployment_id.to_hex()); - // Write the initrd and vmlinuz at /boot// - let path = boot_dir.join(&id_hex); + // Write the initrd and vmlinuz at /boot/composefs-/ + let path = boot_dir.join(&dir_name); create_dir_all(&path)?; let entries_dir = Dir::open_ambient_dir(&path, ambient_authority()) @@ -496,6 +504,7 @@ pub(crate) fn setup_composefs_bls_boot( cmdline_options.extend(&root_setup.kargs); + // TODO(Johan-Liebert1): Use ComposefsCmdline let composefs_cmdline = if state.composefs_options.allow_missing_verity { format!("{COMPOSEFS_CMDLINE}=?{id_hex}") } else { @@ -648,13 +657,18 @@ pub(crate) fn setup_composefs_bls_boot( let mut bls_config = BLSConfig::default(); + let entries_dir = get_type1_dir_name(&id_hex); + bls_config .with_title(title) .with_version(version) .with_sort_key(sort_key) .with_cfg(BLSConfigType::NonEFI { - linux: entry_paths.abs_entries_path.join(&id_hex).join(VMLINUZ), - initrd: vec![entry_paths.abs_entries_path.join(&id_hex).join(INITRD)], + linux: entry_paths + .abs_entries_path + .join(&entries_dir) + .join(VMLINUZ), + initrd: vec![entry_paths.abs_entries_path.join(&entries_dir).join(INITRD)], options: Some(cmdline_refs), }); @@ -679,7 +693,16 @@ pub(crate) fn setup_composefs_bls_boot( // We shouldn't error here as all our file names are UTF-8 compatible let ent_name = ent.file_name()?; - if shared_entries.contains(&ent_name) { + let Some(entry_verity_part) = ent_name.strip_prefix(TYPE1_BOOT_DIR_PREFIX) + else { + // Not our directory + continue; + }; + + if shared_entries + .iter() + .any(|shared_ent| shared_ent == entry_verity_part) + { shared_entry = Some(ent_name); break; } diff --git a/crates/lib/src/bootc_composefs/delete.rs b/crates/lib/src/bootc_composefs/delete.rs index 04e495c86..b64ba0173 100644 --- a/crates/lib/src/bootc_composefs/delete.rs +++ b/crates/lib/src/bootc_composefs/delete.rs @@ -221,7 +221,6 @@ pub(crate) async fn delete_composefs_deployment( deployment_id: &str, storage: &Storage, booted_cfs: &BootedComposefs, - dry_run: bool, ) -> Result<()> { const COMPOSEFS_DELETE_JOURNAL_ID: &str = "2a1f0e9d8c7b6a5f4e3d2c1b0a9f8e7d6"; @@ -276,7 +275,7 @@ pub(crate) async fn delete_composefs_deployment( delete_depl_boot_entries(&depl_to_del, &storage, deleting_staged)?; - composefs_gc(storage, booted_cfs, dry_run).await?; + composefs_gc(storage, booted_cfs, true).await?; Ok(()) } diff --git a/crates/lib/src/bootc_composefs/gc.rs b/crates/lib/src/bootc_composefs/gc.rs index 4e7325034..792b300ba 100644 --- a/crates/lib/src/bootc_composefs/gc.rs +++ b/crates/lib/src/bootc_composefs/gc.rs @@ -4,20 +4,19 @@ //! - We delete the bootloader entry but fail to delete image //! - We delete bootloader + image but fail to delete the state/unrefenced objects etc -use std::os::fd::AsRawFd; - use anyhow::{Context, Result}; use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt}; +use composefs::repository::GcResult; use composefs_boot::bootloader::{EFI_ADDON_DIR_EXT, EFI_EXT}; -use rustix::fs::readlink; use crate::{ + bootc_composefs::boot::get_type1_dir_name, bootc_composefs::{ - boot::{BOOTC_UKI_DIR, BootType, VMLINUZ}, + boot::{BOOTC_UKI_DIR, BootType}, delete::{delete_image, delete_staged, delete_state_dir}, status::{get_composefs_status, get_imginfo, list_bootloader_entries}, }, - composefs_consts::STATE_DIR_RELATIVE, + composefs_consts::{STATE_DIR_RELATIVE, TYPE1_BOOT_DIR_PREFIX}, store::{BootedComposefs, Storage}, }; @@ -63,7 +62,7 @@ type BootBinary = (BootType, String); /// Collect all BLS Type1 boot binaries and UKI binaries by scanning filesystem /// -/// Returns a vector of binary type (UKI/Type1) + verity digest of all boot binaries +/// Returns a vector of binary type (UKI/Type1) + name of all boot binaries #[fn_error_context::context("Collecting boot binaries")] fn collect_boot_binaries(storage: &Storage) -> Result> { let mut boot_binaries = Vec::new(); @@ -99,62 +98,44 @@ fn collect_uki_binaries(boot_dir: &Dir, boot_binaries: &mut Vec) -> Ok(()) } -/// Scan for Type1 boot binaries (kernels + initrds) by looking for directories with verity hashes +/// Scan for Type1 boot binaries (kernels + initrds) by looking for directories with +/// that start with bootc_composefs- +/// +/// Strips the prefix and returns the rest of the string #[fn_error_context::context("Collecting Type1 boot binaries")] fn collect_type1_boot_binaries(boot_dir: &Dir, boot_binaries: &mut Vec) -> Result<()> { for entry in boot_dir.entries_utf8()? { let entry = entry?; - let dir_path = entry.file_name()?; + let dir_name = entry.file_name()?; if !entry.file_type()?.is_dir() { continue; } - // Check if the directory name looks like a verity hash - // 128 hex chars for SHA512, 64 hex chars for SHA256 - // TODO: There might be a better way to do this - let valid_len = matches!(dir_path.len(), 64 | 128); - let valid_hex = dir_path.bytes().all(|b| b.is_ascii_hexdigit()); - - if !(valid_len && valid_hex) { + let Some(verity) = dir_name.strip_prefix(TYPE1_BOOT_DIR_PREFIX) else { continue; - } - - let verity_dir = boot_dir - .open_dir(&dir_path) - .with_context(|| format!("Opening {dir_path}"))?; + }; - // Scan inside this directory for kernel and initrd files - for file_entry in verity_dir.entries_utf8()? { - let file_entry = file_entry?; - let file_name = file_entry.file_name()?; - - // Look for kernel and initrd files - if file_name.starts_with(VMLINUZ) { - boot_binaries.push((BootType::Bls, dir_path.clone())); - } - } + // The directory name starts with our custom prefix + boot_binaries.push((BootType::Bls, verity.to_string())); } Ok(()) } #[fn_error_context::context("Deleting kernel and initrd")] -fn delete_kernel_initrd(storage: &Storage, entry_to_delete: &str, dry_run: bool) -> Result<()> { +fn delete_kernel_initrd(storage: &Storage, dir_to_delete: &str, dry_run: bool) -> Result<()> { let boot_dir = storage.require_boot_dir()?; - let path = readlink(format!("/proc/self/fd/{}", boot_dir.as_raw_fd()), []) - .context("Getting kernel path")?; - - tracing::debug!("Deleting {path:?}/{entry_to_delete}"); + tracing::debug!("Deleting Type1 entry {dir_to_delete}"); if dry_run { return Ok(()); } boot_dir - .remove_dir_all(entry_to_delete) - .with_context(|| anyhow::anyhow!("Deleting {entry_to_delete}")) + .remove_dir_all(dir_to_delete) + .with_context(|| anyhow::anyhow!("Deleting {dir_to_delete}")) } /// Deletes the UKI `uki_id` and any addons specific to it @@ -217,7 +198,7 @@ pub(crate) async fn composefs_gc( storage: &Storage, booted_cfs: &BootedComposefs, dry_run: bool, -) -> Result<()> { +) -> Result { const COMPOSEFS_GC_JOURNAL_ID: &str = "3b2a1f0e9d8c7b6a5f4e3d2c1b0a9f8e7"; tracing::info!( @@ -238,7 +219,7 @@ pub(crate) async fn composefs_gc( tracing::debug!("bootloader_entries: {bootloader_entries:?}"); // Bootloader entry is deleted, but the binary (UKI/kernel+initrd) still exists - let unrefenced_boot_binaries = boot_binaries + let unreferenced_boot_binaries = boot_binaries .iter() .filter(|bin_path| { // We reuse kernel + initrd if they're the same for two deployments @@ -248,13 +229,13 @@ pub(crate) async fn composefs_gc( // filter the ones that are not referenced by any bootloader entry !bootloader_entries .iter() - .any(|boot_entry| bin_path.1.contains(boot_entry)) + .any(|boot_entry| bin_path.1 == *boot_entry) }) .collect::>(); - tracing::debug!("unrefenced_boot_binaries: {unrefenced_boot_binaries:?}"); + tracing::debug!("unreferenced_boot_binaries: {unreferenced_boot_binaries:?}"); - if unrefenced_boot_binaries + if unreferenced_boot_binaries .iter() .find(|be| be.1 == booted_cfs_status.verity) .is_some() @@ -265,9 +246,9 @@ pub(crate) async fn composefs_gc( ) } - for (ty, verity) in unrefenced_boot_binaries { + for (ty, verity) in unreferenced_boot_binaries { match ty { - BootType::Bls => delete_kernel_initrd(storage, verity, dry_run)?, + BootType::Bls => delete_kernel_initrd(storage, &get_type1_dir_name(verity), dry_run)?, BootType::Uki => delete_uki(storage, verity, dry_run)?, } } @@ -345,21 +326,5 @@ pub(crate) async fn composefs_gc( booted_cfs.repo.gc(&additional_roots)? }; - if dry_run { - println!("Dry run (no files deleted)"); - } - - println!( - "Objects: {} removed ({} bytes)", - gc_result.objects_removed, gc_result.objects_bytes - ); - - if gc_result.images_pruned > 0 || gc_result.streams_pruned > 0 { - println!( - "Pruned symlinks: {} images, {} streams", - gc_result.images_pruned, gc_result.streams_pruned - ); - } - - Ok(()) + Ok(gc_result) } diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 11c67996e..eeee99358 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -793,8 +793,6 @@ pub(crate) enum Opt { #[clap(hide = true)] DeleteDeployment { depl_id: String, - #[clap(long)] - dry_run: bool, }, } @@ -1917,7 +1915,25 @@ async fn run_from_opt(opt: Opt) -> Result<()> { } BootedStorageKind::Composefs(booted_cfs) => { - composefs_gc(storage, &booted_cfs, dry_run).await + let gc_result = composefs_gc(storage, &booted_cfs, dry_run).await?; + + if dry_run { + println!("Dry run (no files deleted)"); + } + + println!( + "Objects: {} removed ({} bytes)", + gc_result.objects_removed, gc_result.objects_bytes + ); + + if gc_result.images_pruned > 0 || gc_result.streams_pruned > 0 { + println!( + "Pruned symlinks: {} images, {} streams", + gc_result.images_pruned, gc_result.streams_pruned + ); + } + + Ok(()) } } } @@ -1955,14 +1971,14 @@ async fn run_from_opt(opt: Opt) -> Result<()> { } } - Opt::DeleteDeployment { depl_id, dry_run } => { + Opt::DeleteDeployment { depl_id } => { let storage = &get_storage().await?; match storage.kind()? { BootedStorageKind::Ostree(_) => { anyhow::bail!("DeleteDeployment is only supported for composefs backend") } BootedStorageKind::Composefs(booted_cfs) => { - delete_composefs_deployment(&depl_id, storage, &booted_cfs, dry_run).await + delete_composefs_deployment(&depl_id, storage, &booted_cfs).await } } } diff --git a/crates/lib/src/composefs_consts.rs b/crates/lib/src/composefs_consts.rs index 941fc6fed..fc14994fa 100644 --- a/crates/lib/src/composefs_consts.rs +++ b/crates/lib/src/composefs_consts.rs @@ -1,5 +1,3 @@ -#![allow(dead_code)] - /// composefs= parameter in kernel cmdline pub const COMPOSEFS_CMDLINE: &str = "composefs"; @@ -38,3 +36,6 @@ pub(crate) const TYPE1_ENT_PATH: &str = "loader/entries"; pub(crate) const TYPE1_ENT_PATH_STAGED: &str = "loader/entries.staged"; pub(crate) const BOOTC_FINALIZE_STAGED_SERVICE: &str = "bootc-finalize-staged.service"; + +/// The prefix for the directories containing kernel + initrd +pub(crate) const TYPE1_BOOT_DIR_PREFIX: &str = "bootc_composefs-"; diff --git a/tmt/tests/booted/test-composefs-gc.nu b/tmt/tests/booted/test-composefs-gc.nu index e5f2b799d..1288e9965 100644 --- a/tmt/tests/booted/test-composefs-gc.nu +++ b/tmt/tests/booted/test-composefs-gc.nu @@ -10,6 +10,8 @@ use tap.nu let st = bootc status --json | from json let booted = $st.status.booted.image +let dir_prefix = "bootc_composefs-" + if not (tap is_composefs) or ($st.status.booted.composefs.bootType | str downcase) == "uki" { exit 0 } @@ -111,15 +113,17 @@ def third_boot [] { "/sysroot/boot" } - assert ($"($boot_dir)/($booted_verity)" | path exists) + print $"bootdir ($boot_dir)" + + assert ($"($boot_dir)/($dir_prefix)($booted_verity)" | path exists) # This is for the rollback, but since the rollback and the very # first boot have the same kernel + initrd pair, and this rollback # was deployed after the first boot, we will still be using the very # first verity for the boot binary name - assert ($"($boot_dir)/(cat /var/first-verity)" | path exists) + assert ($"($boot_dir)/($dir_prefix)(cat /var/first-verity)" | path exists) - echo $"($boot_dir)/($booted_verity)" | save /var/to-be-deleted-kernel + echo $"($boot_dir)/($dir_prefix)(cat /var/first-verity)" | save /var/to-be-deleted-kernel # Now we create a new image derived from the current kernel + initrd # Switching to this and rebooting should remove the old kernel + initrd @@ -135,6 +139,14 @@ def third_boot [] { } def fourth_boot [] { + let bootloader = (bootc status --json | from json).status.booted.composefs.bootloader + + if ($bootloader | str downcase) == "systemd" { + # TODO: Some concrete API for this would be great + mkdir /var/tmp/efi + mount /dev/vda2 /var/tmp/efi + } + assert equal $booted.image.image "localhost/bootc-final" assert (not ((cat /var/to-be-deleted-kernel | path exists)))