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.