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" diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index ef704e68a..590c0c4a7 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, @@ -692,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")?; @@ -733,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 new file mode 100644 index 000000000..f7f3f71cb --- /dev/null +++ b/crates/lib/src/bootc_composefs/delete.rs @@ -0,0 +1,365 @@ +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::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, + }, + gc::composefs_gc, + 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::{BootEntry, Bootloader, DeploymentEntry}, + status::Slot, +}; + +#[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 { + 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 + tracing::debug!("Deleting EFI .conf file: {}", file_name); + entry.remove_file().context("Removing .conf file")?; + delete_uki(&depl.deployment.verity, boot_dir)?; + + 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; + } + + 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)?; + } + + break; + } + + BLSConfigType::Unknown => anyhow::bail!("Unknown BLS Config Type"), + } + } + + 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")?; + } + + 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")?; + + 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"); + } + + let mut string = String::new(); + let menuentries = get_sorted_grub_uki_boot_entries(boot_dir, &mut string)?; + + 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; + } + + f.write_all(entry.to_string().as_bytes())?; + } + + Ok(()) + }) + .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_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 => { + let boot_dir = Dir::open_ambient_dir("/sysroot/boot", ambient_authority()) + .context("Opening boot dir")?; + + match deployment.deployment.boot_type { + 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)?; + + remove_grub_menucfg_entry( + &deployment.deployment.verity, + &boot_dir, + deleting_staged, + )?; + + delete_uki(&deployment.deployment.verity, &esp_mount.fd) + } + } + } + + 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_entry(deployment, &esp_mount.fd, deleting_staged) + } + } +} + +#[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<()> { + 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"); + return Ok(()); + }; + + 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")?; + + Ok(()) +} + +#[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 + .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?; + } + + let kind = if depl_to_del.pinned { + "pinned " + } else if deleting_staged { + "staged " + } else { + "" + }; + + tracing::info!("Deleting {kind}deployment '{deployment_id}'"); + + delete_depl_boot_entries(&depl_to_del, deleting_staged)?; + + 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..a6e0c31a4 --- /dev/null +++ b/crates/lib/src/bootc_composefs/gc.rs @@ -0,0 +1,226 @@ +//! 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_error_context::context("Listing EROFS images")] +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_error_context::context("Listing bootloader 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_error_context::context("Listing state directories")] +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 +#[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 +/// 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 +#[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()?; + + 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 { + 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 ee8a742ed..a9ced452d 100644 --- a/crates/lib/src/bootc_composefs/mod.rs +++ b/crates/lib/src/bootc_composefs/mod.rs @@ -1,5 +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/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/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index 55708e002..51ab35f83 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -6,7 +6,8 @@ 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}, + 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") { @@ -227,6 +226,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 +239,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..3425c88af 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,8 @@ pub(crate) enum Opt { #[cfg(feature = "composefs-backend")] /// Diff current /etc configuration versus default ConfigDiff, + #[cfg(feature = "composefs-backend")] + DeleteDeployment { depl_id: String }, } /// Ensure we've entered a mount namespace, so that we can remount @@ -1609,6 +1613,9 @@ 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_composefs_deployment(&depl_id).await, } } 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. 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.