diff --git a/Justfile b/Justfile index a33e9640f..ca3fada2f 100644 --- a/Justfile +++ b/Justfile @@ -117,7 +117,7 @@ test-container: build build-units podman run --rm --env=BOOTC_variant={{variant}} --env=BOOTC_base={{base}} --env=BOOTC_boot_type={{boot_type}} {{base_img}} bootc-integration-tests container [group('core')] -test-composefs bootloader filesystem boot_type seal_state: +test-composefs bootloader filesystem boot_type seal_state *ARGS: @if [ "{{seal_state}}" = "sealed" ] && [ "{{filesystem}}" = "xfs" ]; then \ echo "Invalid combination: sealed requires filesystem that supports fs-verity (ext4, btrfs)"; \ exit 1; \ @@ -138,6 +138,7 @@ test-composefs bootloader filesystem boot_type seal_state: --filesystem={{filesystem}} \ --seal-state={{seal_state}} \ --boot-type={{boot_type}} \ + {{ARGS}} \ $(if [ "{{boot_type}}" = "uki" ]; then echo "readonly"; else echo "integration"; fi) # Run cargo fmt and clippy checks in container diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index f64540c5b..ba38179b3 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -91,7 +91,6 @@ use rustix::{mount::MountFlags, path::Arg}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use crate::bootc_composefs::state::{get_booted_bls, write_composefs_state}; use crate::parsers::bls_config::{BLSConfig, BLSConfigType}; use crate::task::Task; use crate::{ @@ -102,6 +101,10 @@ use crate::{ bootc_composefs::repo::open_composefs_repo, store::{ComposefsFilesystem, Storage}, }; +use crate::{ + bootc_composefs::state::{get_booted_bls, write_composefs_state}, + composefs_consts::TYPE1_BOOT_DIR_PREFIX, +}; use crate::{ bootc_composefs::status::get_container_manifest_and_config, bootc_kargs::compute_new_kargs, }; @@ -126,8 +129,8 @@ pub(crate) const EFI_LINUX: &str = "EFI/Linux"; const SYSTEMD_TIMEOUT: &str = "timeout 5"; const SYSTEMD_LOADER_CONF_PATH: &str = "loader/loader.conf"; -const INITRD: &str = "initrd"; -const VMLINUZ: &str = "vmlinuz"; +pub(crate) const INITRD: &str = "initrd"; +pub(crate) const VMLINUZ: &str = "vmlinuz"; const BOOTC_AUTOENROLL_PATH: &str = "usr/lib/bootc/install/secureboot-keys"; @@ -137,7 +140,7 @@ const AUTH_EXT: &str = "auth"; /// directory specified by the BLS spec. We do this because we want systemd-boot to only look at /// our config files and not show the actual UKIs in the bootloader menu /// This is relative to the ESP -pub(crate) const SYSTEMD_UKI_DIR: &str = "EFI/Linux/bootc"; +pub(crate) const BOOTC_UKI_DIR: &str = "EFI/Linux/bootc"; pub(crate) enum BootSetupType<'a> { /// For initial setup, i.e. install to-disk @@ -270,6 +273,11 @@ pub(crate) fn secondary_sort_key(os_id: &str) -> String { format!("bootc-{os_id}-{SORTKEY_PRIORITY_SECONDARY}") } +/// Returns the name of the directory where we store Type1 boot entries +pub(crate) fn get_type1_dir_name(depl_verity: &String) -> String { + format!("{TYPE1_BOOT_DIR_PREFIX}{depl_verity}") +} + /// Compute SHA256Sum of VMlinuz + Initrd /// /// # Arguments @@ -381,10 +389,10 @@ fn write_bls_boot_entries_to_disk( entry: &UsrLibModulesVmlinuz, repo: &crate::store::ComposefsRepository, ) -> Result<()> { - let id_hex = deployment_id.to_hex(); + let dir_name = get_type1_dir_name(&deployment_id.to_hex()); - // Write the initrd and vmlinuz at /boot// - let path = boot_dir.join(&id_hex); + // Write the initrd and vmlinuz at /boot/composefs-/ + let path = boot_dir.join(&dir_name); create_dir_all(&path)?; let entries_dir = Dir::open_ambient_dir(&path, ambient_authority()) @@ -496,6 +504,7 @@ pub(crate) fn setup_composefs_bls_boot( cmdline_options.extend(&root_setup.kargs); + // TODO(Johan-Liebert1): Use ComposefsCmdline let composefs_cmdline = if state.composefs_options.allow_missing_verity { format!("{COMPOSEFS_CMDLINE}=?{id_hex}") } else { @@ -648,13 +657,18 @@ pub(crate) fn setup_composefs_bls_boot( let mut bls_config = BLSConfig::default(); + let entries_dir = get_type1_dir_name(&id_hex); + bls_config .with_title(title) .with_version(version) .with_sort_key(sort_key) .with_cfg(BLSConfigType::NonEFI { - linux: entry_paths.abs_entries_path.join(&id_hex).join(VMLINUZ), - initrd: vec![entry_paths.abs_entries_path.join(&id_hex).join(INITRD)], + linux: entry_paths + .abs_entries_path + .join(&entries_dir) + .join(VMLINUZ), + initrd: vec![entry_paths.abs_entries_path.join(&entries_dir).join(INITRD)], options: Some(cmdline_refs), }); @@ -679,7 +693,16 @@ pub(crate) fn setup_composefs_bls_boot( // We shouldn't error here as all our file names are UTF-8 compatible let ent_name = ent.file_name()?; - if shared_entries.contains(&ent_name) { + let Some(entry_verity_part) = ent_name.strip_prefix(TYPE1_BOOT_DIR_PREFIX) + else { + // Not our directory + continue; + }; + + if shared_entries + .iter() + .any(|shared_ent| shared_ent == entry_verity_part) + { shared_entry = Some(ent_name); break; } @@ -793,7 +816,6 @@ fn write_pe_to_esp( uki_id: &Sha512HashValue, missing_fsverity_allowed: bool, mounted_efi: impl AsRef, - bootloader: &Bootloader, ) -> Result> { let efi_bin = read_file(file, &repo).context("Reading .efi binary")?; @@ -843,14 +865,8 @@ fn write_pe_to_esp( }); } - // Write the UKI to ESP - let efi_linux_path = mounted_efi.as_ref().join(match bootloader { - Bootloader::Grub => EFI_LINUX, - Bootloader::Systemd => SYSTEMD_UKI_DIR, - Bootloader::None => unreachable!("Checked at install time"), - }); - - create_dir_all(&efi_linux_path).context("Creating EFI/Linux")?; + let efi_linux_path = mounted_efi.as_ref().join(BOOTC_UKI_DIR); + create_dir_all(&efi_linux_path).context("Creating bootc UKI directory")?; let final_pe_path = match file_path.parent() { Some(parent) => { @@ -1000,7 +1016,7 @@ fn write_systemd_uki_config( bls_conf .with_title(boot_label.boot_label) .with_cfg(BLSConfigType::EFI { - efi: format!("/{SYSTEMD_UKI_DIR}/{}{}", id.to_hex(), EFI_EXT).into(), + efi: format!("/{BOOTC_UKI_DIR}/{}{}", id.to_hex(), EFI_EXT).into(), }) .with_sort_key(primary_sort_key.clone()) .with_version(boot_label.version.unwrap_or_else(|| id.to_hex())); @@ -1143,7 +1159,6 @@ pub(crate) fn setup_composefs_uki_boot( &id, missing_fsverity_allowed, esp_mount.dir.path(), - &bootloader, )?; if let Some(label) = ret { diff --git a/crates/lib/src/bootc_composefs/delete.rs b/crates/lib/src/bootc_composefs/delete.rs index c6ee676ce..b64ba0173 100644 --- a/crates/lib/src/bootc_composefs/delete.rs +++ b/crates/lib/src/bootc_composefs/delete.rs @@ -1,15 +1,12 @@ -use std::{collections::HashSet, io::Write, path::Path}; +use std::{io::Write, path::Path}; use anyhow::{Context, Result}; use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt}; -use composefs::fsverity::Sha512HashValue; -use composefs_boot::bootloader::{EFI_ADDON_DIR_EXT, EFI_EXT}; use crate::{ bootc_composefs::{ - boot::{BootType, SYSTEMD_UKI_DIR, find_vmlinuz_initrd_duplicates, get_efi_uuid_source}, + boot::{BootType, get_efi_uuid_source}, gc::composefs_gc, - repo::open_composefs_repo, rollback::{composefs_rollback, rename_exchange_user_cfg}, status::{get_composefs_status, get_sorted_grub_uki_boot_entries}, }, @@ -24,7 +21,11 @@ use crate::{ }; #[fn_error_context::context("Deleting Type1 Entry {}", depl.deployment.verity)] -fn delete_type1_entry(depl: &DeploymentEntry, boot_dir: &Dir, deleting_staged: bool) -> Result<()> { +fn delete_type1_conf_file( + depl: &DeploymentEntry, + boot_dir: &Dir, + deleting_staged: bool, +) -> Result<()> { let entries_dir_path = if deleting_staged { TYPE1_ENT_PATH_STAGED } else { @@ -35,15 +36,6 @@ fn delete_type1_entry(depl: &DeploymentEntry, boot_dir: &Dir, deleting_staged: b .open_dir(entries_dir_path) .context("Opening entries dir")?; - // We reuse kernel + initrd if they're the same for two deployments - // We don't want to delete the (being deleted) deployment's kernel + initrd - // if it's in use by any other deployment - let should_del_kernel = match depl.deployment.boot_digest.as_ref() { - Some(digest) => find_vmlinuz_initrd_duplicates(digest)? - .is_some_and(|vec| vec.iter().any(|digest| *digest != depl.deployment.verity)), - None => false, - }; - for entry in entries_dir.entries_utf8()? { let entry = entry?; let file_name = entry.file_name()?; @@ -70,7 +62,6 @@ fn delete_type1_entry(depl: &DeploymentEntry, boot_dir: &Dir, deleting_staged: b // Boot dir in case of EFI will be the ESP tracing::debug!("Deleting EFI .conf file: {}", file_name); entry.remove_file().context("Removing .conf file")?; - delete_uki(&depl.deployment.verity, boot_dir)?; break; } @@ -87,10 +78,6 @@ fn delete_type1_entry(depl: &DeploymentEntry, boot_dir: &Dir, deleting_staged: b tracing::debug!("Deleting non-EFI .conf file: {}", file_name); entry.remove_file().context("Removing .conf file")?; - if should_del_kernel { - delete_kernel_initrd(&bls_config.cfg_type, boot_dir)?; - } - break; } @@ -103,6 +90,7 @@ fn delete_type1_entry(depl: &DeploymentEntry, boot_dir: &Dir, deleting_staged: b "Deleting staged entries directory: {}", TYPE1_ENT_PATH_STAGED ); + boot_dir .remove_dir_all(TYPE1_ENT_PATH_STAGED) .context("Removing staged entries dir")?; @@ -111,69 +99,6 @@ fn delete_type1_entry(depl: &DeploymentEntry, boot_dir: &Dir, deleting_staged: b Ok(()) } -#[fn_error_context::context("Deleting kernel and initrd")] -fn delete_kernel_initrd(bls_config: &BLSConfigType, boot_dir: &Dir) -> Result<()> { - let BLSConfigType::NonEFI { linux, initrd, .. } = bls_config else { - anyhow::bail!("Found EFI config") - }; - - // "linux" and "initrd" are relative to the boot_dir in our config files - tracing::debug!("Deleting kernel: {:?}", linux); - boot_dir - .remove_file(linux) - .with_context(|| format!("Removing {linux:?}"))?; - - for ird in initrd { - tracing::debug!("Deleting initrd: {:?}", ird); - boot_dir - .remove_file(ird) - .with_context(|| format!("Removing {ird:?}"))?; - } - - // Remove the directory if it's empty - // - // This shouldn't ever error as we'll never have these in root - let dir = linux - .parent() - .ok_or_else(|| anyhow::anyhow!("Bad path for vmlinuz {linux}"))?; - - let kernel_parent_dir = boot_dir.open_dir(&dir)?; - - if kernel_parent_dir.entries().iter().len() == 0 { - // We don't have anything other than kernel and initrd in this directory for now - // So this directory should *always* be empty, for now at least - tracing::debug!("Deleting empty kernel directory: {:?}", dir); - kernel_parent_dir.remove_open_dir()?; - }; - - Ok(()) -} - -/// Deletes the UKI `uki_id` and any addons specific to it -#[fn_error_context::context("Deleting UKI and UKI addons {uki_id}")] -fn delete_uki(uki_id: &str, esp_mnt: &Dir) -> Result<()> { - // TODO: We don't delete global addons here - let ukis = esp_mnt.open_dir(SYSTEMD_UKI_DIR)?; - - for entry in ukis.entries_utf8()? { - let entry = entry?; - let entry_name = entry.file_name()?; - - // The actual UKI PE binary - if entry_name == format!("{}{}", uki_id, EFI_EXT) { - tracing::debug!("Deleting UKI: {}", entry_name); - entry.remove_file().context("Deleting UKI")?; - } else if entry_name == format!("{}{}", uki_id, EFI_ADDON_DIR_EXT) { - // Addons dir - tracing::debug!("Deleting UKI addons directory: {}", entry_name); - ukis.remove_dir_all(entry_name) - .context("Deleting UKI addons dir")?; - } - } - - Ok(()) -} - #[fn_error_context::context("Removing Grub Menuentry")] fn remove_grub_menucfg_entry(id: &str, boot_dir: &Dir, deleting_staged: bool) -> Result<()> { let grub_dir = boot_dir.open_dir("grub2").context("Opening grub2")?; @@ -209,6 +134,9 @@ fn remove_grub_menucfg_entry(id: &str, boot_dir: &Dir, deleting_staged: bool) -> rename_exchange_user_cfg(&grub_dir) } +/// Deletes the .conf files in case for systemd-boot and Type1 bootloader entries for Grub +/// or removes the corresponding menuentry from Grub's user.cfg in case for grub UKI +/// Does not delete the actual boot binaries #[fn_error_context::context("Deleting boot entries for deployment {}", deployment.deployment.verity)] fn delete_depl_boot_entries( deployment: &DeploymentEntry, @@ -219,92 +147,71 @@ fn delete_depl_boot_entries( match deployment.deployment.bootloader { Bootloader::Grub => match deployment.deployment.boot_type { - BootType::Bls => delete_type1_entry(deployment, boot_dir, deleting_staged), - + BootType::Bls => delete_type1_conf_file(deployment, boot_dir, deleting_staged), BootType::Uki => { - let esp = storage - .esp - .as_ref() - .ok_or_else(|| anyhow::anyhow!("ESP not found"))?; - - remove_grub_menucfg_entry( - &deployment.deployment.verity, - boot_dir, - deleting_staged, - )?; - - delete_uki(&deployment.deployment.verity, &esp.fd) + remove_grub_menucfg_entry(&deployment.deployment.verity, boot_dir, deleting_staged) } }, Bootloader::Systemd => { // For Systemd UKI as well, we use .conf files - delete_type1_entry(deployment, boot_dir, deleting_staged) + delete_type1_conf_file(deployment, boot_dir, deleting_staged) } Bootloader::None => unreachable!("Checked at install time"), } } -#[fn_error_context::context("Getting image objects")] -pub(crate) fn get_image_objects(sysroot: &Dir) -> Result> { - let repo = open_composefs_repo(&sysroot)?; - - let images_dir = sysroot - .open_dir("composefs/images") - .context("Opening images dir")?; - - let image_entries = images_dir - .entries_utf8() - .context("Reading entries in images dir")?; - - let mut object_refs = HashSet::new(); - - for image in image_entries { - let image = image?; - - let img_name = image.file_name().context("Getting image name")?; - - let objects = repo - .objects_for_image(&img_name) - .with_context(|| format!("Getting objects for image {img_name}"))?; - - object_refs.extend(objects); - } - - Ok(object_refs) -} - #[fn_error_context::context("Deleting image for deployment {}", deployment_id)] -pub(crate) fn delete_image(sysroot: &Dir, deployment_id: &str) -> Result<()> { +pub(crate) fn delete_image(sysroot: &Dir, deployment_id: &str, dry_run: bool) -> Result<()> { let img_path = Path::new("composefs").join("images").join(deployment_id); - tracing::debug!("Deleting EROFS image: {:?}", img_path); + + if dry_run { + return Ok(()); + } + sysroot .remove_file(&img_path) .context("Deleting EROFS image") } #[fn_error_context::context("Deleting state directory for deployment {}", deployment_id)] -pub(crate) fn delete_state_dir(sysroot: &Dir, deployment_id: &str) -> Result<()> { +pub(crate) fn delete_state_dir(sysroot: &Dir, deployment_id: &str, dry_run: bool) -> Result<()> { let state_dir = Path::new(STATE_DIR_RELATIVE).join(deployment_id); - tracing::debug!("Deleting state directory: {:?}", state_dir); + + if dry_run { + return Ok(()); + } + 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() { +pub(crate) fn delete_staged( + staged: &Option, + cleanup_list: &Vec<&String>, + dry_run: bool, +) -> Result<()> { + let Some(staged_depl) = staged else { tracing::debug!("No staged deployment"); return Ok(()); }; + if !cleanup_list.contains(&&staged_depl.require_composefs()?.verity) { + tracing::debug!("Staged deployment not in cleanup list"); + return Ok(()); + } + let file = Path::new(COMPOSEFS_TRANSIENT_STATE_DIR).join(COMPOSEFS_STAGED_DEPLOYMENT_FNAME); - tracing::debug!("Deleting staged deployment file: {file:?}"); - std::fs::remove_file(file).context("Removing staged file")?; + + if !dry_run && file.exists() { + tracing::debug!("Deleting staged deployment file: {file:?}"); + std::fs::remove_file(file).context("Removing staged file")?; + } Ok(()) } @@ -368,7 +275,7 @@ pub(crate) async fn delete_composefs_deployment( delete_depl_boot_entries(&depl_to_del, &storage, deleting_staged)?; - composefs_gc(storage, booted_cfs).await?; + composefs_gc(storage, booted_cfs, true).await?; Ok(()) } diff --git a/crates/lib/src/bootc_composefs/digest.rs b/crates/lib/src/bootc_composefs/digest.rs index 0ddef9631..ecf12ed93 100644 --- a/crates/lib/src/bootc_composefs/digest.rs +++ b/crates/lib/src/bootc_composefs/digest.rs @@ -19,6 +19,7 @@ use crate::store::ComposefsRepository; /// /// Returns the TempDir guard (must be kept alive for the repo to remain valid) /// and the repository wrapped in Arc. +#[fn_error_context::context("Creating new temp composefs repo")] pub(crate) fn new_temp_composefs_repo() -> Result<(TempDir, Arc)> { let td_guard = tempfile::tempdir_in("/var/tmp")?; let td_path = td_guard.path(); @@ -49,6 +50,7 @@ pub(crate) fn new_temp_composefs_repo() -> Result<(TempDir, Arc, @@ -61,7 +63,8 @@ pub(crate) fn compute_composefs_digest( // Read filesystem from path, transform for boot, compute digest let mut fs = - composefs::fs::read_container_root(rustix::fs::CWD, path.as_std_path(), Some(&repo))?; + composefs::fs::read_container_root(rustix::fs::CWD, path.as_std_path(), Some(&repo)) + .context("Reading container root")?; fs.transform_for_boot(&repo).context("Preparing for boot")?; let id = fs.compute_image_id(); let digest = id.to_hex(); @@ -143,10 +146,12 @@ mod tests { fn test_compute_composefs_digest_rejects_root() { let result = compute_composefs_digest(Utf8Path::new("/"), None); assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!( - err.contains("Cannot operate on active root filesystem"), - "Unexpected error message: {err}" - ); + let err = result.unwrap_err(); + let found = err.chain().any(|e| { + e.to_string() + .contains("Cannot operate on active root filesystem") + }); + + assert!(found, "Unexpected error chain: {err:?}"); } } diff --git a/crates/lib/src/bootc_composefs/export.rs b/crates/lib/src/bootc_composefs/export.rs index f86392421..5cea0ddb9 100644 --- a/crates/lib/src/bootc_composefs/export.rs +++ b/crates/lib/src/bootc_composefs/export.rs @@ -28,14 +28,7 @@ pub async fn export_repo_to_image( let mut depl_verity = None; - for depl in host - .status - .booted - .iter() - .chain(host.status.staged.iter()) - .chain(host.status.rollback.iter()) - .chain(host.status.other_deployments.iter()) - { + for depl in host.list_deployments() { let img = &depl.image.as_ref().unwrap().image; // Not checking transport here as we'll be pulling from the repo anyway diff --git a/crates/lib/src/bootc_composefs/finalize.rs b/crates/lib/src/bootc_composefs/finalize.rs index 61f602fee..960534b06 100644 --- a/crates/lib/src/bootc_composefs/finalize.rs +++ b/crates/lib/src/bootc_composefs/finalize.rs @@ -123,10 +123,7 @@ pub(crate) async fn composefs_backend_finalize( let boot_dir = storage.require_boot_dir()?; - let esp_mount = storage - .esp - .as_ref() - .ok_or_else(|| anyhow::anyhow!("ESP not found"))?; + let esp_mount = storage.require_esp()?; // NOTE: Assuming here we won't have two bootloaders at the same time match booted_composefs.bootloader { diff --git a/crates/lib/src/bootc_composefs/gc.rs b/crates/lib/src/bootc_composefs/gc.rs index b277e522b..792b300ba 100644 --- a/crates/lib/src/bootc_composefs/gc.rs +++ b/crates/lib/src/bootc_composefs/gc.rs @@ -6,18 +6,17 @@ use anyhow::{Context, Result}; use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt}; -use composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; +use composefs::repository::GcResult; +use composefs_boot::bootloader::{EFI_ADDON_DIR_EXT, EFI_EXT}; use crate::{ + bootc_composefs::boot::get_type1_dir_name, bootc_composefs::{ - delete::{delete_image, delete_staged, delete_state_dir, get_image_objects}, - status::{ - get_bootloader, get_composefs_status, get_sorted_grub_uki_boot_entries, - get_sorted_type1_boot_entries, - }, + boot::{BOOTC_UKI_DIR, BootType}, + delete::{delete_image, delete_staged, delete_state_dir}, + status::{get_composefs_status, get_imginfo, list_bootloader_entries}, }, - composefs_consts::{STATE_DIR_RELATIVE, USER_CFG}, - spec::Bootloader, + composefs_consts::{STATE_DIR_RELATIVE, TYPE1_BOOT_DIR_PREFIX}, store::{BootedComposefs, Storage}, }; @@ -38,55 +37,6 @@ fn list_erofs_images(sysroot: &Dir) -> Result> { 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(storage: &Storage) -> Result> { - let bootloader = get_bootloader()?; - let boot_dir = storage.require_boot_dir()?; - - let entries = match bootloader { - Bootloader::Grub => { - // Grub entries are always in boot - let grub_dir = boot_dir.open_dir("grub2").context("Opening grub dir")?; - - 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 boot_entries = get_sorted_type1_boot_entries(boot_dir, true)?; - - boot_entries - .into_iter() - .map(|entry| entry.get_verity()) - .collect::, _>>()? - } - - Bootloader::None => unreachable!("Checked at install time"), - }; - - Ok(entries) -} - #[fn_error_context::context("Listing state directories")] fn list_state_dirs(sysroot: &Dir) -> Result> { let state = sysroot @@ -108,46 +58,119 @@ fn list_state_dirs(sysroot: &Dir) -> Result> { Ok(dirs) } -/// Deletes objects in sysroot/composefs/objects that are not being referenced by any of the -/// present EROFS images +type BootBinary = (BootType, String); + +/// Collect all BLS Type1 boot binaries and UKI binaries by scanning filesystem +/// +/// Returns a vector of binary type (UKI/Type1) + name of all boot binaries +#[fn_error_context::context("Collecting boot binaries")] +fn collect_boot_binaries(storage: &Storage) -> Result> { + let mut boot_binaries = Vec::new(); + let boot_dir = storage.require_boot_dir()?; + let esp = storage.require_esp()?; + + // Scan for UKI binaries in EFI/Linux/bootc + collect_uki_binaries(&esp.fd, &mut boot_binaries)?; + + // Scan for Type1 boot binaries (kernels + initrds) in `boot_dir` + // depending upon whether systemd-boot is being used, or grub + collect_type1_boot_binaries(&boot_dir, &mut boot_binaries)?; + + Ok(boot_binaries) +} + +/// Scan for UKI binaries in EFI/Linux/bootc +#[fn_error_context::context("Collecting UKI binaries")] +fn collect_uki_binaries(boot_dir: &Dir, boot_binaries: &mut Vec) -> Result<()> { + let Ok(Some(efi_dir)) = boot_dir.open_dir_optional(BOOTC_UKI_DIR) else { + return Ok(()); + }; + + for entry in efi_dir.entries_utf8()? { + let entry = entry?; + let name = entry.file_name()?; + + if name.ends_with(EFI_EXT) { + boot_binaries.push((BootType::Uki, name)); + } + } + + Ok(()) +} + +/// Scan for Type1 boot binaries (kernels + initrds) by looking for directories with +/// that start with bootc_composefs- /// -/// 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 { +/// Strips the prefix and returns the rest of the string +#[fn_error_context::context("Collecting Type1 boot binaries")] +fn collect_type1_boot_binaries(boot_dir: &Dir, boot_binaries: &mut Vec) -> Result<()> { + for entry in boot_dir.entries_utf8()? { + let entry = entry?; + let dir_name = entry.file_name()?; + + if !entry.file_type()?.is_dir() { + continue; + } + + let Some(verity) = dir_name.strip_prefix(TYPE1_BOOT_DIR_PREFIX) else { continue; }; - for entry in dir.entries_utf8()? { - let entry = entry?; - let filename = entry.file_name()?; + // The directory name starts with our custom prefix + boot_binaries.push((BootType::Bls, verity.to_string())); + } + + Ok(()) +} + +#[fn_error_context::context("Deleting kernel and initrd")] +fn delete_kernel_initrd(storage: &Storage, dir_to_delete: &str, dry_run: bool) -> Result<()> { + let boot_dir = storage.require_boot_dir()?; + + tracing::debug!("Deleting Type1 entry {dir_to_delete}"); + + if dry_run { + return Ok(()); + } + + boot_dir + .remove_dir_all(dir_to_delete) + .with_context(|| anyhow::anyhow!("Deleting {dir_to_delete}")) +} - let id = Sha512HashValue::from_object_dir_and_basename(dir_name, filename.as_bytes())?; +/// Deletes the UKI `uki_id` and any addons specific to it +#[fn_error_context::context("Deleting UKI and UKI addons {uki_id}")] +fn delete_uki(storage: &Storage, uki_id: &str, dry_run: bool) -> Result<()> { + let esp_mnt = storage.require_esp()?; - // If this object is not referenced by any image, delete it - if !obj_refs.contains(&id) { - tracing::trace!("Deleting unreferenced object: {filename}"); + // NOTE: We don't delete global addons here + // Which is fine as global addons don't belong to any single deployment + let uki_dir = esp_mnt.fd.open_dir(BOOTC_UKI_DIR)?; - entry - .remove_file() - .with_context(|| format!("Removing object {filename}"))?; + for entry in uki_dir.entries_utf8()? { + let entry = entry?; + let entry_name = entry.file_name()?; + + // The actual UKI PE binary + if entry_name == format!("{}{}", uki_id, EFI_EXT) { + tracing::debug!("Deleting UKI: {}", entry_name); + + if dry_run { + continue; } + + entry.remove_file().context("Deleting UKI")?; + } else if entry_name == format!("{}{}", uki_id, EFI_ADDON_DIR_EXT) { + // Addons dir + tracing::debug!("Deleting UKI addons directory: {}", entry_name); + + if dry_run { + continue; + } + + uki_dir + .remove_dir_all(entry_name) + .context("Deleting UKI addons dir")?; } } @@ -164,8 +187,18 @@ 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 +// +// Cases +// - BLS Entries +// - On upgrade/switch, if only two are left, the staged and the current, then no GC +// - If there are three - rollback, booted and staged, GC the rollback, so the current +// becomes rollback #[fn_error_context::context("Running composefs garbage collection")] -pub(crate) async fn composefs_gc(storage: &Storage, booted_cfs: &BootedComposefs) -> Result<()> { +pub(crate) async fn composefs_gc( + storage: &Storage, + booted_cfs: &BootedComposefs, + dry_run: bool, +) -> Result { const COMPOSEFS_GC_JOURNAL_ID: &str = "3b2a1f0e9d8c7b6a5f4e3d2c1b0a9f8e7"; tracing::info!( @@ -180,15 +213,58 @@ pub(crate) async fn composefs_gc(storage: &Storage, booted_cfs: &BootedComposefs let sysroot = &storage.physical_root; - let bootloader_entries = list_bootloader_entries(&storage)?; + let bootloader_entries = list_bootloader_entries(storage)?; + let boot_binaries = collect_boot_binaries(storage)?; + + tracing::debug!("bootloader_entries: {bootloader_entries:?}"); + + // Bootloader entry is deleted, but the binary (UKI/kernel+initrd) still exists + let unreferenced_boot_binaries = boot_binaries + .iter() + .filter(|bin_path| { + // We reuse kernel + initrd if they're the same for two deployments + // We don't want to delete the (being deleted) deployment's kernel + initrd + // if it's in use by any other deployment + // + // filter the ones that are not referenced by any bootloader entry + !bootloader_entries + .iter() + .any(|boot_entry| bin_path.1 == *boot_entry) + }) + .collect::>(); + + tracing::debug!("unreferenced_boot_binaries: {unreferenced_boot_binaries:?}"); + + if unreferenced_boot_binaries + .iter() + .find(|be| be.1 == booted_cfs_status.verity) + .is_some() + { + anyhow::bail!( + "Inconsistent state. Booted binaries '{}' found for cleanup", + booted_cfs_status.verity + ) + } + + for (ty, verity) in unreferenced_boot_binaries { + match ty { + BootType::Bls => delete_kernel_initrd(storage, &get_type1_dir_name(verity), dry_run)?, + BootType::Uki => delete_uki(storage, verity, dry_run)?, + } + } + let images = list_erofs_images(&sysroot)?; // Collect the deployments that have an image but no bootloader entry + // and vice versa let img_bootloader_diff = images .iter() .filter(|i| !bootloader_entries.contains(i)) + .chain(bootloader_entries.iter().filter(|b| !images.contains(b))) .collect::>(); + tracing::debug!("img_bootloader_diff: {img_bootloader_diff:#?}"); + let staged = &host.status.staged; if img_bootloader_diff.contains(&&booted_cfs_status.verity) { @@ -201,9 +277,9 @@ pub(crate) async fn composefs_gc(storage: &Storage, booted_cfs: &BootedComposefs for verity in &img_bootloader_diff { tracing::debug!("Cleaning up orphaned image: {verity}"); - delete_staged(staged)?; - delete_image(&sysroot, verity)?; - delete_state_dir(&sysroot, verity)?; + delete_staged(staged, &img_bootloader_diff, dry_run)?; + delete_image(&sysroot, verity, dry_run)?; + delete_state_dir(&sysroot, verity, dry_run)?; } let state_dirs = list_state_dirs(&sysroot)?; @@ -216,12 +292,39 @@ pub(crate) async fn composefs_gc(storage: &Storage, booted_cfs: &BootedComposefs .collect::>(); for verity in &state_img_diff { - delete_staged(staged)?; - delete_state_dir(&sysroot, verity)?; + delete_staged(staged, &state_img_diff, dry_run)?; + delete_state_dir(&sysroot, verity, dry_run)?; } + // Now we GC the unrefenced objects in composefs repo + let mut additional_roots = vec![]; + + for deployment in host.list_deployments() { + let verity = &deployment.require_composefs()?.verity; + + // These need to be GC'd + if img_bootloader_diff.contains(&verity) || state_img_diff.contains(&verity) { + continue; + } + + let image = get_imginfo(storage, verity, None).await?; + let stream = format!("oci-config-{}", image.manifest.config().digest()); + + additional_roots.push(verity.clone()); + additional_roots.push(stream); + } + + let additional_roots = additional_roots + .iter() + .map(|x| x.as_str()) + .collect::>(); + // Run garbage collection on objects after deleting images - gc_objects(&sysroot)?; + let gc_result = if dry_run { + booted_cfs.repo.gc_dry_run(&additional_roots)? + } else { + booted_cfs.repo.gc(&additional_roots)? + }; - Ok(()) + Ok(gc_result) } diff --git a/crates/lib/src/bootc_composefs/soft_reboot.rs b/crates/lib/src/bootc_composefs/soft_reboot.rs index b9e8a6e53..ce69eb5fb 100644 --- a/crates/lib/src/bootc_composefs/soft_reboot.rs +++ b/crates/lib/src/bootc_composefs/soft_reboot.rs @@ -1,7 +1,5 @@ use crate::{ - bootc_composefs::{ - service::start_finalize_stated_svc, status::composefs_deployment_status_from, - }, + bootc_composefs::{service::start_finalize_stated_svc, status::get_composefs_status}, cli::SoftRebootMode, composefs_consts::COMPOSEFS_CMDLINE, store::{BootedComposefs, Storage}, @@ -81,7 +79,7 @@ pub(crate) async fn prepare_soft_reboot_composefs( } // We definitely need to re-query the state as some deployment might've been staged - let host = composefs_deployment_status_from(storage, booted_cfs.cmdline).await?; + let host = get_composefs_status(storage, &booted_cfs).await?; let all_deployments = host.all_composefs_deployments()?; diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index 72ea5a58d..4a1f142f8 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -16,6 +16,7 @@ use crate::{ }, composefs_consts::{ COMPOSEFS_CMDLINE, ORIGIN_KEY_BOOT_DIGEST, TYPE1_ENT_PATH, TYPE1_ENT_PATH_STAGED, USER_CFG, + USER_CFG_STAGED, }, install::EFI_LOADER_INFO, parsers::{ @@ -145,14 +146,45 @@ pub(crate) fn composefs_booted() -> Result> { Ok(r.as_ref()) } -// Need str to store lifetime +/// Get the staged grub UKI menuentries +pub(crate) fn get_sorted_grub_uki_boot_entries_staged<'a>( + boot_dir: &Dir, + str: &'a mut String, +) -> Result>> { + get_sorted_grub_uki_boot_entries_helper(boot_dir, str, true) +} + +/// Get the grub UKI menuentries pub(crate) fn get_sorted_grub_uki_boot_entries<'a>( boot_dir: &Dir, str: &'a mut String, ) -> Result>> { - let mut file = boot_dir - .open(format!("grub2/{USER_CFG}")) - .with_context(|| format!("Opening {USER_CFG}"))?; + get_sorted_grub_uki_boot_entries_helper(boot_dir, str, false) +} + +// Need str to store lifetime +fn get_sorted_grub_uki_boot_entries_helper<'a>( + boot_dir: &Dir, + str: &'a mut String, + staged: bool, +) -> Result>> { + let file = if staged { + boot_dir + // As the staged entry might not exist + .open_optional(format!("grub2/{USER_CFG_STAGED}")) + .with_context(|| format!("Opening {USER_CFG_STAGED}"))? + } else { + let f = boot_dir + .open(format!("grub2/{USER_CFG}")) + .with_context(|| format!("Opening {USER_CFG}"))?; + + Some(f) + }; + + let Some(mut file) = file else { + return Ok(Vec::new()); + }; + file.read_to_string(str)?; parse_grub_menuentry_file(str) } @@ -224,6 +256,62 @@ fn get_sorted_type1_boot_entries_helper( Ok(all_configs) } +fn list_type1_entries(boot_dir: &Dir) -> Result> { + // Type1 Entry + let boot_entries = get_sorted_type1_boot_entries(boot_dir, true)?; + + // We wouldn't want to delete the staged deployment if the GC runs when a + // deployment is staged + let staged_boot_entries = get_sorted_staged_type1_boot_entries(boot_dir, true)?; + + boot_entries + .into_iter() + .chain(staged_boot_entries) + .map(|entry| entry.get_verity()) + .collect::, _>>() +} + +/// Get all Type1/Type2 bootloader entries +/// +/// # Returns +/// The fsverity of EROFS images corresponding to boot entries +#[fn_error_context::context("Listing bootloader entries")] +pub(crate) fn list_bootloader_entries(storage: &Storage) -> Result> { + let bootloader = get_bootloader()?; + let boot_dir = storage.require_boot_dir()?; + + let entries = match bootloader { + Bootloader::Grub => { + // Grub entries are always in boot + let grub_dir = boot_dir.open_dir("grub2").context("Opening grub dir")?; + + // Grub UKI + if grub_dir.exists(USER_CFG) { + let mut s = String::new(); + let boot_entries = get_sorted_grub_uki_boot_entries(boot_dir, &mut s)?; + + let mut staged = String::new(); + let boot_entries_staged = + get_sorted_grub_uki_boot_entries_staged(boot_dir, &mut staged)?; + + boot_entries + .into_iter() + .chain(boot_entries_staged) + .map(|entry| entry.get_verity()) + .collect::, _>>()? + } else { + list_type1_entries(boot_dir)? + } + } + + Bootloader::Systemd => list_type1_entries(boot_dir)?, + + Bootloader::None => unreachable!("Checked at install time"), + }; + + Ok(entries) +} + /// imgref = transport:image_name #[context("Getting container info")] pub(crate) async fn get_container_manifest_and_config( @@ -323,7 +411,7 @@ pub(crate) async fn get_imginfo( async fn boot_entry_from_composefs_deployment( storage: &Storage, origin: tini::Ini, - verity: String, + verity: &str, ) -> Result { let image = match origin.get::("origin", ORIGIN_CONTAINER) { Some(img_name_from_config) => { @@ -368,11 +456,11 @@ async fn boot_entry_from_composefs_deployment( cached_update: None, incompatible: false, pinned: false, - download_only: false, // Not yet supported for composefs backend + download_only: false, // Set later on store: None, ostree: None, composefs: Some(crate::spec::BootEntryComposefs { - verity, + verity: verity.into(), boot_type, bootloader: get_bootloader()?, boot_digest, @@ -600,7 +688,7 @@ fn set_reboot_capable_uki_deployments( } #[context("Getting composefs deployment status")] -pub(crate) async fn composefs_deployment_status_from( +async fn composefs_deployment_status_from( storage: &Storage, cmdline: &ComposefsCmdline, ) -> Result { @@ -608,10 +696,13 @@ pub(crate) async fn composefs_deployment_status_from( let boot_dir = storage.require_boot_dir()?; - let deployments = storage + // This is our source of truth + let bootloader_entry_verity = list_bootloader_entries(storage)?; + + let state_dir = storage .physical_root - .read_dir(STATE_DIR_RELATIVE) - .with_context(|| format!("Reading sysroot {STATE_DIR_RELATIVE}"))?; + .open_dir(STATE_DIR_RELATIVE) + .with_context(|| format!("Opening {STATE_DIR_RELATIVE}"))?; let host_spec = HostSpec { image: None, @@ -640,24 +731,19 @@ pub(crate) async fn composefs_deployment_status_from( // Rollback deployment is in here, but may also contain stale deployment entries let mut extra_deployment_boot_entries: Vec = Vec::new(); - for depl in deployments { - let depl = depl?; - - let depl_file_name = depl.file_name(); - let depl_file_name = depl_file_name.to_string_lossy(); - + for verity_digest in bootloader_entry_verity { // read the origin file - let config = depl - .open_dir() - .with_context(|| format!("Failed to open {depl_file_name}"))? - .read_to_string(format!("{depl_file_name}.origin")) - .with_context(|| format!("Reading file {depl_file_name}.origin"))?; + let config = state_dir + .open_dir(&verity_digest) + .with_context(|| format!("Failed to open {verity_digest}"))? + .read_to_string(format!("{verity_digest}.origin")) + .with_context(|| format!("Reading file {verity_digest}.origin"))?; let ini = tini::Ini::from_string(&config) - .with_context(|| format!("Failed to parse file {depl_file_name}.origin as ini"))?; + .with_context(|| format!("Failed to parse file {verity_digest}.origin as ini"))?; let mut boot_entry = - boot_entry_from_composefs_deployment(storage, ini, depl_file_name.to_string()).await?; + boot_entry_from_composefs_deployment(storage, ini, &verity_digest).await?; // SAFETY: boot_entry.composefs will always be present let boot_type_from_origin = boot_entry.composefs.as_ref().unwrap().boot_type; @@ -674,7 +760,7 @@ pub(crate) async fn composefs_deployment_status_from( } }; - if depl.file_name() == booted_composefs_digest.as_ref() { + if verity_digest == booted_composefs_digest.as_ref() { host.spec.image = boot_entry.image.as_ref().map(|x| x.image.clone()); host.status.booted = Some(boot_entry); continue; @@ -683,7 +769,7 @@ pub(crate) async fn composefs_deployment_status_from( if let Some(staged_deployment) = &staged_deployment { let staged_depl = serde_json::from_str::(&staged_deployment)?; - if depl_file_name == staged_depl.depl_id { + if verity_digest == staged_depl.depl_id { boot_entry.download_only = staged_depl.finalization_locked; host.status.staged = Some(boot_entry); continue; diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index bd2b2b2ce..0a7f1d8ec 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -11,6 +11,7 @@ use ostree_ext::container::ManifestDiff; use crate::{ bootc_composefs::{ boot::{BootSetupType, BootType, setup_composefs_bls_boot, setup_composefs_uki_boot}, + gc::composefs_gc, repo::{get_imgref, pull_composefs_repo}, service::start_finalize_stated_svc, soft_reboot::prepare_soft_reboot_composefs, @@ -302,6 +303,10 @@ pub(crate) async fn do_upgrade( ) .await?; + // We take into account the staged bootloader entries so this won't remove + // the currently staged entry + composefs_gc(storage, booted_cfs, false).await?; + apply_upgrade(storage, booted_cfs, &id.to_hex(), opts).await } diff --git a/crates/lib/src/bootc_composefs/utils.rs b/crates/lib/src/bootc_composefs/utils.rs index 0c3c0bb8b..0eff05f3a 100644 --- a/crates/lib/src/bootc_composefs/utils.rs +++ b/crates/lib/src/bootc_composefs/utils.rs @@ -1,6 +1,6 @@ use crate::{ bootc_composefs::{ - boot::{SYSTEMD_UKI_DIR, compute_boot_digest_uki}, + boot::{BOOTC_UKI_DIR, compute_boot_digest_uki}, state::update_boot_digest_in_origin, }, store::Storage, @@ -10,12 +10,7 @@ use bootc_kernel_cmdline::utf8::Cmdline; use fn_error_context::context; fn get_uki(storage: &Storage, deployment_verity: &str) -> Result> { - let uki_dir = storage - .esp - .as_ref() - .ok_or_else(|| anyhow::anyhow!("ESP not mounted"))? - .fd - .open_dir(SYSTEMD_UKI_DIR)?; + let uki_dir = storage.require_esp()?.fd.open_dir(BOOTC_UKI_DIR)?; let req_fname = format!("{deployment_verity}.efi"); diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index ddac010b0..eeee99358 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -34,6 +34,7 @@ use schemars::schema_for; use serde::{Deserialize, Serialize}; use crate::bootc_composefs::delete::delete_composefs_deployment; +use crate::bootc_composefs::gc::composefs_gc; use crate::bootc_composefs::soft_reboot::{prepare_soft_reboot_composefs, reset_soft_reboot}; use crate::bootc_composefs::{ digest::{compute_composefs_digest, new_temp_composefs_repo}, @@ -652,6 +653,10 @@ pub(crate) enum InternalsOpts { #[clap(long, conflicts_with = "reboot")] reset: bool, }, + ComposefsGC { + #[clap(long)] + dry_run: bool, + }, } #[derive(Debug, clap::Subcommand, PartialEq, Eq)] @@ -1901,6 +1906,37 @@ async fn run_from_opt(opt: Opt) -> Result<()> { } } } + InternalsOpts::ComposefsGC { dry_run } => { + let storage = &get_storage().await?; + + match storage.kind()? { + BootedStorageKind::Ostree(..) => { + anyhow::bail!("composefs-gc only works for composefs backend"); + } + + BootedStorageKind::Composefs(booted_cfs) => { + let gc_result = composefs_gc(storage, &booted_cfs, dry_run).await?; + + if dry_run { + println!("Dry run (no files deleted)"); + } + + println!( + "Objects: {} removed ({} bytes)", + gc_result.objects_removed, gc_result.objects_bytes + ); + + if gc_result.images_pruned > 0 || gc_result.streams_pruned > 0 { + println!( + "Pruned symlinks: {} images, {} streams", + gc_result.images_pruned, gc_result.streams_pruned + ); + } + + Ok(()) + } + } + } }, Opt::State(opts) => match opts { StateOpts::WipeOstree => { diff --git a/crates/lib/src/composefs_consts.rs b/crates/lib/src/composefs_consts.rs index 941fc6fed..fc14994fa 100644 --- a/crates/lib/src/composefs_consts.rs +++ b/crates/lib/src/composefs_consts.rs @@ -1,5 +1,3 @@ -#![allow(dead_code)] - /// composefs= parameter in kernel cmdline pub const COMPOSEFS_CMDLINE: &str = "composefs"; @@ -38,3 +36,6 @@ pub(crate) const TYPE1_ENT_PATH: &str = "loader/entries"; pub(crate) const TYPE1_ENT_PATH_STAGED: &str = "loader/entries.staged"; pub(crate) const BOOTC_FINALIZE_STAGED_SERVICE: &str = "bootc-finalize-staged.service"; + +/// The prefix for the directories containing kernel + initrd +pub(crate) const TYPE1_BOOT_DIR_PREFIX: &str = "bootc_composefs-"; diff --git a/crates/lib/src/parsers/grub_menuconfig.rs b/crates/lib/src/parsers/grub_menuconfig.rs index 6e5d0dc56..a1502af3a 100644 --- a/crates/lib/src/parsers/grub_menuconfig.rs +++ b/crates/lib/src/parsers/grub_menuconfig.rs @@ -15,6 +15,8 @@ use nom::{ sequence::delimited, }; +use crate::bootc_composefs::boot::BOOTC_UKI_DIR; + /// Body content of a GRUB menuentry containing parsed commands. #[derive(Debug, PartialEq, Eq)] pub(crate) struct MenuentryBody<'a> { @@ -95,7 +97,7 @@ impl<'a> MenuEntry<'a> { title: format!("{boot_label}: ({uki_id})"), body: MenuentryBody { insmod: vec!["fat", "chain"], - chainloader: format!("/EFI/Linux/{uki_id}.efi"), + chainloader: format!("/{BOOTC_UKI_DIR}/{uki_id}.efi"), search: "--no-floppy --set=root --fs-uuid \"${EFI_PART_UUID}\"", version: 0, extra: vec![], diff --git a/crates/lib/src/spec.rs b/crates/lib/src/spec.rs index e2426998b..bf920413f 100644 --- a/crates/lib/src/spec.rs +++ b/crates/lib/src/spec.rs @@ -430,6 +430,17 @@ impl Host { } } + /// Returns a vector of all deployments, i.e. staged, booted, rollback and other deployments + pub(crate) fn list_deployments(&self) -> Vec<&BootEntry> { + self.status + .staged + .iter() + .chain(self.status.booted.iter()) + .chain(self.status.rollback.iter()) + .chain(self.status.other_deployments.iter()) + .collect::>() + } + pub(crate) fn require_composefs_booted(&self) -> anyhow::Result<&BootEntryComposefs> { let cfs = self .status diff --git a/crates/lib/src/store/mod.rs b/crates/lib/src/store/mod.rs index a5f844256..778ce536c 100644 --- a/crates/lib/src/store/mod.rs +++ b/crates/lib/src/store/mod.rs @@ -368,6 +368,13 @@ impl Storage { .ok_or_else(|| anyhow::anyhow!("Boot dir not found")) } + /// Returns the mounted `esp` if it exists + pub(crate) fn require_esp(&self) -> Result<&TempMount> { + self.esp + .as_ref() + .ok_or_else(|| anyhow::anyhow!("ESP not found")) + } + /// Access the underlying ostree repository pub(crate) fn get_ostree(&self) -> Result<&SysrootLock> { self.ostree diff --git a/tmt/plans/integration.fmf b/tmt/plans/integration.fmf index ae6374690..9f88acdb6 100644 --- a/tmt/plans/integration.fmf +++ b/tmt/plans/integration.fmf @@ -182,8 +182,23 @@ execute: test: - /tmt/tests/tests/test-34-user-agent +/plan-35-composefs-gc: + summary: Test composefs garbage collection with same and different kernel+initrd + discover: + how: fmf + test: + - /tmt/tests/tests/test-35-composefs-gc + +/plan-35-upgrade-preflight-disk-check: + summary: Verify pre-flight disk space check rejects images with inflated layer sizes + discover: + how: fmf + test: + - /tmt/tests/tests/test-35-upgrade-preflight-disk-check + extra-fixme_skip_if_composefs: true + /plan-36-rollback: - summary: Test bootc rollback functionality through image switch and rollback cycle + summary: Test bootc rollback functionality discover: how: fmf test: diff --git a/tmt/tests/booted/test-composefs-gc.nu b/tmt/tests/booted/test-composefs-gc.nu new file mode 100644 index 000000000..1288e9965 --- /dev/null +++ b/tmt/tests/booted/test-composefs-gc.nu @@ -0,0 +1,165 @@ +# number: 35 +# tmt: +# summary: Test composefs garbage collection with same and different kernel+initrd +# duration: 30m + +use std assert +use tap.nu + +# bootc status +let st = bootc status --json | from json +let booted = $st.status.booted.image + +let dir_prefix = "bootc_composefs-" + +if not (tap is_composefs) or ($st.status.booted.composefs.bootType | str downcase) == "uki" { + exit 0 +} + +# Create a large file in a new container image, then bootc switch to the image +def first_boot [] { + bootc image copy-to-storage + + echo $" + FROM localhost/bootc + RUN dd if=/dev/zero of=/usr/share/large-test-file bs=1k count=1337 + RUN echo 'large-file-marker' | dd of=/usr/share/large-test-file conv=notrunc + " | podman build -t localhost/bootc-derived . -f - + + let current_time = (date now) + + bootc switch --transport containers-storage localhost/bootc-derived + + # Find the large file's verity and save it + # nu has its own built in find which sucks, so we use the other one + # TODO: Replace this with some concrete API + # See: https://github.com/composefs/composefs-rs/pull/236 + let file_path = ( + /usr/bin/find /sysroot/composefs/objects -type f -size 1337k -newermt ($current_time | format date "%Y-%m-%d %H:%M:%S") + | xargs grep -lx "large-file-marker" + ) + + echo $file_path | save /var/large-file-marker-objpath + cat /var/large-file-marker-objpath + + echo $st.status.booted.composefs.verity | save /var/first-verity + + tmt-reboot +} + +# Create a container image derived from the first boot image, but update the initrd +def second_boot [] { + assert equal $booted.image.image "localhost/bootc-derived" + + let path = cat /var/large-file-marker-objpath + + echo "\$path" + echo $path + + assert ($path | path exists) + + # Create another image with a different initrd so we can test kernel + initrd cleanup + + echo " + FROM localhost/bootc + + RUN echo 'echo hello' > /usr/bin/hello + RUN chmod +x /usr/bin/hello + + RUN mkdir /usr/lib/dracut/modules.d/99something + + RUN cat <<-EOF > /usr/lib/dracut/modules.d/99something/module-setup.sh + #!/usr/bin/bash + + check() { + return 0 + } + + depends() { + return 0 + } + + install() { + inst '/usr/bin/hello' /bin/hello + } + EOF + + RUN set -x; kver=$(cd /usr/lib/modules && echo *); dracut -vf --add bootc /usr/lib/modules/$kver/initramfs.img $kver; + " | lines | each { str trim } | str join "\n" | podman build -t localhost/bootc-derived-initrd . -f - + + bootc switch --transport containers-storage localhost/bootc-derived-initrd + + tmt-reboot +} + +# The large file should've been GC'd as we switched to an image derived from the original one +def third_boot [] { + assert equal $booted.image.image "localhost/bootc-derived-initrd" + + let path = cat /var/large-file-marker-objpath + assert (not ($"/sysroot/composefs/objects/($path)" | path exists)) + + # Also assert we have two different kernel + initrd pairs + let booted_verity = (bootc status --json | from json).status.booted.composefs.verity + + let bootloader = (bootc status --json | from json).status.booted.composefs.bootloader + + let boot_dir = if ($bootloader | str downcase) == "systemd" { + # TODO: Some concrete API for this would be great + mkdir /var/tmp/efi + mount /dev/vda2 /var/tmp/efi + "/var/tmp/efi/EFI/Linux" + } else { + "/sysroot/boot" + } + + print $"bootdir ($boot_dir)" + + assert ($"($boot_dir)/($dir_prefix)($booted_verity)" | path exists) + + # This is for the rollback, but since the rollback and the very + # first boot have the same kernel + initrd pair, and this rollback + # was deployed after the first boot, we will still be using the very + # first verity for the boot binary name + assert ($"($boot_dir)/($dir_prefix)(cat /var/first-verity)" | path exists) + + echo $"($boot_dir)/($dir_prefix)(cat /var/first-verity)" | save /var/to-be-deleted-kernel + + # Now we create a new image derived from the current kernel + initrd + # Switching to this and rebooting should remove the old kernel + initrd + echo " + FROM localhost/bootc-derived-initrd + RUN echo 'another file' > /usr/share/another-one + " | podman build -t localhost/bootc-final . -f - + + + bootc switch --transport containers-storage localhost/bootc-final + + tmt-reboot +} + +def fourth_boot [] { + let bootloader = (bootc status --json | from json).status.booted.composefs.bootloader + + if ($bootloader | str downcase) == "systemd" { + # TODO: Some concrete API for this would be great + mkdir /var/tmp/efi + mount /dev/vda2 /var/tmp/efi + } + + assert equal $booted.image.image "localhost/bootc-final" + assert (not ((cat /var/to-be-deleted-kernel | path exists))) + + tap ok +} + +def main [] { + match $env.TMT_REBOOT_COUNT? { + null | "0" => first_boot, + "1" => second_boot, + "2" => third_boot, + "3" => fourth_boot, + $o => { error make { msg: $"Invalid TMT_REBOOT_COUNT ($o)" } }, + } +} + diff --git a/tmt/tests/booted/test-upgrade-preflight-disk-check.nu b/tmt/tests/booted/test-upgrade-preflight-disk-check.nu index c3371193c..1fad2a8cf 100644 --- a/tmt/tests/booted/test-upgrade-preflight-disk-check.nu +++ b/tmt/tests/booted/test-upgrade-preflight-disk-check.nu @@ -2,6 +2,8 @@ # tmt: # summary: Verify pre-flight disk space check rejects images with inflated layer sizes # duration: 10m +# extra: +# fixme_skip_if_composefs: true # # This test does NOT require a reboot. # It constructs a minimal fake OCI image directory that claims to have an diff --git a/tmt/tests/tests.fmf b/tmt/tests/tests.fmf index 851d6b293..044376fae 100644 --- a/tmt/tests/tests.fmf +++ b/tmt/tests/tests.fmf @@ -102,13 +102,18 @@ duration: 10m test: python3 booted/test-user-agent.py +/test-35-composefs-gc: + summary: Test composefs garbage collection with same and different kernel+initrd + duration: 30m + test: nu booted/test-composefs-gc.nu + /test-35-upgrade-preflight-disk-check: summary: Verify pre-flight disk space check rejects images with inflated layer sizes - duration: 20m + duration: 10m test: nu booted/test-upgrade-preflight-disk-check.nu /test-36-rollback: - summary: Test bootc rollback functionality through image switch and rollback cycle + summary: Test bootc rollback functionality duration: 30m test: nu booted/test-rollback.nu