From 3632dffa9fdfc2abb3820c27539830fda7e9bd81 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Sat, 11 Oct 2025 22:20:06 +0530 Subject: [PATCH 1/5] composefs-backend: Deleting deployments Add a command to delete a composefs native deployment Deleting a deployment would mean, deleting the EROFS image, the bootloader entries for that deployment and deleting any objects in the composefs repository that are only referenced by said deployment. Also refactor some functions and add error contexts in some places Signed-off-by: Pragyan Poudyal composefs-backend: Deleting staged deployment Signed-off-by: Pragyan Poudyal --- crates/lib/src/bootc_composefs/boot.rs | 16 +- crates/lib/src/bootc_composefs/delete.rs | 369 +++++++++++++++++++++++ crates/lib/src/bootc_composefs/mod.rs | 1 + crates/lib/src/bootc_composefs/status.rs | 5 +- crates/lib/src/cli.rs | 13 + crates/lib/src/spec.rs | 54 +++- crates/lib/src/status.rs | 9 + 7 files changed, 457 insertions(+), 10 deletions(-) create mode 100644 crates/lib/src/bootc_composefs/delete.rs diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index ef704e68a..a2a0a1fb9 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -64,7 +64,7 @@ const SYSTEMD_LOADER_CONF_PATH: &str = "loader/loader.conf"; /// 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 -const SYSTEMD_UKI_DIR: &str = "EFI/Linux/bootc"; +pub(crate) const SYSTEMD_UKI_DIR: &str = "EFI/Linux/bootc"; pub(crate) enum BootSetupType<'a> { /// For initial setup, i.e. install to-disk @@ -211,9 +211,9 @@ fn compute_boot_digest( /// Given the SHA256 sum of current VMlinuz + Initrd combo, find boot entry with the same SHA256Sum /// /// # Returns -/// Returns the verity of the deployment that has a boot digest same as the one passed in +/// Returns the verity of all deployments that have a boot digest same as the one passed in #[context("Checking boot entry duplicates")] -fn find_vmlinuz_initrd_duplicates(digest: &str) -> Result> { +pub(crate) fn find_vmlinuz_initrd_duplicates(digest: &str) -> Result>> { let deployments = Dir::open_ambient_dir(STATE_DIR_ABS, ambient_authority()); let deployments = match deployments { @@ -223,7 +223,7 @@ fn find_vmlinuz_initrd_duplicates(digest: &str) -> Result> { Err(e) => anyhow::bail!(e), }; - let mut symlink_to: Option = None; + let mut symlink_to: Option> = None; for depl in deployments.entries()? { let depl = depl?; @@ -243,8 +243,10 @@ fn find_vmlinuz_initrd_duplicates(digest: &str) -> Result> { match ini.get::(ORIGIN_KEY_BOOT, ORIGIN_KEY_BOOT_DIGEST) { Some(hash) => { if hash == digest { - symlink_to = Some(depl_file_name.to_string()); - break; + match symlink_to { + Some(ref mut prev) => prev.push(depl_file_name.to_string()), + None => symlink_to = Some(vec![depl_file_name.to_string()]), + } } } @@ -479,6 +481,8 @@ pub(crate) fn setup_composefs_bls_boot( match find_vmlinuz_initrd_duplicates(&boot_digest)? { Some(symlink_to) => { + let symlink_to = &symlink_to[0]; + match bls_config.cfg_type { BLSConfigType::NonEFI { ref mut linux, diff --git a/crates/lib/src/bootc_composefs/delete.rs b/crates/lib/src/bootc_composefs/delete.rs new file mode 100644 index 000000000..b90882d9d --- /dev/null +++ b/crates/lib/src/bootc_composefs/delete.rs @@ -0,0 +1,369 @@ +use std::{collections::HashSet, io::Write, path::Path}; + +use anyhow::{Context, Result}; +use cap_std_ext::{ + cap_std::{ambient_authority, fs::Dir}, + dirext::CapStdExtDirExt, +}; +use composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; +use composefs_boot::bootloader::{EFI_ADDON_DIR_EXT, EFI_EXT}; + +use crate::{ + bootc_composefs::{ + boot::{ + find_vmlinuz_initrd_duplicates, get_efi_uuid_source, get_esp_partition, + get_sysroot_parent_dev, mount_esp, BootType, SYSTEMD_UKI_DIR, + }, + repo::open_composefs_repo, + rollback::{composefs_rollback, rename_exchange_user_cfg}, + status::{composefs_deployment_status, get_sorted_grub_uki_boot_entries}, + }, + composefs_consts::{ + COMPOSEFS_STAGED_DEPLOYMENT_FNAME, COMPOSEFS_TRANSIENT_STATE_DIR, STATE_DIR_RELATIVE, + TYPE1_ENT_PATH, TYPE1_ENT_PATH_STAGED, USER_CFG_STAGED, + }, + parsers::bls_config::{parse_bls_config, BLSConfigType}, + spec::{Bootloader, DeploymentEntry}, + status::Slot, +}; + +struct ObjectRefs { + other_depl: HashSet, + depl_to_del: HashSet, +} + +#[fn_error_context::context("Deleting Type1 Entry {}", depl.deployment.verity)] +fn delete_type1_entries( + depl: &DeploymentEntry, + boot_dir: &Dir, + deleting_staged: bool, +) -> Result<()> { + let entries_dir_path = if deleting_staged { + TYPE1_ENT_PATH_STAGED + } else { + TYPE1_ENT_PATH + }; + + let entries_dir = boot_dir + .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 { + 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()?; + + if !file_name.ends_with(".conf") { + // We don't put any non .conf file in the entries dir + // This is here just for sanity + tracing::debug!("Found non .conf file '{file_name}' in entires dir"); + continue; + } + + let cfg = entries_dir + .read_to_string(&file_name) + .with_context(|| format!("Reading {file_name}"))?; + + let bls_config = parse_bls_config(&cfg)?; + + match &bls_config.cfg_type { + BLSConfigType::EFI { efi } => { + if !efi.as_str().contains(&depl.deployment.verity) { + continue; + } + + // Boot dir in case of EFI will be the ESP + delete_uki(&depl.deployment.verity, boot_dir)?; + entry.remove_file().context("Removing .conf file")?; + + break; + } + + BLSConfigType::NonEFI { options, .. } => { + let options = options + .as_ref() + .ok_or(anyhow::anyhow!("options not found in BLS config file"))?; + + if !options.contains(&depl.deployment.verity) { + continue; + } + + if should_del_kernel { + delete_kernel_initrd(&bls_config.cfg_type, boot_dir)?; + } + + entry.remove_file().context("Removing .conf file")?; + + break; + } + + BLSConfigType::Unknown => anyhow::bail!("Unknown BLS Config Type"), + } + } + + if deleting_staged { + boot_dir + .remove_dir_all(TYPE1_ENT_PATH_STAGED) + .context("Removing staged entries dir")?; + } + + 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 + boot_dir + .remove_file(linux) + .with_context(|| format!("Removing {linux:?}"))?; + + for ird in initrd { + 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 + 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<()> { + 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) { + entry.remove_file().context("Deleting UKI")?; + } else if entry_name == format!("{}{}", uki_id, EFI_ADDON_DIR_EXT) { + // Addons dir + 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")?; + + if deleting_staged { + return grub_dir + .remove_file(USER_CFG_STAGED) + .context("Deleting staged Menuentry"); + } + + let mut string = String::new(); + let menuentries = get_sorted_grub_uki_boot_entries(boot_dir, &mut string)?; + + let mut buffer = vec![]; + + buffer.write_all(get_efi_uuid_source().as_bytes())?; + + for entry in menuentries { + if entry.body.chainloader.contains(id) { + continue; + } + + buffer.write_all(entry.to_string().as_bytes())?; + } + + grub_dir + .atomic_write(USER_CFG_STAGED, buffer) + .with_context(|| format!("Writing to {USER_CFG_STAGED}"))?; + + rustix::fs::fsync(grub_dir.reopen_as_ownedfd().context("Reopening")?).context("fsync")?; + + rename_exchange_user_cfg(&grub_dir) +} + +fn delete_depl_boot_entries(deployment: &DeploymentEntry, deleting_staged: bool) -> Result<()> { + match deployment.deployment.bootloader { + Bootloader::Grub => { + let boot_dir = Dir::open_ambient_dir("/sysroot/boot", ambient_authority()) + .context("Opening boot dir")?; + + match deployment.deployment.boot_type { + BootType::Bls => delete_type1_entries(deployment, &boot_dir, deleting_staged), + + BootType::Uki => { + let device = get_sysroot_parent_dev()?; + let (esp_part, ..) = get_esp_partition(&device)?; + let esp_mount = mount_esp(&esp_part)?; + + delete_uki(&deployment.deployment.verity, &esp_mount.fd)?; + + remove_grub_menucfg_entry( + &deployment.deployment.verity, + &boot_dir, + deleting_staged, + ) + } + } + } + + Bootloader::Systemd => { + let device = get_sysroot_parent_dev()?; + let (esp_part, ..) = get_esp_partition(&device)?; + + let esp_mount = mount_esp(&esp_part)?; + + // For Systemd UKI as well, we use .conf files + delete_type1_entries(deployment, &esp_mount.fd, deleting_staged) + } + } +} + +pub(crate) async fn delete_composefs_deployment(deployment_id: &str, delete: bool) -> Result<()> { + let host = composefs_deployment_status().await?; + + let booted = host.require_composefs_booted()?; + + let all_depls = host.all_composefs_deployments()?; + + let depl_to_del = all_depls + .iter() + .find(|d| d.deployment.verity == deployment_id); + + let Some(depl_to_del) = depl_to_del else { + anyhow::bail!("Deployment {deployment_id} not found"); + }; + + let deleting_staged = host + .status + .staged + .as_ref() + .and_then(|s| s.composefs.as_ref()) + .map_or(false, |cfs| cfs.verity == deployment_id); + + // Get all objects referenced by all images + // Delete objects that are only referenced by the deployment to be deleted + + // Unqueue rollback. This makes it easier to delete boot entries later on + if matches!(depl_to_del.ty, Some(Slot::Rollback)) && host.status.rollback_queued { + composefs_rollback().await?; + } + + let sysroot = + Dir::open_ambient_dir("/sysroot", ambient_authority()).context("Opening sysroot")?; + + 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 = ObjectRefs { + other_depl: HashSet::new(), + depl_to_del: 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}"))?; + + if img_name == deployment_id { + object_refs.depl_to_del.extend(objects); + } else { + object_refs.other_depl.extend(objects); + } + } + + let diff: Vec<&Sha512HashValue> = object_refs + .depl_to_del + .difference(&object_refs.other_depl) + .collect(); + + tracing::debug!("diff: {:#?}", diff); + + // For debugging, but maybe useful elsewhere? + if !delete { + return Ok(()); + } + + if deployment_id == &booted.verity { + anyhow::bail!("Cannot delete currently booted deployment"); + } + + let kind = if depl_to_del.pinned { + "pinned " + } else if deleting_staged { + "staged " + } else { + "" + }; + + tracing::info!("Deleting {kind}deployment '{deployment_id}'"); + + for sha in diff { + let object_path = Path::new("composefs") + .join("objects") + .join(sha.to_object_pathname()); + + sysroot + .remove_file(&object_path) + .with_context(|| format!("Removing {object_path:?}"))?; + } + + let state_dir = Path::new(STATE_DIR_RELATIVE).join(deployment_id); + sysroot + .remove_dir_all(&state_dir) + .with_context(|| format!("Removing dir {state_dir:?}"))?; + + if deleting_staged { + let file = Path::new(COMPOSEFS_TRANSIENT_STATE_DIR).join(COMPOSEFS_STAGED_DEPLOYMENT_FNAME); + tracing::debug!("Deleting staged file {file:?}"); + std::fs::remove_file(file).context("Removing staged file")?; + } + + delete_depl_boot_entries(&depl_to_del, deleting_staged)?; + + // Delete the image + let img_path = Path::new("composefs").join("images").join(deployment_id); + sysroot + .remove_file(&img_path) + .context("Deleting EROFS image")?; + + Ok(()) +} diff --git a/crates/lib/src/bootc_composefs/mod.rs b/crates/lib/src/bootc_composefs/mod.rs index ee8a742ed..69b8e59f3 100644 --- a/crates/lib/src/bootc_composefs/mod.rs +++ b/crates/lib/src/bootc_composefs/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod boot; +pub(crate) mod delete; pub(crate) mod finalize; pub(crate) mod repo; pub(crate) mod rollback; diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index 55708e002..6739d3598 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -6,7 +6,7 @@ use fn_error_context::context; use crate::{ bootc_composefs::boot::{get_esp_partition, get_sysroot_parent_dev, mount_esp, BootType}, - composefs_consts::{COMPOSEFS_CMDLINE, TYPE1_ENT_PATH, USER_CFG}, + composefs_consts::{COMPOSEFS_CMDLINE, ORIGIN_KEY_BOOT_DIGEST, TYPE1_ENT_PATH, USER_CFG}, parsers::{ bls_config::{parse_bls_config, BLSConfig, BLSConfigType}, grub_menuconfig::{parse_grub_menuentry_file, MenuEntry}, @@ -227,6 +227,8 @@ async fn boot_entry_from_composefs_deployment( None => anyhow::bail!("{ORIGIN_KEY_BOOT} not found"), }; + let boot_digest = origin.get::(ORIGIN_KEY_BOOT, ORIGIN_KEY_BOOT_DIGEST); + let e = BootEntry { image, cached_update: None, @@ -238,6 +240,7 @@ async fn boot_entry_from_composefs_deployment( verity, boot_type, bootloader: get_bootloader()?, + boot_digest, }), soft_reboot_capable: false, }; diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 6677342cc..08cf25c30 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -33,6 +33,8 @@ use schemars::schema_for; use serde::{Deserialize, Serialize}; use tempfile::tempdir_in; +#[cfg(feature = "composefs-backend")] +use crate::bootc_composefs::delete::delete_composefs_deployment; #[cfg(feature = "composefs-backend")] use crate::bootc_composefs::{ finalize::{composefs_backend_finalize, get_etc_diff}, @@ -672,6 +674,12 @@ pub(crate) enum Opt { #[cfg(feature = "composefs-backend")] /// Diff current /etc configuration versus default ConfigDiff, + #[cfg(feature = "composefs-backend")] + DeleteDeployment { + depl_id: String, + #[clap(long, default_value_t)] + delete: bool, + }, } /// Ensure we've entered a mount namespace, so that we can remount @@ -1609,6 +1617,11 @@ async fn run_from_opt(opt: Opt) -> Result<()> { #[cfg(feature = "composefs-backend")] Opt::ConfigDiff => get_etc_diff().await, + + #[cfg(feature = "composefs-backend")] + Opt::DeleteDeployment { depl_id, delete } => { + delete_composefs_deployment(&depl_id, delete).await + } } } diff --git a/crates/lib/src/spec.rs b/crates/lib/src/spec.rs index e7d964ead..75b1962e3 100644 --- a/crates/lib/src/spec.rs +++ b/crates/lib/src/spec.rs @@ -209,6 +209,9 @@ pub struct BootEntryComposefs { pub boot_type: BootType, /// Whether we boot using systemd or grub pub bootloader: Bootloader, + /// The sha256sum of vmlinuz + initrd + /// Only `Some` for Type1 boot entries + pub boot_digest: Option, } /// A bootable entry @@ -269,6 +272,13 @@ pub struct HostStatus { pub ty: Option, } +#[cfg(feature = "composefs-backend")] +pub(crate) struct DeploymentEntry<'a> { + pub(crate) ty: Option, + pub(crate) deployment: &'a BootEntryComposefs, + pub(crate) pinned: bool, +} + impl Host { /// Create a new host pub fn new(spec: HostSpec) -> Self { @@ -312,12 +322,50 @@ impl Host { .booted .as_ref() .ok_or(anyhow::anyhow!("Could not find booted deployment"))? - .composefs - .as_ref() - .ok_or(anyhow::anyhow!("Could not find booted image"))?; + .require_composefs()?; Ok(cfs) } + + /// Returns all composefs deployments in a list + #[cfg(feature = "composefs-backend")] + #[fn_error_context::context("Getting all composefs deployments")] + pub(crate) fn all_composefs_deployments<'a>(&'a self) -> Result>> { + let mut all_deps = vec![]; + + let booted = self.require_composefs_booted()?; + all_deps.push(DeploymentEntry { + ty: Some(Slot::Booted), + deployment: booted, + pinned: false, + }); + + if let Some(staged) = &self.status.staged { + all_deps.push(DeploymentEntry { + ty: Some(Slot::Staged), + deployment: staged.require_composefs()?, + pinned: false, + }); + } + + if let Some(rollback) = &self.status.rollback { + all_deps.push(DeploymentEntry { + ty: Some(Slot::Rollback), + deployment: rollback.require_composefs()?, + pinned: false, + }); + } + + for pinned in &self.status.other_deployments { + all_deps.push(DeploymentEntry { + ty: None, + deployment: pinned.require_composefs()?, + pinned: true, + }); + } + + Ok(all_deps) + } } impl Default for Host { diff --git a/crates/lib/src/status.rs b/crates/lib/src/status.rs index 22741e2b2..5f5305509 100644 --- a/crates/lib/src/status.rs +++ b/crates/lib/src/status.rs @@ -22,6 +22,8 @@ use ostree_ext::ostree; #[cfg(feature = "composefs-backend")] use crate::bootc_composefs::status::{composefs_booted, composefs_deployment_status}; use crate::cli::OutputFormat; +#[cfg(feature = "composefs-backend")] +use crate::spec::BootEntryComposefs; use crate::spec::ImageStatus; use crate::spec::{BootEntry, BootOrder, Host, HostSpec, HostStatus, HostType}; use crate::spec::{ImageReference, ImageSignature}; @@ -231,6 +233,13 @@ impl BootEntry { Ok(None) } } + + #[cfg(feature = "composefs-backend")] + pub(crate) fn require_composefs(&self) -> Result<&BootEntryComposefs> { + self.composefs.as_ref().ok_or(anyhow::anyhow!( + "BootEntry is not a composefs native boot entry" + )) + } } /// A variant of [`get_status`] that requires a booted deployment. From 1101839a187c6dd26664f7dbd4236c7da331830e Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Mon, 13 Oct 2025 11:30:01 +0530 Subject: [PATCH 2/5] composefs-backend: Update the order of deletion Delete the boot entries first, the image second and everything else afterwards. If we fail to delete the boot entry, then there's no point in deleting the image as the boot entry will still show, but there will be no image. We delete the objects at the end, as when we later perform a gc operation and don't find the image that references these objects, we can remove them then. The state directory shouldn't have any effect on boot if the image associated to it doesn't exist. If the staged file /run/composefs/staged-deployment does exist, but we have already deleted the staged image, the finalize service would fail but that wouldn't break anything Signed-off-by: Pragyan Poudyal --- crates/lib/src/bootc_composefs/delete.rs | 36 ++++++++++++------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/crates/lib/src/bootc_composefs/delete.rs b/crates/lib/src/bootc_composefs/delete.rs index b90882d9d..49ecf8f28 100644 --- a/crates/lib/src/bootc_composefs/delete.rs +++ b/crates/lib/src/bootc_composefs/delete.rs @@ -336,20 +336,13 @@ pub(crate) async fn delete_composefs_deployment(deployment_id: &str, delete: boo tracing::info!("Deleting {kind}deployment '{deployment_id}'"); - for sha in diff { - let object_path = Path::new("composefs") - .join("objects") - .join(sha.to_object_pathname()); - - sysroot - .remove_file(&object_path) - .with_context(|| format!("Removing {object_path:?}"))?; - } + delete_depl_boot_entries(&depl_to_del, deleting_staged)?; - let state_dir = Path::new(STATE_DIR_RELATIVE).join(deployment_id); + // Delete the image + let img_path = Path::new("composefs").join("images").join(deployment_id); sysroot - .remove_dir_all(&state_dir) - .with_context(|| format!("Removing dir {state_dir:?}"))?; + .remove_file(&img_path) + .context("Deleting EROFS image")?; if deleting_staged { let file = Path::new(COMPOSEFS_TRANSIENT_STATE_DIR).join(COMPOSEFS_STAGED_DEPLOYMENT_FNAME); @@ -357,13 +350,20 @@ pub(crate) async fn delete_composefs_deployment(deployment_id: &str, delete: boo std::fs::remove_file(file).context("Removing staged file")?; } - delete_depl_boot_entries(&depl_to_del, deleting_staged)?; - - // Delete the image - let img_path = Path::new("composefs").join("images").join(deployment_id); + let state_dir = Path::new(STATE_DIR_RELATIVE).join(deployment_id); sysroot - .remove_file(&img_path) - .context("Deleting EROFS image")?; + .remove_dir_all(&state_dir) + .with_context(|| format!("Removing dir {state_dir:?}"))?; + + for sha in diff { + let object_path = Path::new("composefs") + .join("objects") + .join(sha.to_object_pathname()); + + sysroot + .remove_file(&object_path) + .with_context(|| format!("Removing {object_path:?}"))?; + } Ok(()) } From 90b88f94b0ffcc61ec9a89905f9f1ffa4fc19cfb Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Tue, 21 Oct 2025 12:54:36 +0530 Subject: [PATCH 3/5] composefs-backend: Add garbage collection Update the deletion of deployment to only simply delete the bootloader entries related to the deployment and then call a `gc` function, which will just get the difference between the states represented by the bootloader entries and the repository then try to reconcile everything by performing GC operation on the repository. Signed-off-by: Pragyan Poudyal --- crates/lib/src/bootc_composefs/delete.rs | 164 ++++++++-------- crates/lib/src/bootc_composefs/gc.rs | 220 ++++++++++++++++++++++ crates/lib/src/bootc_composefs/mod.rs | 1 + crates/lib/src/bootc_composefs/status.rs | 5 +- crates/lib/src/install.rs | 3 + crates/lib/src/parsers/bls_config.rs | 37 ++++ crates/lib/src/parsers/grub_menuconfig.rs | 16 ++ 7 files changed, 359 insertions(+), 87 deletions(-) create mode 100644 crates/lib/src/bootc_composefs/gc.rs diff --git a/crates/lib/src/bootc_composefs/delete.rs b/crates/lib/src/bootc_composefs/delete.rs index 49ecf8f28..3add5cc7c 100644 --- a/crates/lib/src/bootc_composefs/delete.rs +++ b/crates/lib/src/bootc_composefs/delete.rs @@ -5,7 +5,7 @@ use cap_std_ext::{ cap_std::{ambient_authority, fs::Dir}, dirext::CapStdExtDirExt, }; -use composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; +use composefs::fsverity::Sha512HashValue; use composefs_boot::bootloader::{EFI_ADDON_DIR_EXT, EFI_EXT}; use crate::{ @@ -14,6 +14,7 @@ use crate::{ find_vmlinuz_initrd_duplicates, get_efi_uuid_source, get_esp_partition, get_sysroot_parent_dev, mount_esp, BootType, SYSTEMD_UKI_DIR, }, + gc::composefs_gc, repo::open_composefs_repo, rollback::{composefs_rollback, rename_exchange_user_cfg}, status::{composefs_deployment_status, get_sorted_grub_uki_boot_entries}, @@ -23,21 +24,17 @@ use crate::{ TYPE1_ENT_PATH, TYPE1_ENT_PATH_STAGED, USER_CFG_STAGED, }, parsers::bls_config::{parse_bls_config, BLSConfigType}, - spec::{Bootloader, DeploymentEntry}, + spec::{BootEntry, Bootloader, DeploymentEntry}, status::Slot, }; -struct ObjectRefs { - other_depl: HashSet, - depl_to_del: HashSet, +pub(crate) struct ObjectRefs { + pub(crate) other_depl: HashSet, + pub(crate) depl_to_del: HashSet, } #[fn_error_context::context("Deleting Type1 Entry {}", depl.deployment.verity)] -fn delete_type1_entries( - depl: &DeploymentEntry, - boot_dir: &Dir, - deleting_staged: bool, -) -> Result<()> { +fn delete_type1_entry(depl: &DeploymentEntry, boot_dir: &Dir, deleting_staged: bool) -> Result<()> { let entries_dir_path = if deleting_staged { TYPE1_ENT_PATH_STAGED } else { @@ -81,8 +78,8 @@ fn delete_type1_entries( } // Boot dir in case of EFI will be the ESP - delete_uki(&depl.deployment.verity, boot_dir)?; entry.remove_file().context("Removing .conf file")?; + delete_uki(&depl.deployment.verity, boot_dir)?; break; } @@ -96,12 +93,12 @@ fn delete_type1_entries( continue; } + entry.remove_file().context("Removing .conf file")?; + if should_del_kernel { delete_kernel_initrd(&bls_config.cfg_type, boot_dir)?; } - entry.remove_file().context("Removing .conf file")?; - break; } @@ -156,6 +153,7 @@ fn delete_kernel_initrd(bls_config: &BLSConfigType, boot_dir: &Dir) -> Result<() /// 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()? { @@ -209,6 +207,7 @@ fn remove_grub_menucfg_entry(id: &str, boot_dir: &Dir, deleting_staged: bool) -> rename_exchange_user_cfg(&grub_dir) } +#[fn_error_context::context("Deleting boot entries for deployment {}", deployment.deployment.verity)] fn delete_depl_boot_entries(deployment: &DeploymentEntry, deleting_staged: bool) -> Result<()> { match deployment.deployment.bootloader { Bootloader::Grub => { @@ -216,20 +215,20 @@ fn delete_depl_boot_entries(deployment: &DeploymentEntry, deleting_staged: bool) .context("Opening boot dir")?; match deployment.deployment.boot_type { - BootType::Bls => delete_type1_entries(deployment, &boot_dir, deleting_staged), + BootType::Bls => delete_type1_entry(deployment, &boot_dir, deleting_staged), BootType::Uki => { let device = get_sysroot_parent_dev()?; let (esp_part, ..) = get_esp_partition(&device)?; let esp_mount = mount_esp(&esp_part)?; - delete_uki(&deployment.deployment.verity, &esp_mount.fd)?; - remove_grub_menucfg_entry( &deployment.deployment.verity, &boot_dir, deleting_staged, - ) + )?; + + delete_uki(&deployment.deployment.verity, &esp_mount.fd) } } } @@ -241,44 +240,12 @@ fn delete_depl_boot_entries(deployment: &DeploymentEntry, deleting_staged: bool) let esp_mount = mount_esp(&esp_part)?; // For Systemd UKI as well, we use .conf files - delete_type1_entries(deployment, &esp_mount.fd, deleting_staged) + delete_type1_entry(deployment, &esp_mount.fd, deleting_staged) } } } -pub(crate) async fn delete_composefs_deployment(deployment_id: &str, delete: bool) -> Result<()> { - let host = composefs_deployment_status().await?; - - let booted = host.require_composefs_booted()?; - - let all_depls = host.all_composefs_deployments()?; - - let depl_to_del = all_depls - .iter() - .find(|d| d.deployment.verity == deployment_id); - - let Some(depl_to_del) = depl_to_del else { - anyhow::bail!("Deployment {deployment_id} not found"); - }; - - let deleting_staged = host - .status - .staged - .as_ref() - .and_then(|s| s.composefs.as_ref()) - .map_or(false, |cfs| cfs.verity == deployment_id); - - // Get all objects referenced by all images - // Delete objects that are only referenced by the deployment to be deleted - - // Unqueue rollback. This makes it easier to delete boot entries later on - if matches!(depl_to_del.ty, Some(Slot::Rollback)) && host.status.rollback_queued { - composefs_rollback().await?; - } - - let sysroot = - Dir::open_ambient_dir("/sysroot", ambient_authority()).context("Opening sysroot")?; - +pub(crate) fn get_image_objects(sysroot: &Dir, deployment_id: Option<&str>) -> Result { let repo = open_composefs_repo(&sysroot)?; let images_dir = sysroot @@ -303,19 +270,73 @@ pub(crate) async fn delete_composefs_deployment(deployment_id: &str, delete: boo .objects_for_image(&img_name) .with_context(|| format!("Getting objects for image {img_name}"))?; - if img_name == deployment_id { - object_refs.depl_to_del.extend(objects); + if let Some(deployment_id) = deployment_id { + if deployment_id == img_name { + object_refs.depl_to_del.extend(objects); + } } else { object_refs.other_depl.extend(objects); } } - let diff: Vec<&Sha512HashValue> = object_refs - .depl_to_del - .difference(&object_refs.other_depl) - .collect(); + Ok(object_refs) +} + +pub(crate) fn delete_image(sysroot: &Dir, deployment_id: &str) -> Result<()> { + let img_path = Path::new("composefs").join("images").join(deployment_id); + + sysroot + .remove_file(&img_path) + .context("Deleting EROFS image") +} - tracing::debug!("diff: {:#?}", diff); +pub(crate) fn delete_state_dir(sysroot: &Dir, deployment_id: &str) -> Result<()> { + let state_dir = Path::new(STATE_DIR_RELATIVE).join(deployment_id); + + sysroot + .remove_dir_all(&state_dir) + .with_context(|| format!("Removing dir {state_dir:?}")) +} + +pub(crate) fn delete_staged(staged: &Option) -> Result<()> { + if staged.is_none() { + tracing::debug!("No staged deployment"); + return Ok(()); + }; + + let file = Path::new(COMPOSEFS_TRANSIENT_STATE_DIR).join(COMPOSEFS_STAGED_DEPLOYMENT_FNAME); + tracing::debug!("Deleting staged file {file:?}"); + std::fs::remove_file(file).context("Removing staged file")?; + + Ok(()) +} + +pub(crate) async fn delete_composefs_deployment(deployment_id: &str, delete: bool) -> Result<()> { + let host = composefs_deployment_status().await?; + + let booted = host.require_composefs_booted()?; + + let all_depls = host.all_composefs_deployments()?; + + let depl_to_del = all_depls + .iter() + .find(|d| d.deployment.verity == deployment_id); + + let Some(depl_to_del) = depl_to_del else { + anyhow::bail!("Deployment {deployment_id} not found"); + }; + + let deleting_staged = host + .status + .staged + .as_ref() + .and_then(|s| s.composefs.as_ref()) + .map_or(false, |cfs| cfs.verity == deployment_id); + + // Unqueue rollback. This makes it easier to delete boot entries later on + if matches!(depl_to_del.ty, Some(Slot::Rollback)) && host.status.rollback_queued { + composefs_rollback().await?; + } // For debugging, but maybe useful elsewhere? if !delete { @@ -338,32 +359,7 @@ pub(crate) async fn delete_composefs_deployment(deployment_id: &str, delete: boo delete_depl_boot_entries(&depl_to_del, deleting_staged)?; - // Delete the image - let img_path = Path::new("composefs").join("images").join(deployment_id); - sysroot - .remove_file(&img_path) - .context("Deleting EROFS image")?; - - if deleting_staged { - let file = Path::new(COMPOSEFS_TRANSIENT_STATE_DIR).join(COMPOSEFS_STAGED_DEPLOYMENT_FNAME); - tracing::debug!("Deleting staged file {file:?}"); - std::fs::remove_file(file).context("Removing staged file")?; - } - - let state_dir = Path::new(STATE_DIR_RELATIVE).join(deployment_id); - sysroot - .remove_dir_all(&state_dir) - .with_context(|| format!("Removing dir {state_dir:?}"))?; - - for sha in diff { - let object_path = Path::new("composefs") - .join("objects") - .join(sha.to_object_pathname()); - - sysroot - .remove_file(&object_path) - .with_context(|| format!("Removing {object_path:?}"))?; - } + composefs_gc().await?; Ok(()) } diff --git a/crates/lib/src/bootc_composefs/gc.rs b/crates/lib/src/bootc_composefs/gc.rs new file mode 100644 index 000000000..d938c3369 --- /dev/null +++ b/crates/lib/src/bootc_composefs/gc.rs @@ -0,0 +1,220 @@ +//! This module handles the case when deleting a deployment fails midway +//! +//! There could be the following cases (See ./delete.rs:delete_composefs_deployment): +//! - We delete the bootloader entry but fail to delete image +//! - We delete bootloader + image but fail to delete the state/unrefenced objects etc + +use anyhow::{Context, Result}; +use cap_std_ext::{ + cap_std::{ambient_authority, fs::Dir}, + dirext::CapStdExtDirExt, +}; +use composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; + +use crate::{ + bootc_composefs::{ + boot::{get_esp_partition, get_sysroot_parent_dev, mount_esp}, + delete::{delete_image, delete_staged, delete_state_dir, get_image_objects}, + status::{ + composefs_deployment_status, get_bootloader, get_sorted_grub_uki_boot_entries, + get_sorted_type1_boot_entries, + }, + }, + composefs_consts::{STATE_DIR_RELATIVE, USER_CFG}, + spec::Bootloader, +}; + +fn list_erofs_images(sysroot: &Dir) -> Result> { + let images_dir = sysroot + .open_dir("composefs/images") + .context("Opening images dir")?; + + let mut images = vec![]; + + for entry in images_dir.entries_utf8()? { + let entry = entry?; + let name = entry.file_name()?; + images.push(name); + } + + Ok(images) +} + +/// Get all Type1/Type2 bootloader entries +/// +/// # Returns +/// The fsverity of EROFS images corresponding to boot entries +fn list_bootloader_entries() -> Result> { + let bootloader = get_bootloader()?; + + let entries = match bootloader { + Bootloader::Grub => { + let boot_dir = Dir::open_ambient_dir("/sysroot/boot", ambient_authority()) + .context("Opening boot dir")?; + + // Grub entries are always in boot + let grub_dir = boot_dir.open_dir("grub2").context("Opening grub dir")?; + + 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)?; + + boot_entries + .into_iter() + .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::, _>>()? + } + } + + Bootloader::Systemd => { + let device = get_sysroot_parent_dev()?; + let (esp_part, ..) = get_esp_partition(&device)?; + let esp_mount = mount_esp(&esp_part)?; + + let boot_entries = get_sorted_type1_boot_entries(&esp_mount.fd, true)?; + + boot_entries + .into_iter() + .map(|entry| entry.get_verity()) + .collect::, _>>()? + } + }; + + Ok(entries) +} + +fn list_state_dirs(sysroot: &Dir) -> Result> { + let state = sysroot + .open_dir(STATE_DIR_RELATIVE) + .context("Opening state dir")?; + + let mut dirs = vec![]; + + for dir in state.entries_utf8()? { + let dir = dir?; + + if dir.file_type()?.is_file() { + continue; + } + + dirs.push(dir.file_name()?); + } + + 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 +pub(crate) fn gc_objects(sysroot: &Dir) -> Result<()> { + // Get all the objects referenced by all available images + let obj_refs = get_image_objects(sysroot, None)?; + + // 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.other_depl.contains(&id) { + tracing::trace!("Removed 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 +/// 4. List staged depl if any +/// +/// If bootloader entry B1 doesn't exist, but EROFS image B1 does exist, then delete the image and +/// perform GC +/// +/// Similarly if EROFS image B1 doesn't exist, but state dir does, then delete the state dir and +/// perform GC +pub(crate) async fn composefs_gc() -> Result<()> { + let host = composefs_deployment_status().await?; + let booted_cfs = host.require_composefs_booted()?; + + let sysroot = + Dir::open_ambient_dir("/sysroot", ambient_authority()).context("Opening sysroot")?; + + let bootloader_entries = list_bootloader_entries()?; + let images = list_erofs_images(&sysroot)?; + + // Collect the deployments that have an image but no bootloader entry + let img_bootloader_diff = images + .iter() + .filter(|i| !bootloader_entries.contains(i)) + .collect::>(); + + let staged = &host.status.staged; + + if img_bootloader_diff.contains(&&booted_cfs.verity) { + anyhow::bail!( + "Inconsistent state. Booted entry '{}' found for cleanup", + booted_cfs.verity + ) + } + + 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)?; + } + + let state_dirs = list_state_dirs(&sysroot)?; + + // Collect all the deployments that have no image but have a state dir + // This for the case where the gc was interrupted after deleting the image + let state_img_diff = state_dirs + .iter() + .filter(|s| !images.contains(s)) + .collect::>(); + + for verity in &state_img_diff { + tracing::debug!("Cleaning up orphaned state directory: {verity}"); + + delete_staged(staged)?; + delete_state_dir(&sysroot, verity)?; + } + + // Run garbage collection on objects after deleting images + gc_objects(&sysroot)?; + + Ok(()) +} diff --git a/crates/lib/src/bootc_composefs/mod.rs b/crates/lib/src/bootc_composefs/mod.rs index 69b8e59f3..a9ced452d 100644 --- a/crates/lib/src/bootc_composefs/mod.rs +++ b/crates/lib/src/bootc_composefs/mod.rs @@ -1,6 +1,7 @@ pub(crate) mod boot; pub(crate) mod delete; pub(crate) mod finalize; +pub(crate) mod gc; pub(crate) mod repo; pub(crate) mod rollback; pub(crate) mod service; diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index 6739d3598..51ab35f83 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -7,6 +7,7 @@ use fn_error_context::context; use crate::{ bootc_composefs::boot::{get_esp_partition, get_sysroot_parent_dev, mount_esp, BootType}, composefs_consts::{COMPOSEFS_CMDLINE, ORIGIN_KEY_BOOT_DIGEST, TYPE1_ENT_PATH, USER_CFG}, + install::EFI_LOADER_INFO, parsers::{ bls_config::{parse_bls_config, BLSConfig, BLSConfigType}, grub_menuconfig::{parse_grub_menuentry_file, MenuEntry}, @@ -152,9 +153,7 @@ async fn get_container_manifest_and_config( } #[context("Getting bootloader")] -fn get_bootloader() -> Result { - const EFI_LOADER_INFO: &str = "LoaderInfo-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f"; - +pub(crate) fn get_bootloader() -> Result { match read_uefi_var(EFI_LOADER_INFO) { Ok(loader) => { if loader.to_lowercase().contains("systemd-boot") { diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index fe11d8e34..9f8fb2105 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -88,6 +88,9 @@ const SELINUXFS: &str = "/sys/fs/selinux"; pub(crate) const EFIVARFS: &str = "/sys/firmware/efi/efivars"; pub(crate) const ARCH_USES_EFI: bool = cfg!(any(target_arch = "x86_64", target_arch = "aarch64")); +#[cfg(feature = "composefs-backend")] +pub(crate) const EFI_LOADER_INFO: &str = "LoaderInfo-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f"; + const DEFAULT_REPO_CONFIG: &[(&str, &str)] = &[ // Default to avoiding grub2-mkconfig etc. ("sysroot.bootloader", "none"), diff --git a/crates/lib/src/parsers/bls_config.rs b/crates/lib/src/parsers/bls_config.rs index 1d24c9ebe..5f96fe647 100644 --- a/crates/lib/src/parsers/bls_config.rs +++ b/crates/lib/src/parsers/bls_config.rs @@ -5,12 +5,16 @@ #![allow(dead_code)] use anyhow::{anyhow, Result}; +use bootc_kernel_cmdline::utf8::Cmdline; use camino::Utf8PathBuf; +use composefs_boot::bootloader::EFI_EXT; use core::fmt; use std::collections::HashMap; use std::fmt::Display; use uapi_version::Version; +use crate::composefs_consts::COMPOSEFS_CMDLINE; + #[derive(Debug, PartialEq, PartialOrd, Eq, Default)] pub enum BLSConfigType { EFI { @@ -166,6 +170,39 @@ impl BLSConfig { self.extra = new_val; self } + + pub(crate) fn get_verity(&self) -> Result { + match &self.cfg_type { + BLSConfigType::EFI { efi } => Ok(efi + .components() + .last() + .ok_or(anyhow::anyhow!("Empty efi field"))? + .to_string() + .strip_suffix(EFI_EXT) + .ok_or(anyhow::anyhow!("efi doesn't end with .efi"))? + .to_string()), + + BLSConfigType::NonEFI { options, .. } => { + let options = options.as_ref().ok_or(anyhow::anyhow!("No options"))?; + + let cmdline = Cmdline::from(&options); + + let kv = cmdline + .find(COMPOSEFS_CMDLINE) + .ok_or(anyhow::anyhow!("No composefs= param"))?; + + let value = kv + .value() + .ok_or(anyhow::anyhow!("Empty composefs= param"))?; + + let value = value.to_owned(); + + Ok(value) + } + + BLSConfigType::Unknown => anyhow::bail!("Unknown config type"), + } + } } pub(crate) fn parse_bls_config(input: &str) -> Result { diff --git a/crates/lib/src/parsers/grub_menuconfig.rs b/crates/lib/src/parsers/grub_menuconfig.rs index f51b2eb29..41e25554c 100644 --- a/crates/lib/src/parsers/grub_menuconfig.rs +++ b/crates/lib/src/parsers/grub_menuconfig.rs @@ -4,6 +4,9 @@ use std::fmt::Display; +use anyhow::Result; +use camino::Utf8PathBuf; +use composefs_boot::bootloader::EFI_EXT; use nom::{ bytes::complete::{escaped, tag, take_until}, character::complete::{multispace0, multispace1, none_of}, @@ -99,6 +102,19 @@ impl<'a> MenuEntry<'a> { }, } } + + pub(crate) fn get_verity(&self) -> Result { + let to_path = Utf8PathBuf::from(self.body.chainloader.clone()); + + Ok(to_path + .components() + .last() + .ok_or(anyhow::anyhow!("Empty efi field"))? + .to_string() + .strip_suffix(EFI_EXT) + .ok_or(anyhow::anyhow!("efi doesn't end with .efi"))? + .to_string()) + } } /// Parser that takes content until balanced brackets, handling nested brackets and escapes. From 5afe838e5aa2ceea8f3484429ecdeb0060206c42 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Tue, 28 Oct 2025 16:39:22 +0530 Subject: [PATCH 4/5] composefs-backend/gc: Refactor and add logs Add debug logs for whatever is being deleted Remove the `delete` param from `delete_deployment` function Use `atomic_replace_with` instead of writing to a buffer then writing Signed-off-by: Pragyan Poudyal --- crates/lib/src/bootc_composefs/boot.rs | 52 +++++++-------- crates/lib/src/bootc_composefs/delete.rs | 78 +++++++++++----------- crates/lib/src/bootc_composefs/gc.rs | 16 +++-- crates/lib/src/bootc_composefs/rollback.rs | 18 ++--- crates/lib/src/cli.rs | 10 +-- 5 files changed, 87 insertions(+), 87 deletions(-) diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index a2a0a1fb9..590c0c4a7 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -696,28 +696,27 @@ fn write_grub_uki_menuentry( // // TODO: We might find a staged deployment here if is_upgrade { - let mut buffer = vec![]; - - // Shouldn't really fail so no context here - buffer.write_all(efi_uuid_source.as_bytes())?; - buffer.write_all( - MenuEntry::new(&boot_label, &id.to_hex()) - .to_string() - .as_bytes(), - )?; - let mut str_buf = String::new(); let boot_dir = Dir::open_ambient_dir(boot_dir, ambient_authority()).context("Opening boot dir")?; let entries = get_sorted_grub_uki_boot_entries(&boot_dir, &mut str_buf)?; - // Write out only the currently booted entry, which should be the very first one - // Even if we have booted into the second menuentry "boot entry", the default will be the - // first one - buffer.write_all(entries[0].to_string().as_bytes())?; - grub_dir - .atomic_write(user_cfg_name, buffer) + .atomic_replace_with(user_cfg_name, |f| -> std::io::Result<_> { + f.write_all(efi_uuid_source.as_bytes())?; + f.write_all( + MenuEntry::new(&boot_label, &id.to_hex()) + .to_string() + .as_bytes(), + )?; + + // Write out only the currently booted entry, which should be the very first one + // Even if we have booted into the second menuentry "boot entry", the default will be the + // first one + f.write_all(entries[0].to_string().as_bytes())?; + + Ok(()) + }) .with_context(|| format!("Writing to {user_cfg_name}"))?; rustix::fs::fsync(grub_dir.reopen_as_ownedfd()?).context("fsync")?; @@ -737,18 +736,17 @@ fn write_grub_uki_menuentry( )?; // Write to grub2/user.cfg - let mut buffer = vec![]; - - // Shouldn't really fail so no context here - buffer.write_all(efi_uuid_source.as_bytes())?; - buffer.write_all( - MenuEntry::new(&boot_label, &id.to_hex()) - .to_string() - .as_bytes(), - )?; - grub_dir - .atomic_write(user_cfg_name, buffer) + .atomic_replace_with(user_cfg_name, |f| -> std::io::Result<_> { + f.write_all(efi_uuid_source.as_bytes())?; + f.write_all( + MenuEntry::new(&boot_label, &id.to_hex()) + .to_string() + .as_bytes(), + )?; + + Ok(()) + }) .with_context(|| format!("Writing to {user_cfg_name}"))?; rustix::fs::fsync(grub_dir.reopen_as_ownedfd()?).context("fsync")?; diff --git a/crates/lib/src/bootc_composefs/delete.rs b/crates/lib/src/bootc_composefs/delete.rs index 3add5cc7c..f7f3f71cb 100644 --- a/crates/lib/src/bootc_composefs/delete.rs +++ b/crates/lib/src/bootc_composefs/delete.rs @@ -28,11 +28,6 @@ use crate::{ status::Slot, }; -pub(crate) struct ObjectRefs { - pub(crate) other_depl: HashSet, - pub(crate) depl_to_del: HashSet, -} - #[fn_error_context::context("Deleting Type1 Entry {}", depl.deployment.verity)] fn delete_type1_entry(depl: &DeploymentEntry, boot_dir: &Dir, deleting_staged: bool) -> Result<()> { let entries_dir_path = if deleting_staged { @@ -78,6 +73,7 @@ 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)?; @@ -93,6 +89,7 @@ fn delete_type1_entry(depl: &DeploymentEntry, boot_dir: &Dir, deleting_staged: b continue; } + tracing::debug!("Deleting non-EFI .conf file: {}", file_name); entry.remove_file().context("Removing .conf file")?; if should_del_kernel { @@ -107,6 +104,10 @@ fn delete_type1_entry(depl: &DeploymentEntry, boot_dir: &Dir, deleting_staged: b } if deleting_staged { + tracing::debug!( + "Deleting staged entries directory: {}", + TYPE1_ENT_PATH_STAGED + ); boot_dir .remove_dir_all(TYPE1_ENT_PATH_STAGED) .context("Removing staged entries dir")?; @@ -122,11 +123,13 @@ fn delete_kernel_initrd(bls_config: &BLSConfigType, boot_dir: &Dir) -> Result<() }; // "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:?}"))?; @@ -144,6 +147,7 @@ fn delete_kernel_initrd(bls_config: &BLSConfigType, boot_dir: &Dir) -> Result<() 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()?; }; @@ -162,9 +166,11 @@ fn delete_uki(uki_id: &str, esp_mnt: &Dir) -> Result<()> { // 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")?; } @@ -178,6 +184,7 @@ fn remove_grub_menucfg_entry(id: &str, boot_dir: &Dir, deleting_staged: bool) -> let grub_dir = boot_dir.open_dir("grub2").context("Opening grub2")?; if deleting_staged { + tracing::debug!("Deleting staged grub menuentry file: {}", USER_CFG_STAGED); return grub_dir .remove_file(USER_CFG_STAGED) .context("Deleting staged Menuentry"); @@ -186,20 +193,20 @@ fn remove_grub_menucfg_entry(id: &str, boot_dir: &Dir, deleting_staged: bool) -> let mut string = String::new(); let menuentries = get_sorted_grub_uki_boot_entries(boot_dir, &mut string)?; - let mut buffer = vec![]; - - buffer.write_all(get_efi_uuid_source().as_bytes())?; + grub_dir + .atomic_replace_with(USER_CFG_STAGED, move |f| -> std::io::Result<_> { + f.write_all(get_efi_uuid_source().as_bytes())?; - for entry in menuentries { - if entry.body.chainloader.contains(id) { - continue; - } + for entry in menuentries { + if entry.body.chainloader.contains(id) { + continue; + } - buffer.write_all(entry.to_string().as_bytes())?; - } + f.write_all(entry.to_string().as_bytes())?; + } - grub_dir - .atomic_write(USER_CFG_STAGED, buffer) + Ok(()) + }) .with_context(|| format!("Writing to {USER_CFG_STAGED}"))?; rustix::fs::fsync(grub_dir.reopen_as_ownedfd().context("Reopening")?).context("fsync")?; @@ -245,7 +252,8 @@ fn delete_depl_boot_entries(deployment: &DeploymentEntry, deleting_staged: bool) } } -pub(crate) fn get_image_objects(sysroot: &Dir, deployment_id: Option<&str>) -> Result { +#[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 @@ -256,10 +264,7 @@ pub(crate) fn get_image_objects(sysroot: &Dir, deployment_id: Option<&str>) -> R .entries_utf8() .context("Reading entries in images dir")?; - let mut object_refs = ObjectRefs { - other_depl: HashSet::new(), - depl_to_del: HashSet::new(), - }; + let mut object_refs = HashSet::new(); for image in image_entries { let image = image?; @@ -270,34 +275,33 @@ pub(crate) fn get_image_objects(sysroot: &Dir, deployment_id: Option<&str>) -> R .objects_for_image(&img_name) .with_context(|| format!("Getting objects for image {img_name}"))?; - if let Some(deployment_id) = deployment_id { - if deployment_id == img_name { - object_refs.depl_to_del.extend(objects); - } - } else { - object_refs.other_depl.extend(objects); - } + 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<()> { 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") } +#[fn_error_context::context("Deleting state directory for deployment {}", deployment_id)] pub(crate) fn delete_state_dir(sysroot: &Dir, deployment_id: &str) -> 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:?}")) } +#[fn_error_context::context("Deleting staged deployment")] pub(crate) fn delete_staged(staged: &Option) -> Result<()> { if staged.is_none() { tracing::debug!("No staged deployment"); @@ -305,17 +309,22 @@ 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 file {file:?}"); + tracing::debug!("Deleting staged deployment file: {file:?}"); std::fs::remove_file(file).context("Removing staged file")?; Ok(()) } -pub(crate) async fn delete_composefs_deployment(deployment_id: &str, delete: bool) -> Result<()> { +#[fn_error_context::context("Deleting composefs deployment {}", deployment_id)] +pub(crate) async fn delete_composefs_deployment(deployment_id: &str) -> Result<()> { let host = composefs_deployment_status().await?; let booted = host.require_composefs_booted()?; + if deployment_id == &booted.verity { + anyhow::bail!("Cannot delete currently booted deployment"); + } + let all_depls = host.all_composefs_deployments()?; let depl_to_del = all_depls @@ -338,15 +347,6 @@ pub(crate) async fn delete_composefs_deployment(deployment_id: &str, delete: boo composefs_rollback().await?; } - // For debugging, but maybe useful elsewhere? - if !delete { - return Ok(()); - } - - if deployment_id == &booted.verity { - anyhow::bail!("Cannot delete currently booted deployment"); - } - let kind = if depl_to_del.pinned { "pinned " } else if deleting_staged { diff --git a/crates/lib/src/bootc_composefs/gc.rs b/crates/lib/src/bootc_composefs/gc.rs index d938c3369..a6e0c31a4 100644 --- a/crates/lib/src/bootc_composefs/gc.rs +++ b/crates/lib/src/bootc_composefs/gc.rs @@ -24,6 +24,7 @@ use crate::{ spec::Bootloader, }; +#[fn_error_context::context("Listing EROFS images")] fn list_erofs_images(sysroot: &Dir) -> Result> { let images_dir = sysroot .open_dir("composefs/images") @@ -44,6 +45,7 @@ fn list_erofs_images(sysroot: &Dir) -> Result> { /// /// # Returns /// The fsverity of EROFS images corresponding to boot entries +#[fn_error_context::context("Listing bootloader entries")] fn list_bootloader_entries() -> Result> { let bootloader = get_bootloader()?; @@ -92,6 +94,7 @@ fn list_bootloader_entries() -> Result> { Ok(entries) } +#[fn_error_context::context("Listing state directories")] fn list_state_dirs(sysroot: &Dir) -> Result> { let state = sysroot .open_dir(STATE_DIR_RELATIVE) @@ -116,9 +119,13 @@ fn list_state_dirs(sysroot: &Dir) -> Result> { /// 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, None)?; + let obj_refs = get_image_objects(sysroot)?; // List all objects in the objects directory let objects_dir = sysroot @@ -141,8 +148,8 @@ pub(crate) fn gc_objects(sysroot: &Dir) -> Result<()> { 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.other_depl.contains(&id) { - tracing::trace!("Removed unreferenced object: {filename}"); + if !obj_refs.contains(&id) { + tracing::trace!("Deleting unreferenced object: {filename}"); entry .remove_file() @@ -164,6 +171,7 @@ 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() -> Result<()> { let host = composefs_deployment_status().await?; let booted_cfs = host.require_composefs_booted()?; @@ -207,8 +215,6 @@ pub(crate) async fn composefs_gc() -> Result<()> { .collect::>(); for verity in &state_img_diff { - tracing::debug!("Cleaning up orphaned state directory: {verity}"); - delete_staged(staged)?; delete_state_dir(&sysroot, verity)?; } diff --git a/crates/lib/src/bootc_composefs/rollback.rs b/crates/lib/src/bootc_composefs/rollback.rs index 8f103c256..0860f0489 100644 --- a/crates/lib/src/bootc_composefs/rollback.rs +++ b/crates/lib/src/bootc_composefs/rollback.rs @@ -1,4 +1,4 @@ -use std::fmt::Write; +use std::io::Write; use anyhow::{anyhow, Context, Result}; use cap_std_ext::cap_std::ambient_authority; @@ -92,16 +92,18 @@ fn rollback_grub_uki_entries(boot_dir: &Dir) -> Result<()> { let (first, second) = menuentries.split_at_mut(1); std::mem::swap(&mut first[0], &mut second[0]); - let mut buffer = get_efi_uuid_source(); - - for entry in menuentries { - write!(buffer, "{entry}")?; - } - let entries_dir = boot_dir.open_dir("grub2").context("Opening grub dir")?; entries_dir - .atomic_write(USER_CFG_STAGED, buffer) + .atomic_replace_with(USER_CFG_STAGED, |f| -> std::io::Result<_> { + f.write_all(get_efi_uuid_source().as_bytes())?; + + for entry in menuentries { + f.write_all(entry.to_string().as_bytes())?; + } + + Ok(()) + }) .with_context(|| format!("Writing to {USER_CFG_STAGED}"))?; rename_exchange_user_cfg(&entries_dir) diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 08cf25c30..3425c88af 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -675,11 +675,7 @@ pub(crate) enum Opt { /// Diff current /etc configuration versus default ConfigDiff, #[cfg(feature = "composefs-backend")] - DeleteDeployment { - depl_id: String, - #[clap(long, default_value_t)] - delete: bool, - }, + DeleteDeployment { depl_id: String }, } /// Ensure we've entered a mount namespace, so that we can remount @@ -1619,9 +1615,7 @@ async fn run_from_opt(opt: Opt) -> Result<()> { Opt::ConfigDiff => get_etc_diff().await, #[cfg(feature = "composefs-backend")] - Opt::DeleteDeployment { depl_id, delete } => { - delete_composefs_deployment(&depl_id, delete).await - } + Opt::DeleteDeployment { depl_id } => delete_composefs_deployment(&depl_id).await, } } From 152ef251513ccb71e7d701e172fb7f916b9a20bf Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Tue, 28 Oct 2025 16:42:10 +0530 Subject: [PATCH 5/5] Bump composefs-rs Signed-off-by: Pragyan Poudyal --- Cargo.lock | 6 +++--- Cargo.toml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8dc49313f..edf06b837 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -589,7 +589,7 @@ checksum = "55b672471b4e9f9e95499ea597ff64941a309b2cdbffcc46f2cc5e2d971fd335" [[package]] name = "composefs" version = "0.3.0" -source = "git+https://github.com/containers/composefs-rs?rev=82e211ee3e5c8e2b7b3bc00b40b828133633c41b#82e211ee3e5c8e2b7b3bc00b40b828133633c41b" +source = "git+https://github.com/containers/composefs-rs?rev=0f636031a1ec81cdd9e7f674909ef6b75c2642cb#0f636031a1ec81cdd9e7f674909ef6b75c2642cb" dependencies = [ "anyhow", "hex", @@ -609,7 +609,7 @@ dependencies = [ [[package]] name = "composefs-boot" version = "0.3.0" -source = "git+https://github.com/containers/composefs-rs?rev=82e211ee3e5c8e2b7b3bc00b40b828133633c41b#82e211ee3e5c8e2b7b3bc00b40b828133633c41b" +source = "git+https://github.com/containers/composefs-rs?rev=0f636031a1ec81cdd9e7f674909ef6b75c2642cb#0f636031a1ec81cdd9e7f674909ef6b75c2642cb" dependencies = [ "anyhow", "composefs", @@ -622,7 +622,7 @@ dependencies = [ [[package]] name = "composefs-oci" version = "0.3.0" -source = "git+https://github.com/containers/composefs-rs?rev=82e211ee3e5c8e2b7b3bc00b40b828133633c41b#82e211ee3e5c8e2b7b3bc00b40b828133633c41b" +source = "git+https://github.com/containers/composefs-rs?rev=0f636031a1ec81cdd9e7f674909ef6b75c2642cb#0f636031a1ec81cdd9e7f674909ef6b75c2642cb" dependencies = [ "anyhow", "async-compression", diff --git a/Cargo.toml b/Cargo.toml index 4c3f89f1c..ff0288f7a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,9 +41,9 @@ chrono = { version = "0.4.38", default-features = false } clap = "4.5.4" clap_mangen = { version = "0.2.20" } # Reviewers (including AI tools): This hash is duplicated as there's not a much better way to handle it right now -composefs = { git = "https://github.com/containers/composefs-rs", rev = "82e211ee3e5c8e2b7b3bc00b40b828133633c41b", package = "composefs", features = ["rhel9"] } -composefs-boot = { git = "https://github.com/containers/composefs-rs", rev = "82e211ee3e5c8e2b7b3bc00b40b828133633c41b", package = "composefs-boot" } -composefs-oci = { git = "https://github.com/containers/composefs-rs", rev = "82e211ee3e5c8e2b7b3bc00b40b828133633c41b", package = "composefs-oci" } +composefs = { git = "https://github.com/containers/composefs-rs", rev = "0f636031a1ec81cdd9e7f674909ef6b75c2642cb", package = "composefs", features = ["rhel9"] } +composefs-boot = { git = "https://github.com/containers/composefs-rs", rev = "0f636031a1ec81cdd9e7f674909ef6b75c2642cb", package = "composefs-boot" } +composefs-oci = { git = "https://github.com/containers/composefs-rs", rev = "0f636031a1ec81cdd9e7f674909ef6b75c2642cb", package = "composefs-oci" } fn-error-context = "0.2.1" hex = "0.4.3" indicatif = "0.18.0"