diff --git a/contrib/packaging/finalize-uki b/contrib/packaging/finalize-uki index 7c54f1e2e..b6774bf94 100755 --- a/contrib/packaging/finalize-uki +++ b/contrib/packaging/finalize-uki @@ -35,6 +35,17 @@ if [[ -f "${uki_src}/${kver}.dump" ]]; then cp "${uki_src}/${kver}.dump" /boot fi +# If we have a UKI Addon dir, add it +if [[ -d "${uki_src}/${kver}.efi.extra.d" ]]; then + cp -r "${uki_src}/${kver}.efi.extra.d" /boot/EFI/Linux +fi + +# If we have a global UKI Addon dir, add it +if [[ -d "${uki_src}/loader/addons" ]]; then + mkdir -p /boot/loader + cp -r "${uki_src}/loader/addons" /boot/loader +fi + # NOTE: We used to create a symlink from /usr/lib/modules/${kver}/${kver}.efi to the UKI # for tooling compatibility. However, composefs-boot's find_uki_components() doesn't # handle symlinks correctly and fails with "is not a regular file". The UKI is already diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 7723ed4da..624d214e5 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -94,12 +94,14 @@ use composefs_ctl::composefs_oci; use fn_error_context::context; use linux_kernel_cmdline::utf8::{Cmdline, Parameter, ParameterKey}; use ostree_ext::composefs::dumpfile; +use ostree_ext::composefs_boot::bootloader::Type2Entry; 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::bootc_composefs::status::build_composefs_karg; +use crate::bootc_composefs::uki_addon::{UkiAddonType, UkiAddonsList, list_installed_uki_addons}; use crate::bootc_kargs::compute_new_kargs; use crate::composefs_consts::{TYPE1_BOOT_DIR_PREFIX, TYPE1_ENT_PATH, TYPE1_ENT_PATH_STAGED}; use crate::parsers::bls_config::{BLSConfig, BLSConfigType, EFIKey}; @@ -118,7 +120,7 @@ use crate::{ }; use crate::{parsers::grub_menuconfig::MenuEntry, store::BootedComposefs}; -use crate::install::{BOOT, RootSetup, State}; +use crate::install::{BOOT, RootSetup, State, UkiAddonOpts}; /// Contains the EFP's filesystem UUID. Used by grub pub(crate) const EFI_UUID_FILE: &str = "efiuuid.cfg"; @@ -148,15 +150,12 @@ pub(crate) const BOOTC_UKI_DIR: &str = "EFI/Linux/bootc"; /// deployment, so they're neither namespaced by deployment verity nor cleaned up by GC. /// /// TODO: This directory is shared, unscoped machine state (any systemd-stub UKI on the -/// ESP will load whatever's here), but we currently treat it like deployment-owned -/// content: we blindly overwrite same-named files with no ownership tracking, we only -/// (re)install addons on `install` (not on upgrade, see `uki_addons` being hardcoded to -/// `None` for `BootSetupType::Upgrade` below), and GC never removes stale entries here. -/// Before recommending this feature for real use we should track which files here are -/// bootc-owned, reconcile that set on every upgrade (installing newly-selected addons, -/// removing ones we own that are no longer selected/present), and decide/document how -/// this interacts with deployment rollback (a global addon update isn't reverted by -/// rolling back to an older deployment). +/// ESP will load whatever's here). Addons are now (re)installed on upgrade/switch +/// (installed addons are auto-updated when the new image has a matching filename), +/// but we still blindly overwrite same-named (global) files with no ownership tracking. +/// Before recommending this feature for wider use we should track which global addon +/// files are bootc-owned, and decide/document how this interacts with deployment +/// rollback (a global addon update isn't reverted by rolling back to an older deployment). pub(crate) const GLOBAL_UKI_ADDONS_DIR: &str = "loader/addons"; #[derive(thiserror::Error, Debug)] @@ -216,6 +215,9 @@ pub(crate) fn print_uki_dumpfile_diff( let dumpfile_name = mismatch .uki_name() .and_then(|x| x.strip_suffix(EFI_EXT).map(|x| format!("{x}.dump"))); + // .uki_name + // .strip_suffix(EFI_EXT) + // .map(|x| format!("{x}.dump")); let Some(dumpfile_name) = &dumpfile_name else { return; @@ -317,7 +319,14 @@ pub(crate) enum BootSetupType<'a> { /// For initial setup, i.e. install to-disk Setup((&'a RootSetup, &'a State, &'a PostFetchState, bool)), /// For `bootc upgrade` - Upgrade((&'a Storage, &'a BootedComposefs, &'a Host)), + Upgrade( + ( + &'a Storage, + &'a BootedComposefs, + &'a Host, + Option<&'a UkiAddonOpts>, + ), + ), } impl BootSetupType<'_> { @@ -549,10 +558,18 @@ pub(crate) fn get_uki_addon_dir_name(depl_verity: &str) -> String { format!("{UKI_NAME_PREFIX}{depl_verity}{EFI_ADDON_DIR_EXT}") } -#[allow(dead_code)] -/// Returns the name of a UKI Addon given verity digest -pub(crate) fn get_uki_addon_file_name(depl_verity: &str) -> String { - format!("{UKI_NAME_PREFIX}{depl_verity}{EFI_ADDON_FILE_EXT}") +/// Returns the name of a scoped/local UKI Addon directory given name +/// with or without the `.addon.efi` prefix +pub(crate) fn get_scoped_uki_addon_name(name: &str) -> String { + let name_wo_suffix = name.strip_suffix(EFI_ADDON_FILE_EXT).unwrap_or(name); + format!("{name_wo_suffix}{EFI_ADDON_FILE_EXT}") +} + +/// Returns the name of a global UKI Addon directory given name +/// with or without the `.addon.efi` prefix +pub(crate) fn get_global_uki_addon_name(name: &str) -> String { + let name_wo_suffix = name.strip_suffix(EFI_ADDON_FILE_EXT).unwrap_or(name); + format!("{UKI_NAME_PREFIX}{name_wo_suffix}{EFI_ADDON_FILE_EXT}") } /// Compute SHA256Sum of VMlinuz + Initrd @@ -820,7 +837,7 @@ pub(crate) fn setup_composefs_bls_boot( ) } - BootSetupType::Upgrade((storage, booted_cfs, host)) => { + BootSetupType::Upgrade((storage, booted_cfs, host, _)) => { let bootloader = host.require_composefs_booted()?.bootloader.clone(); let boot_dir = storage.require_boot_dir()?; @@ -1056,7 +1073,7 @@ struct UKIInfo { version: Option, os_id: Option, boot_digest: String, - composefs_digest: Sha512HashValue, + composefs_digest: Option, } /// The EROFS format a UKI composefs kernel argument claims for its digest. @@ -1276,106 +1293,160 @@ fn pe_output_dir( } } -/// Writes a PortableExecutable to ESP along with any PE specific or Global addons -#[context("Writing {file_path} to ESP")] -fn write_pe_to_esp( +/// Return a UKI/UKI Addon as a file descriptor from a `Type2Entry` boot entry +fn file_from_type2_entry( repo: &crate::store::ComposefsRepository, - file: &RegularFile, - file_path: &Utf8Path, - pe_type: PEType, - uki_id: &Sha512HashValue, - boot_ids: &ExpectedBootImageIds, - missing_fsverity_allowed: bool, - mounted_efi: impl AsRef, -) -> Result> { - let mut uki_reader = match file { + entry: &Type2Entry, +) -> Result { + match &entry.file { RegularFile::Inline(..) => { // UKI/Addons would always be large enough to be an external object anyhow::bail!("File too small to be UKI/Addon") } RegularFile::External(id, ..) | RegularFile::ExternalNoVerity(id, ..) => { - std::fs::File::from(repo.open_object(id)?) + Ok(std::fs::File::from(repo.open_object(id)?)) } RegularFile::Sparse(..) => { anyhow::bail!("Sparse file cannot be a UKI/Addon") } - }; - - let mut boot_label: Option = None; + } +} - // UKI Extension might not even have a cmdline - // TODO: UKI Addon might also have a composefs= cmdline? +#[context("Parsing UKI cmdline from {uki_name}")] +/// Makes sure there is no composefs= cmdline in a global UKI Addon +/// Makes sure if we already have a parsed cmdline, we don't find another one +fn parse_uki_cmdline( + pe_type: &PEType, + missing_fsverity_allowed: bool, + boot_ids: &ExpectedBootImageIds, + uki_reader: &mut R, + uki_name: &str, + uki_info: &mut UKIInfo, +) -> Result<()> { + // We expect this in the UKI itself and not in addons if matches!(pe_type, PEType::Uki) { - let cmdline = uki::get_cmdline_buffered(&mut uki_reader).context("Getting UKI cmdline")?; + let osrel = uki::get_text_section_buffered(uki_reader, ".osrel")?; - let composefs_candidates = parse_uki_composefs_candidates(&cmdline) - .context("Parsing composefs kernel arguments")?; - let missing_verity_allowed_cmdline = uki_candidates_policy(&composefs_candidates)?; - let composefs_digest = primary_uki_candidate(&composefs_candidates).digest.clone(); + let parsed_osrel = OsReleaseInfo::parse(&osrel); - anyhow::ensure!( - !missing_verity_allowed_cmdline || missing_fsverity_allowed, - "The UKI requests insecure composefs operation, but this repository requires fs-verity. Use --allow-missing-verity only when missing fs-verity is explicitly supported for this install." - ); + uki_reader.seek(SeekFrom::Start(0))?; + let boot_digest = compute_boot_digest_uki(uki_reader)?; - // If the UKI cmdline does not match what the user has passed as cmdline option - // NOTE: This will only be checked for new installs and now upgrades/switches - match missing_fsverity_allowed { - true if !missing_verity_allowed_cmdline => { - tracing::warn!( - "--allow-missing-verity passed as option but UKI cmdline does not support it" - ); - } + uki_reader.seek(SeekFrom::Start(0))?; - false if missing_verity_allowed_cmdline => { - tracing::warn!("UKI cmdline has composefs set as insecure"); - } + uki_info.boot_label = + uki::get_boot_label_buffered(uki_reader).context("Getting UKI boot label")?; + uki_info.version = parsed_osrel.get_version(); + uki_info.os_id = parsed_osrel.get_value(&["ID"]); + uki_info.boot_digest = boot_digest; - _ => { /* no-op */ } + uki_reader.seek(SeekFrom::Start(0))?; + } + + // UKI Addon might not even have a cmdline + let cmdline = uki::get_cmdline_buffered(uki_reader); + + let cmdline_str = match cmdline { + Ok(ref cmdline) => cmdline, + Err(uki::UkiError::MissingSection(..)) => { + // No .cmdline section here + // We might find it in another PE binary + return Ok(()); } + Err(e) => { + return Err(e).context("Getting UKI cmdline"); + } + }; - validate_uki_candidates( - &composefs_candidates, - boot_ids, - file_path.file_name().map(|name| name.to_string()), - )?; + tracing::debug!("cmdline found in {pe_type:?}: {cmdline_str}"); - uki_reader.seek(SeekFrom::Start(0))?; - let osrel = uki::get_text_section_buffered(&mut uki_reader, ".osrel")?; + let composefs_candidates = parse_uki_composefs_candidates(&cmdline_str) + .context("Parsing composefs kernel arguments")?; - let parsed_osrel = OsReleaseInfo::parse(&osrel); + // We could have a cmdline in an Addon, so don't early error out if we don't find it + // immediately + if composefs_candidates.is_empty() { + return Ok(()); + }; - uki_reader.seek(SeekFrom::Start(0))?; - let boot_digest = compute_boot_digest_uki(&mut uki_reader)?; + // Make sure there's no composefs= in a global UKI Addon + if matches!(pe_type, PEType::GlobalUkiAddon) { + anyhow::bail!("Composefs cmdline {cmdline:?} found in a Global UKI Addon"); + } - uki_reader.seek(SeekFrom::Start(0))?; - boot_label = Some(UKIInfo { - boot_label: uki::get_boot_label_buffered(&mut uki_reader) - .context("Getting UKI boot label")?, - version: parsed_osrel.get_version(), - os_id: parsed_osrel.get_value(&["ID"]), - boot_digest, - composefs_digest, - }); + let missing_verity_allowed_cmdline = uki_candidates_policy(&composefs_candidates)?; + let composefs_digest = primary_uki_candidate(&composefs_candidates).digest.clone(); + + // Already found a cmdline, outright refuse another cmdline found in an addon + // or otherwise, even if they're the same + if let Some(found_cmdline) = &uki_info.composefs_digest { + anyhow::bail!("Already had cmdline {found_cmdline:?}, found another {composefs_digest:?}"); + }; + + anyhow::ensure!( + !missing_verity_allowed_cmdline || missing_fsverity_allowed, + "The UKI requests insecure composefs operation, but this repository requires fs-verity. + Use --allow-missing-verity only when missing fs-verity is explicitly supported for this install." + ); + + // If the UKI cmdline does not match what the user has passed as cmdline option + // NOTE: This will only be checked for new installs and now upgrades/switches + match missing_fsverity_allowed { + true if !missing_verity_allowed_cmdline => { + tracing::warn!( + "--allow-missing-fsverity passed as option but UKI cmdline does not support it" + ); + } + + false if missing_verity_allowed_cmdline => { + tracing::warn!("UKI cmdline has composefs set as insecure"); + } + + _ => { /* no-op */ } } - let final_pe_path = pe_output_dir(&pe_type, mounted_efi.as_ref(), file_path, uki_id); + validate_uki_candidates(&composefs_candidates, boot_ids, Some(uki_name.into()))?; + + uki_info.composefs_digest = Some(composefs_digest); + + Ok(()) +} + +/// Writes a PortableExecutable to ESP along with any PE specific or Global addons +#[context("Writing {} to ESP", entry.file_path.display())] +fn write_pe_to_esp( + repo: &crate::store::ComposefsRepository, + entry: &Type2Entry, + uki_id: &Sha512HashValue, + mounted_efi: impl AsRef, +) -> Result<()> { + let mut uki_reader = file_from_type2_entry(repo, entry)?; + + let file_path = Utf8Path::from_path(&entry.file_path) + .ok_or_else(|| anyhow::anyhow!("Path is not valid UTf8"))?; + + let final_pe_path = pe_output_dir(&entry.pe_type, mounted_efi.as_ref(), file_path, uki_id); create_dir_all(&final_pe_path).with_context(|| format!("Creating {final_pe_path:?}"))?; let pe_dir = Dir::open_ambient_dir(&final_pe_path, ambient_authority()) .with_context(|| format!("Opening {final_pe_path:?}"))?; - let pe_name_owned; - let pe_name = match pe_type { - PEType::Uki => { - pe_name_owned = get_uki_name(&boot_label.as_ref().unwrap().composefs_digest.to_hex()); - &pe_name_owned - } + let pe_name = match entry.pe_type { + PEType::Uki => get_uki_name(&uki_id.to_hex()), PEType::UkiAddon | PEType::GlobalUkiAddon => file_path .components() .last() .ok_or_else(|| anyhow::anyhow!("Failed to get UKI Addon file name"))? - .as_str(), + .to_string(), + }; + + // Prefix global Uki Addons for identification + let pe_name = if matches!(entry.pe_type, PEType::GlobalUkiAddon) { + get_global_uki_addon_name(&pe_name) + } else if matches!(entry.pe_type, PEType::UkiAddon) { + get_scoped_uki_addon_name(&pe_name) + } else { + pe_name }; uki_reader.seek(SeekFrom::Start(0))?; @@ -1390,7 +1461,7 @@ fn write_pe_to_esp( ) .context("fsync")?; - Ok(boot_label) + Ok(()) } fn uki_file_name(file_path: &Path) -> Result { @@ -1775,44 +1846,145 @@ pub(crate) fn setup_composefs_uki_boot( boot_ids: &ExpectedBootImageIds, entries: Vec>, ) -> Result<(String, Sha512HashValue)> { - let (esp_device, bootloader, missing_fsverity_allowed, uki_addons) = match setup_type { + let ( + esp_device, + bootloader, + missing_fsverity_allowed, + uki_addons_via_cli, + installed_uki_addons, + ) = match setup_type { BootSetupType::Setup((root_setup, state, postfetch, allow_missing_fsverity)) => { state.require_no_kargs_for_uki()?; // Locate ESP partition device by walking up to the root disk(s) let esp_part = root_setup.device_info.find_first_colocated_esp()?; + let mut addons: Vec = vec![]; + + let addon_opts = &state.composefs_options.uki_addon_opts; + + for addon in addon_opts.scoped.iter().flatten() { + addons.push(UkiAddonsList { + name: addon.into(), + addon_type: UkiAddonType::Scoped { + depl_id: id.to_hex(), + }, + }); + } + + for addon in addon_opts.global.iter().flatten() { + addons.push(UkiAddonsList { + name: addon.into(), + addon_type: UkiAddonType::Global, + }); + } + ( esp_part.path(), postfetch.detected_bootloader.clone(), allow_missing_fsverity, - state.composefs_options.uki_addon.as_ref(), + addons, + vec![], ) } - BootSetupType::Upgrade((storage, booted_cfs, host)) => { + BootSetupType::Upgrade((storage, booted_cfs, host, uki_addon_opts)) => { let bootloader = host.require_composefs_booted()?.bootloader.clone(); // Locate ESP partition device by walking up to the root disk(s) let root_dev = bootc_blockdev::list_dev_by_dir(&storage.physical_root)?; let esp_dev = root_dev.find_first_colocated_esp()?; + // These are the currently installed addons which we will update automatically + // if we find in the new image + // + // Only keep addons referenced by the current deployment + let installed_addons = list_installed_uki_addons(storage)? + .into_iter() + .filter(|addon| match &addon.addon_type { + UkiAddonType::Scoped { depl_id } => *depl_id == *booted_cfs.cmdline.digest, + UkiAddonType::Global => true, + }) + .collect::>(); + + let target_depl_id = id.to_hex(); + + let mut addons_via_cli: Vec = vec![]; + + if let Some(addons) = &uki_addon_opts { + for addon in addons.global.iter().flatten() { + addons_via_cli.push(UkiAddonsList { + name: addon.into(), + addon_type: UkiAddonType::Global, + }); + } + + for addon in addons.scoped.iter().flatten() { + addons_via_cli.push(UkiAddonsList { + name: addon.into(), + addon_type: UkiAddonType::Scoped { + depl_id: target_depl_id.clone(), + }, + }); + } + } + ( esp_dev.path(), bootloader, booted_cfs.cmdline.allow_missing_fsverity, - // TODO: We never (re)install UKI addons on upgrade, only on initial - // `install`. This is especially relevant for global addons (see the - // TODO on `GLOBAL_UKI_ADDONS_DIR`): if a newer image changes or drops - // one, the ESP copy is never reconciled. - None, + addons_via_cli, + installed_addons, ) } }; + // An addon requested explicitly on the CLI must exist in the image; bail + // immediately if one is missing, before we touch the ESP. Addons carried + // forward on upgrade/switch may be absent from the new image, which is fine. + for addon in &uki_addons_via_cli { + let is_global = matches!(addon.addon_type, UkiAddonType::Global); + + let found = entries.iter().any(|entry| { + let ComposefsBootEntry::Type2(entry) = entry else { + return false; + }; + + if matches!(entry.pe_type, PEType::Uki) { + return false; + } + + let entry_is_global = matches!(entry.pe_type, PEType::GlobalUkiAddon); + + entry_is_global == is_global + && entry + .file_path + .file_name() + .and_then(|n| n.to_str()) + .and_then(|n| n.strip_suffix(EFI_ADDON_FILE_EXT)) + == Some(addon.name.as_str()) + }); + + if !found { + let kind = if is_global { "global" } else { "scoped" }; + anyhow::bail!( + "Requested {kind} UKI addon '{}' was not found in the image", + addon.name + ); + } + } + let esp_mount = mount_esp_writable(&esp_device).context("Mounting ESP")?; - let mut uki_info: Option = None; + let mut uki_info = UKIInfo { + boot_label: "".into(), + version: None, + os_id: None, + boot_digest: "".into(), + composefs_digest: None, + }; + + let mut entries_to_write: Vec> = vec![]; for entry in entries { match entry { @@ -1825,10 +1997,6 @@ pub(crate) fn setup_composefs_uki_boot( // If --uki-addon is not passed, we don't install any addon (whether // it's scoped to this UKI or a global one) if matches!(entry.pe_type, PEType::UkiAddon | PEType::GlobalUkiAddon) { - let Some(addons) = uki_addons else { - continue; - }; - let addon_name = entry .file_path .components() @@ -1839,46 +2007,83 @@ pub(crate) fn setup_composefs_uki_boot( let addon_name = addon_name.strip_suffix(EFI_ADDON_FILE_EXT).ok_or_else(|| { - anyhow::anyhow!("UKI addon doesn't end with {EFI_ADDON_DIR_EXT}") + anyhow::anyhow!("UKI addon doesn't end with {EFI_ADDON_FILE_EXT}") })?; - if !addons.iter().any(|passed_addon| passed_addon == addon_name) { - continue; + match entry.pe_type { + PEType::Uki => unreachable!("Outer match should've only caught UKI Addons"), + PEType::UkiAddon => { + let found = uki_addons_via_cli.iter().chain(&installed_uki_addons).any( + |addon| { + matches!(addon.addon_type, UkiAddonType::Scoped { .. }) + && addon.name == addon_name + }, + ); + + if !found { + tracing::info!("Not installing found UKI Addon: {addon_name}"); + continue; + } + } + PEType::GlobalUkiAddon => { + let found = uki_addons_via_cli.iter().chain(&installed_uki_addons).any( + |addon| { + matches!(addon.addon_type, UkiAddonType::Global) + && addon.name == addon_name + }, + ); + + if !found { + tracing::info!( + "Not installing found global UKI Addon: {addon_name}" + ); + continue; + } + } } } let utf8_file_path = Utf8Path::from_path(&entry.file_path) .ok_or_else(|| anyhow::anyhow!("Path is not valid UTf8"))?; - let ret = write_pe_to_esp( - &repo, - &entry.file, - utf8_file_path, - entry.pe_type, - &id, - boot_ids, + let mut uki_reader = file_from_type2_entry(repo, &entry)?; + + parse_uki_cmdline( + &entry.pe_type, missing_fsverity_allowed, - esp_mount.dir.path(), + boot_ids, + &mut uki_reader, + utf8_file_path + .file_name() + .context("Filename not found for PE binary")?, + &mut uki_info, )?; - if let Some(label) = ret { - uki_info = Some(label); - } + entries_to_write.push(entry); } }; } - let uki_info = - uki_info.ok_or_else(|| anyhow::anyhow!("Failed to get version and boot label from UKI"))?; + let Some(deploy_id) = uki_info.composefs_digest else { + anyhow::bail!("No composefs cmdline found in UKI or UKI Addons"); + }; + + if uki_info.boot_label.is_empty() { + anyhow::bail!("Failed to get boot label from UKI"); + } let UKIInfo { boot_label, version, os_id, boot_digest, - composefs_digest: deploy_id, + .. } = uki_info; + for entry in entries_to_write { + write_pe_to_esp(repo, &entry, &deploy_id, esp_mount.dir.path())?; + } + match bootloader.kind()? { BootloaderKind::GRUBClassic => { write_grub_uki_menuentry(&setup_type, boot_label, &deploy_id, &esp_device)? diff --git a/crates/lib/src/bootc_composefs/gc.rs b/crates/lib/src/bootc_composefs/gc.rs index 06fc1f090..6dda7d017 100644 --- a/crates/lib/src/bootc_composefs/gc.rs +++ b/crates/lib/src/bootc_composefs/gc.rs @@ -17,6 +17,11 @@ use ostree_ext::composefs_oci::linked_erofs_images; use rustix::fs::AtFlags; use rustix::fs::{statat, unlinkat}; +use crate::bootc_composefs::boot::GLOBAL_UKI_ADDONS_DIR; +use crate::bootc_composefs::boot::get_global_uki_addon_name; +use crate::bootc_composefs::uki_addon::UkiAddonType; +use crate::bootc_composefs::uki_addon::list_installed_uki_addons; +use crate::bootc_composefs::uki_addon::list_referenced_uki_addons; use crate::{ bootc_composefs::{ boot::{BOOTC_UKI_DIR, BootType, get_type1_dir_name, get_uki_addon_dir_name, get_uki_name}, @@ -157,9 +162,6 @@ fn delete_kernel_initrd(storage: &Storage, dir_to_delete: &str, dry_run: bool) - fn delete_uki(storage: &Storage, uki_id: &str, dry_run: bool) -> Result<()> { let esp_mnt = storage.require_esp()?; - // NOTE: We don't delete global addons here (see `GLOBAL_UKI_ADDONS_DIR`) - // Which is fine as global addons don't belong to any single deployment, but it also - // means they're never cleaned up at all: see the TODO on `GLOBAL_UKI_ADDONS_DIR`. let uki_dir = esp_mnt.fd.open_dir(BOOTC_UKI_DIR)?; for entry in uki_dir.entries_utf8()? { @@ -306,7 +308,7 @@ pub(crate) async fn composefs_gc( ) } - for (ty, verity) in unreferenced_boot_binaries { + for (ty, verity) in &unreferenced_boot_binaries { match ty { BootType::Bls => { delete_kernel_initrd(storage, &get_type1_dir_name(verity), gc_opts.dry_run)? @@ -315,6 +317,55 @@ pub(crate) async fn composefs_gc( } } + // Remove unreferenced global UKI Addons + let currently_referenced_addons = list_referenced_uki_addons(booted_cfs, &bootloader_entries)?; + let currently_installed_addons = list_installed_uki_addons(storage)?; + let mut unreferenced_global_addons = vec![]; + + tracing::debug!("currently_referenced_addons: {currently_referenced_addons:#?}"); + tracing::debug!("currently_installed_addons: {currently_installed_addons:#?}"); + + for installed_addon in ¤tly_installed_addons { + // We handle scoped UKI Addons along with the UKI itself + if installed_addon.addon_type != UkiAddonType::Global { + continue; + } + + let is_referenced = currently_referenced_addons.iter().any(|(_, refs)| { + refs.iter().any(|r| { + r.addon_type == installed_addon.addon_type && r.name == installed_addon.name + }) + }); + + if !is_referenced { + unreferenced_global_addons.push(installed_addon.name.clone()); + } + } + + tracing::debug!("Unreferenced Global Addons: {unreferenced_global_addons:?}"); + + if !unreferenced_global_addons.is_empty() { + let global_uki_dir = storage + .require_esp()? + .fd + .open_dir(GLOBAL_UKI_ADDONS_DIR) + .context("Opening global UKI Addons dir")?; + + for addon in &unreferenced_global_addons { + let global_addon_name = get_global_uki_addon_name(&addon); + + tracing::debug!("Deleting Global UKI Addon: {}", addon); + + if gc_opts.dry_run { + continue; + } + + global_uki_dir + .remove_file(&global_addon_name) + .with_context(|| format!("Removing global addon {global_addon_name}"))?; + } + } + if !gc_opts.prune_repo { return Ok(GcResult::default()); } diff --git a/crates/lib/src/bootc_composefs/mod.rs b/crates/lib/src/bootc_composefs/mod.rs index fe5bc9c6a..a33c80017 100644 --- a/crates/lib/src/bootc_composefs/mod.rs +++ b/crates/lib/src/bootc_composefs/mod.rs @@ -15,5 +15,7 @@ pub(crate) mod soft_reboot; pub(crate) mod state; pub(crate) mod status; pub(crate) mod switch; +pub(crate) mod uki_addon; +pub(crate) mod uki_addons_cli; pub(crate) mod update; pub(crate) mod utils; diff --git a/crates/lib/src/bootc_composefs/switch.rs b/crates/lib/src/bootc_composefs/switch.rs index 0cb0b9ebe..f91c2dc63 100644 --- a/crates/lib/src/bootc_composefs/switch.rs +++ b/crates/lib/src/bootc_composefs/switch.rs @@ -34,6 +34,7 @@ pub(crate) async fn switch_composefs( use_unified: false, quiet: opts.quiet, prog, + uki_addon_opts: opts.uki_addon_opts.clone(), }; if opts.download_opts.from_downloaded { diff --git a/crates/lib/src/bootc_composefs/uki_addon.rs b/crates/lib/src/bootc_composefs/uki_addon.rs new file mode 100644 index 000000000..f3fe87c39 --- /dev/null +++ b/crates/lib/src/bootc_composefs/uki_addon.rs @@ -0,0 +1,272 @@ +use std::fmt; + +use anyhow::{Context, Result}; +use bootc_mount::tempmount::TempMount; +use cap_std_ext::cap_std::fs::Dir; +use cap_std_ext::dirext::CapStdExtDirExt; +use fn_error_context::context; +use ostree_ext::{ + composefs::fsverity::{FsVerityHashValue, Sha512HashValue}, + composefs_boot::bootloader::{EFI_ADDON_DIR_EXT, EFI_ADDON_FILE_EXT}, + composefs_oci::linked_erofs_images, +}; +use serde::Serialize; + +use crate::{ + bootc_composefs::{ + boot::{BOOTC_UKI_DIR, EFI_LINUX, GLOBAL_UKI_ADDONS_DIR}, + status::BootloaderEntry, + }, + composefs_consts::UKI_NAME_PREFIX, + store::{BootedComposefs, Storage}, +}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum UkiAddonType { + Scoped { depl_id: String }, + Global, +} + +impl fmt::Display for UkiAddonType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + UkiAddonType::Global => write!(f, "global"), + UkiAddonType::Scoped { depl_id } => write!(f, "scoped (deployment {depl_id})"), + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct UkiAddonsList { + pub name: String, + pub addon_type: UkiAddonType, +} + +impl fmt::Display for UkiAddonsList { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} ({})", self.name, self.addon_type) + } +} + +fn gather_addons_from_dir( + dir: &Dir, + addons: &mut Vec, + addon_type: UkiAddonType, +) -> Result<()> { + for ent in dir.entries_utf8()? { + let ent = ent?; + let filename = ent.file_name()?; + + let Some(addon_name) = filename.strip_suffix(EFI_ADDON_FILE_EXT) else { + continue; + }; + + match addon_name.strip_prefix(UKI_NAME_PREFIX) { + Some(addon_name) => { + addons.push(UkiAddonsList { + name: addon_name.to_string(), + addon_type: addon_type.clone(), + }); + } + None => match addon_type { + UkiAddonType::Scoped { .. } => { + addons.push(UkiAddonsList { + name: addon_name.to_string(), + addon_type: addon_type.clone(), + }); + } + // We only prefix global UKI Addons for identification + UkiAddonType::Global => { + tracing::info!("Global UKI Addon not managed by bootc found: {addon_name}") + } + }, + } + } + + Ok(()) +} + +/// Gathers UKI Addons (Global + Scoped) from the ESP +#[context("Gathering addons from filesystem")] +pub fn gather_addons_from_filesystem( + boot_dir: &Dir, + depl_id: Option<&str>, +) -> Result> { + let mut addons_list: Vec = vec![]; + + if let Some(global_dir) = boot_dir.open_dir_optional(GLOBAL_UKI_ADDONS_DIR)? { + for entry in global_dir.entries_utf8()? { + let entry = entry?; + let filename = entry.file_name()?; + + if let Some(name) = filename.strip_suffix(EFI_ADDON_FILE_EXT) { + addons_list.push(UkiAddonsList { + name: name.to_string(), + addon_type: UkiAddonType::Global, + }); + } + } + } + + let Some(efi_linux) = boot_dir.open_dir_optional(EFI_LINUX)? else { + return Ok(addons_list); + }; + + for entry in efi_linux.entries_utf8()? { + let entry = entry?; + + if !entry.file_type()?.is_dir() { + continue; + } + + let dirname = entry.file_name()?; + + // This will usually be the kernel version + let Some(..) = dirname.strip_suffix(EFI_ADDON_DIR_EXT) else { + continue; + }; + + let dir = efi_linux + .open_dir(&dirname) + .with_context(|| format!("Opening {dirname}"))?; + + for addon_ent in dir.entries_utf8()? { + let addon_ent = addon_ent?; + let filename = addon_ent.file_name()?; + + if let Some(name) = filename.strip_suffix(EFI_ADDON_FILE_EXT) { + addons_list.push(UkiAddonsList { + name: name.to_string(), + addon_type: UkiAddonType::Scoped { + // We can't always have the deployment id with us + depl_id: depl_id.map(|x| x.to_string()).unwrap_or("".into()), + }, + }); + } + } + } + + Ok(addons_list) +} + +#[context("Listing UKI Addons")] +pub fn list_installed_uki_addons(storage: &Storage) -> Result> { + let mut addons = vec![]; + + let Ok(esp) = storage.require_esp() else { + return Ok(addons); + }; + + if let Some(global_dir) = esp.fd.open_dir_optional(GLOBAL_UKI_ADDONS_DIR)? { + gather_addons_from_dir(&global_dir, &mut addons, UkiAddonType::Global) + .context("Gathering global addons")?; + }; + + let Some(bootc_uki_dir) = esp + .fd + .open_dir_optional(BOOTC_UKI_DIR) + .context("Opening UKI dir")? + else { + return Ok(addons); + }; + + for ent in bootc_uki_dir + .entries_utf8() + .context("Reading UKI dir entries")? + { + let ent = ent?; + let filename = ent.file_name()?; + + if !ent.file_type()?.is_dir() { + continue; + } + + let Some(dir_name) = filename.strip_suffix(EFI_ADDON_DIR_EXT) else { + continue; + }; + + let depl_id = dir_name.strip_prefix(UKI_NAME_PREFIX).unwrap_or(dir_name); + + let dir = esp + .fd + .open_dir(format!("{BOOTC_UKI_DIR}/{filename}")) + .with_context(|| format!("Opening {filename}"))?; + + gather_addons_from_dir( + &dir, + &mut addons, + UkiAddonType::Scoped { + depl_id: depl_id.to_string(), + }, + )?; + } + + Ok(addons) +} + +/// Go through all the EROFS images and get all the referenced UKI Addons +/// +/// Returns a list of tuple of (EROFS verity, List of referenced addons) +#[context("Getting all referenced UKI Addons")] +pub fn list_referenced_uki_addons( + booted_cfs: &BootedComposefs, + bootloader_entries: &Vec, +) -> Result)>> { + let mut all_referenced_addons: Vec<(String, Vec)> = vec![]; + + for entry in bootloader_entries { + let verity = Sha512HashValue::from_hex(&entry.fsverity); + + let verity = match verity { + Ok(v) => v, + Err(e) => { + tracing::warn!( + "Invalid fsverity found in bootloader entry {}: {e:?}", + entry.fsverity + ); + continue; + } + }; + + let linked_erofs = match linked_erofs_images(&booted_cfs.repo, &verity) { + Ok(v) => v, + // Skip entries with no linked EROFS image, but propagate any other error + // TODO: Update with proper error downcasted match once we have + // https://github.com/composefs/composefs-rs/pull/400 released + Err(e) + if e.chain() + .any(|c| c.to_string().contains("No EROFS image found")) => + { + tracing::debug!("No EROFS image found for {}", entry.fsverity); + continue; + } + Err(e) => return Err(e), + }; + + let Some(non_bootable) = linked_erofs.iter().find(|e| !e.bootable) else { + tracing::debug!("No non-bootable EROFS found for {}", entry.fsverity); + continue; + }; + + let composefs_mnt_fd = booted_cfs + .repo + .mount(&non_bootable.id.to_hex()) + .context("Failed to mount composefs image")?; + + let composefs = TempMount::mount_fd(composefs_mnt_fd) + .context("Attaching composefs image to temporary directory")?; + + let cfs_boot_dir = composefs + .fd + .open_dir("boot") + .context("Opening boot directory in composefs image")?; + + let addons_list = gather_addons_from_filesystem(&cfs_boot_dir, Some(&entry.fsverity))?; + + // We work with the bootable fsverity everywhere + all_referenced_addons.push((entry.fsverity.clone(), addons_list)); + } + + Ok(all_referenced_addons) +} diff --git a/crates/lib/src/bootc_composefs/uki_addons_cli.rs b/crates/lib/src/bootc_composefs/uki_addons_cli.rs new file mode 100644 index 000000000..08af9edf0 --- /dev/null +++ b/crates/lib/src/bootc_composefs/uki_addons_cli.rs @@ -0,0 +1,399 @@ +use std::io::{Seek, SeekFrom}; +use std::path::Path; + +use anyhow::{Context, Result}; +use bootc_mount::tempmount::TempMount; +use camino::Utf8PathBuf; +use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt}; +use fn_error_context::context; +use ostree_ext::{ + composefs::fsverity::{FsVerityHashValue, Sha512HashValue}, + composefs_boot::{ + bootloader::{EFI_ADDON_DIR_EXT, EFI_ADDON_FILE_EXT}, + cmdline::ComposefsCmdline as ComposefsBootCmdline, + uki, + }, + composefs_oci::linked_erofs_images, +}; + +use crate::{ + bootc_composefs::{ + boot::{ + BOOTC_UKI_DIR, EFI_LINUX, GLOBAL_UKI_ADDONS_DIR, get_global_uki_addon_name, + get_scoped_uki_addon_name, get_uki_addon_dir_name, + }, + status::list_bootloader_entries, + uki_addon::{UkiAddonType, list_installed_uki_addons, list_referenced_uki_addons}, + }, + cli::{UkiAddonCliOpts, UkiAddonScope}, + store::{BootedComposefs, Storage}, +}; + +#[context("Verifying {addon_type:?} {addon_name} addon exists")] +fn verify_addon_exists( + boot_dir: &Dir, + addon_name: &str, + addon_type: UkiAddonScope, +) -> Result { + let mut path = Utf8PathBuf::from("boot"); + + match addon_type { + UkiAddonScope::Global => { + let addons_dir = boot_dir.open_dir(GLOBAL_UKI_ADDONS_DIR)?; + + for entry in addons_dir.entries_utf8()? { + let entry = entry?; + let filename = entry.file_name()?; + + if let Some(name) = filename.strip_suffix(EFI_ADDON_FILE_EXT) { + if name == addon_name { + return Ok(path.join(GLOBAL_UKI_ADDONS_DIR).join(filename)); + } + }; + } + } + + UkiAddonScope::Scoped => { + path = path.join(EFI_LINUX); + + for entry in boot_dir + .open_dir(EFI_LINUX) + .context("Opening EFI/Linux")? + .entries_utf8()? + { + let entry = entry?; + + if !entry.file_type()?.is_dir() { + continue; + } + + let dirname = entry.file_name()?; + + if !dirname.ends_with(EFI_ADDON_DIR_EXT) { + continue; + } + + path.push(&dirname); + + for addon_ent in entry.open_dir()?.entries()? { + let addon_ent = addon_ent?; + let filename = addon_ent.file_name()?; + + if let Some(name) = filename.strip_suffix(EFI_ADDON_FILE_EXT) { + if name == addon_name { + return Ok(path.join(filename)); + } + }; + } + + path.pop(); + } + } + }; + + anyhow::bail!("{addon_name} not found"); +} + +pub(crate) fn handle_addon_cli_cmd( + storage: &Storage, + booted_cfs: &BootedComposefs, + opts: &UkiAddonCliOpts, +) -> Result<()> { + let Ok(esp) = storage.require_esp() else { + anyhow::bail!("ESP not found"); + }; + + match opts { + UkiAddonCliOpts::List { json } => { + let addons = list_installed_uki_addons(storage)?; + + if *json { + return serde_json::to_writer(std::io::stdout(), &addons) + .context("Writing JSON output"); + } + + if addons.is_empty() { + println!("No UKI addons installed"); + return Ok(()); + } + + for addon in &addons { + println!("{addon}"); + } + } + UkiAddonCliOpts::Remove { + name: addon_name, + deployment_id, + } => { + let addons = list_installed_uki_addons(storage)?; + + match deployment_id { + Some(depl_id) => { + let found = addons.iter().any(|addon| { + matches!( + &addon.addon_type, + UkiAddonType::Scoped { depl_id: id } if id == depl_id + ) && addon.name == *addon_name + }); + + if !found { + anyhow::bail!( + "No addon found with the name {addon_name} for deployment {depl_id}" + ); + } + + let addon_path = Path::new(BOOTC_UKI_DIR) + .join(get_uki_addon_dir_name(depl_id)) + .join(get_scoped_uki_addon_name(addon_name)); + + // Absolutely make sure the addon doesn't contain `composefs=` cmdline + // if it does, we can't remove it + let mut addon_file = esp + .fd + .open(&addon_path) + .with_context(|| format!("Opening {}", addon_path.display()))?; + + match uki::get_cmdline_buffered(&mut addon_file) { + Ok(cmdline_str) => { + let cfs_cmdline_info = + ComposefsBootCmdline::::from_cmdline(&cmdline_str) + .context("Parsing composefs=")?; + + if let Some(cmdline) = cfs_cmdline_info { + anyhow::bail!( + "Composefs commandline {cmdline:?} found in addon {addon_name}, cannot remove" + ); + }; + } + Err(uki::UkiError::MissingSection(..)) => { + // All good, no cmdline section in this addon + } + Err(e) => Err(e).context("Reading cmdline section from addon")?, + }; + + esp.fd + .remove_file(&addon_path) + .with_context(|| format!("Failed to remove addon {addon_name}"))?; + + println!("Removed addon {addon_name}"); + + let addons_dir = addon_path + .parent() + .expect("Expected addon path to have a parent"); + + let num_ents = esp + .fd + .open_dir(addons_dir) + .context("Opening addons dir")? + .entries() + .context("Getting addons dir entries")? + .count(); + + // Remove directory if empty + if num_ents == 0 { + esp.fd.remove_dir(&addons_dir).with_context(|| { + format!("Removing addons dir: {}", addons_dir.display()) + })?; + + println!("Removed empty directory {}", addons_dir.display()); + } + } + + // Removing a global addon + None => { + let found = addons.iter().any(|addon| { + addon.addon_type == UkiAddonType::Global && addon.name == *addon_name + }); + + if !found { + anyhow::bail!("No Global addon found with the name {addon_name}"); + } + + let full_addon_name = get_global_uki_addon_name(addon_name); + + tracing::debug!("Removing Global UKI Addon {full_addon_name}"); + + esp.fd + .remove_file(format!("{GLOBAL_UKI_ADDONS_DIR}/{full_addon_name}")) + .with_context(|| format!("Removing global addon {full_addon_name}"))?; + + println!("Removed Global Addon {addon_name}"); + + let num_ents = esp + .fd + .open_dir(GLOBAL_UKI_ADDONS_DIR) + .context("Opening global addons dir")? + .entries() + .context("Getting global addons dir entries")? + .count(); + + // Remove directory if empty + if num_ents == 0 { + esp.fd + .remove_dir(GLOBAL_UKI_ADDONS_DIR) + .context("Removing global addons dir")?; + + println!("Removed empty directory {GLOBAL_UKI_ADDONS_DIR}"); + } + } + } + } + + UkiAddonCliOpts::Add { + name: addon_name, + addon_type, + } => { + // This should never fail + let booted_digest = Sha512HashValue::from_hex(booted_cfs.cmdline.digest.as_bytes()) + .context("Booted composefs has bad FSVerity")?; + + let addons = list_installed_uki_addons(storage)?; + + let already_present = addons.iter().any(|addon| match addon_type { + UkiAddonScope::Global => { + addon.addon_type == UkiAddonType::Global && addon.name == *addon_name + } + UkiAddonScope::Scoped => { + matches!( + &addon.addon_type, + UkiAddonType::Scoped { depl_id: id } if *id == booted_digest.to_hex() + ) && addon.name == *addon_name + } + }); + + if already_present { + println!("Addon {addon_name} is already present. Nothing to do."); + return Ok(()); + } + + let linked_images = linked_erofs_images(&booted_cfs.repo, &booted_digest) + .context("Finding linked EROFS for booted deployment")?; + + let Some(non_bootable_img) = linked_images.iter().find(|img| !img.bootable) else { + anyhow::bail!("No non-bootable image found. Cannot gather UKI Addons"); + }; + + tracing::debug!("non_bootable_img: {non_bootable_img:#?}"); + + // Now we mount the img, and copy from /boot + let composefs_mnt_fd = booted_cfs + .repo + .mount(&non_bootable_img.id.to_hex()) + .context("Failed to mount composefs image")?; + + let composefs = TempMount::mount_fd(composefs_mnt_fd) + .context("Attaching composefs image to temporary directory")?; + + let cfs_boot_dir = composefs + .fd + .open_dir("boot") + .context("Opening boot directory in composefs image")?; + + // Make sure the addon actually exists in the image + // before creating directories + let addon_path = verify_addon_exists(&cfs_boot_dir, addon_name, *addon_type)?; + + // Make sure this addon doesn't contain a composefs= or composefs.digest= cmdline + let mut addon_file = composefs + .fd + .open(&addon_path) + .with_context(|| format!("Opening UKI Addon at {addon_path}"))?; + + // UKI Addon might not even have a cmdline + let cmdline = uki::get_cmdline_buffered(&mut addon_file); + + match cmdline { + Ok(ref cmdline_str) => { + let cfs_cmdline_info = + ComposefsBootCmdline::::from_cmdline(cmdline_str) + .context("Parsing composefs=")?; + + if let Some(cfs_cmdline) = cfs_cmdline_info { + anyhow::bail!( + "composefs cmdline {cfs_cmdline:?} found in UKI Addon {addon_name}. Refusing to add" + ); + } + } + Err(uki::UkiError::MissingSection(..)) => { + // This is fine, no cmdline section + // so it's just a no op + } + Err(e) => { + return Err(e).context("Getting UKI cmdline"); + } + }; + + let (dest_dir, dest_name) = match addon_type { + UkiAddonScope::Global => { + esp.fd + .create_dir_all(GLOBAL_UKI_ADDONS_DIR) + .context("Creating global addons directory")?; + + let global_addons_dir = esp + .fd + .open_dir(GLOBAL_UKI_ADDONS_DIR) + .context("Opening global addons dir")?; + + (global_addons_dir, get_global_uki_addon_name(addon_name)) + } + UkiAddonScope::Scoped => { + let dir_path = Path::new(BOOTC_UKI_DIR) + .join(get_uki_addon_dir_name(&booted_digest.to_hex())); + + esp.fd + .create_dir_all(&dir_path) + .context("Creating addons directory")?; + + let to_dir = esp + .fd + .open_dir(&dir_path) + .context("Opening addons directory")?; + + (to_dir, get_scoped_uki_addon_name(addon_name)) + } + }; + + addon_file + .seek(SeekFrom::Start(0)) + .context("Seeking to start of addon")?; + + dest_dir + .atomic_replace_with(&dest_name, |writer| std::io::copy(&mut addon_file, writer)) + .with_context(|| format!("Writing addon {dest_name}"))?; + + rustix::fs::fsync( + dest_dir + .reopen_as_ownedfd() + .context("Reopening as owned fd")?, + ) + .context("fsync")?; + } + UkiAddonCliOpts::ListReferenced { json } => { + let referenced = + list_referenced_uki_addons(booted_cfs, &list_bootloader_entries(storage)?)?; + + if *json { + return serde_json::to_writer(std::io::stdout(), &referenced) + .context("Writing JSON output"); + } + + if referenced.is_empty() { + println!("No referenced UKI addons found"); + return Ok(()); + } + + for (verity, addons) in &referenced { + println!("Deployment {verity}:"); + if addons.is_empty() { + println!(" (none)"); + } else { + for addon in addons { + println!(" {addon}"); + } + } + } + } + } + + Ok(()) +} diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index c0dcafe34..5bd05f081 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -15,6 +15,7 @@ use ostree_ext::container::ManifestDiff; use crate::bootc_composefs::finalize::get_etc_diff; use crate::bootc_composefs::gc::GCOpts; +use crate::install::UkiAddonOpts; use crate::spec::BootloaderKind; use crate::{ bootc_composefs::{ @@ -234,6 +235,8 @@ pub(crate) struct DoUpgradeOpts { pub(crate) quiet: bool, /// Structured (JSON-Lines) progress sink; see `--progress-fd`. pub(crate) prog: ProgressWriter, + /// The UKI Addons to install from the new image (if any) + pub(crate) uki_addon_opts: UkiAddonOpts, } async fn apply_upgrade( @@ -335,7 +338,7 @@ pub(crate) async fn do_upgrade( let (boot_digest, deploy_id) = match boot_type { BootType::Bls => ( setup_composefs_bls_boot( - BootSetupType::Upgrade((storage, booted_cfs, &host)), + BootSetupType::Upgrade((storage, booted_cfs, &host, None)), &repo, &provisional_deploy_id, provisional_format, @@ -347,7 +350,7 @@ pub(crate) async fn do_upgrade( BootType::Uki => print_uki_dumpfile_diff_on_mismatch( setup_composefs_uki_boot( - BootSetupType::Upgrade((storage, booted_cfs, &host)), + BootSetupType::Upgrade((storage, booted_cfs, &host, Some(&opts.uki_addon_opts))), &repo, &provisional_deploy_id, &boot_ids, @@ -503,6 +506,7 @@ pub(crate) async fn upgrade_composefs( use_unified: false, quiet: opts.quiet, prog, + uki_addon_opts: opts.uki_addon_opts, }; if opts.download_opts.from_downloaded { diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 0a7c0e598..6ab348ca4 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -43,6 +43,7 @@ use crate::bootc_composefs::delete::delete_composefs_deployment; use crate::bootc_composefs::gc::{GCOpts, composefs_gc}; use crate::bootc_composefs::soft_reboot::{prepare_soft_reboot_composefs, reset_soft_reboot}; use crate::bootc_composefs::state::get_usr_overlay_status; +use crate::bootc_composefs::uki_addons_cli::handle_addon_cli_cmd; use crate::bootc_composefs::{ digest::{compute_composefs_digest, new_temp_composefs_repo}, finalize::{composefs_backend_finalize, get_etc_diff}, @@ -52,6 +53,7 @@ use crate::bootc_composefs::{ update::upgrade_composefs, }; use crate::deploy::{MergeState, RequiredHostSpec}; +use crate::install::UkiAddonOpts; use crate::podstorage::set_additional_image_store; use crate::progress_jsonl::{ProgressWriter, RawProgressFd}; use crate::spec::FilesystemOverlayAccessMode; @@ -143,6 +145,10 @@ pub(crate) struct UpgradeOpts { #[clap(flatten)] pub(crate) progress: ProgressOptions, + + // This is kinda unfortunate that we can't gate this only for composefs systems + #[clap(flatten)] + pub(crate) uki_addon_opts: UkiAddonOpts, } /// Perform an switch operation @@ -213,6 +219,10 @@ pub(crate) struct SwitchOpts { #[clap(flatten)] pub(crate) progress: ProgressOptions, + + // This is kinda unfortunate that we can't gate this only for composefs systems + #[clap(flatten)] + pub(crate) uki_addon_opts: UkiAddonOpts, } /// Finalize a staged composefs deployment. @@ -976,6 +986,42 @@ impl InternalsOpts { const GENERATOR_BIN: &'static str = "bootc-systemd-generator"; } +#[derive(Debug, Clone, Copy, clap::ValueEnum, PartialEq, Eq)] +pub(crate) enum UkiAddonScope { + Global, + Scoped, +} + +#[derive(Debug, clap::Subcommand, PartialEq, Eq)] +pub(crate) enum UkiAddonCliOpts { + /// List all installed UKI Addons + List { + /// Output in JSON format + #[clap(long)] + json: bool, + }, + /// Remove a UKI Addon + Remove { + /// Addon name to be provided without the `.efi.addon` suffix + name: String, + /// If removing a scoped addon, deployment_id is required. + /// If removing a global addon, deployment_id is not required. + deployment_id: Option, + }, + /// Add a UKI Addon to the current deployment + Add { + /// Addon name to be provided without the `.efi.addon` suffix + name: String, + addon_type: UkiAddonScope, + }, + /// List all referenced UKI Addons across all deployments + ListReferenced { + /// Output in JSON format + #[clap(long)] + json: bool, + }, +} + /// Deploy and transactionally in-place with bootable container images. /// /// The `bootc` project currently uses ostree-containers as a backend @@ -1107,6 +1153,10 @@ pub(crate) enum Opt { }, #[clap(hide = true)] DeleteDeployment { depl_id: String }, + + /// Perform operations related to UKI Addons + #[clap(subcommand)] + UkiAddon(UkiAddonCliOpts), } /// Ensure we've entered a mount namespace, so that we can remount @@ -1731,6 +1781,10 @@ async fn switch(opts: SwitchOpts) -> Result<()> { let storage = &get_storage().await?; match storage.kind()? { BootedStorageKind::Ostree(booted_ostree) => { + if opts.uki_addon_opts.scoped.is_some() || opts.uki_addon_opts.global.is_some() { + anyhow::bail!("UKI Addon options are only supported for composefs backend"); + } + switch_ostree(opts, storage, &booted_ostree).await } BootedStorageKind::Composefs(booted_cfs) => { @@ -2090,6 +2144,11 @@ async fn run_from_opt(opt: Opt) -> Result { let storage = &get_storage().await?; match storage.kind()? { BootedStorageKind::Ostree(booted_ostree) => { + if opts.uki_addon_opts.scoped.is_some() || opts.uki_addon_opts.global.is_some() + { + anyhow::bail!("UKI Addon options are only supported for composefs backend"); + } + upgrade(opts, storage, &booted_ostree).await } BootedStorageKind::Composefs(booted_cfs) => { @@ -2768,6 +2827,17 @@ async fn run_from_opt(opt: Opt) -> Result { } } } + Opt::UkiAddon(opts) => { + let storage = &get_storage().await?; + match storage.kind()? { + BootedStorageKind::Ostree(_) => { + anyhow::bail!("UKI Addons are only supported for Composefs Backend") + } + BootedStorageKind::Composefs(booted_cfs) => { + handle_addon_cli_cmd(storage, &booted_cfs, &opts) + } + } + } }; result.map(|()| CliExitStatus::Success) } diff --git a/crates/lib/src/composefs_consts.rs b/crates/lib/src/composefs_consts.rs index 03ccc5310..7a9c56d06 100644 --- a/crates/lib/src/composefs_consts.rs +++ b/crates/lib/src/composefs_consts.rs @@ -43,6 +43,12 @@ pub(crate) const BOOTC_FINALIZE_STAGED_SERVICE: &str = "bootc-finalize-staged.se pub(crate) const TYPE1_BOOT_DIR_PREFIX: &str = "bootc_composefs-"; /// The prefix for names of UKI and UKI Addons +/// +/// The actual name of a scoped UKI Addon is NOT prefixed, +/// only its directory name is prefixed +/// +/// The actual name of a global UKI Addon IS prefixed, since +/// they all live in ESP/loader/addons pub(crate) const UKI_NAME_PREFIX: &str = TYPE1_BOOT_DIR_PREFIX; /// Prefix for OCI tags owned by bootc in the composefs repository. diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 8f9f80fc4..b6ca12d3d 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -395,6 +395,23 @@ pub(crate) struct InstallConfigOpts { pub(crate) bootloader: Option, } +#[derive(Debug, Default, Clone, clap::Parser, Serialize, Deserialize, PartialEq, Eq)] +pub(crate) struct UkiAddonOpts { + /// Name of the local/scoped UKI addons to install without the ".efi.addon" suffix. + /// This option can be provided multiple times if multiple addons are to be installed + /// (composefs backend only). + #[clap(long = "uki-addon")] + #[serde(default)] + pub(crate) scoped: Option>, + + /// Name of the global UKI addons to install without the ".efi.addon" suffix. + /// This option can be provided multiple times if multiple addons are to be installed + /// (composefs backend only). + #[clap(long = "global-uki-addon")] + #[serde(default)] + pub(crate) global: Option>, +} + #[derive(Debug, Default, Clone, clap::Parser, Serialize, Deserialize, PartialEq, Eq)] pub(crate) struct InstallComposefsOpts { /// Use the composefs backend instead of ostree. This is the default for images with a UKI, @@ -409,12 +426,9 @@ pub(crate) struct InstallComposefsOpts { #[serde(default)] pub(crate) allow_missing_verity: bool, - /// Name of the UKI addons to install without the ".efi.addon" suffix. - /// This option can be provided multiple times if multiple addons are to be installed - /// (composefs backend only). - #[clap(long)] - #[serde(default)] - pub(crate) uki_addon: Option>, + #[clap(flatten)] + #[serde(flatten)] + pub(crate) uki_addon_opts: UkiAddonOpts, } impl InstallComposefsOpts { @@ -432,9 +446,13 @@ impl InstallComposefsOpts { "--allow-missing-verity requires the composefs backend" ); anyhow::ensure!( - self.uki_addon.is_none(), + self.uki_addon_opts.scoped.is_none(), "--uki-addon requires the composefs backend" ); + anyhow::ensure!( + self.uki_addon_opts.global.is_none(), + "--global-uki-addon requires the composefs backend" + ); } Ok(()) } @@ -1642,6 +1660,14 @@ async fn prepare_install( target_fs: Option, ) -> Result> { tracing::trace!("Preparing install"); + + if !composefs_options.composefs_backend + && (composefs_options.uki_addon_opts.scoped.is_some() + || composefs_options.uki_addon_opts.global.is_some()) + { + anyhow::bail!("UKI Addons are only supported on composefs backends"); + } + let allow_missing_verity_explicit = composefs_options.allow_missing_verity; let rootfs = cap_std::fs::Dir::open_ambient_dir("/", cap_std::ambient_authority()) .context("Opening /")?; @@ -3148,7 +3174,10 @@ mod tests { let opts = InstallComposefsOpts { composefs_backend, allow_missing_verity, - uki_addon, + uki_addon_opts: UkiAddonOpts { + scoped: uki_addon, + global: None, + }, }; assert_eq!( opts.validate(bootloader.as_ref()).is_ok(), diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index fe4d0ebfa..86348f2d3 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -92,6 +92,11 @@ - [install reset](bootc-experimental-install-reset.7.md) - [--progress-fd](bootc-experimental-progress-fd.7.md) - [container export](bootc-experimental-container-export.7.md) +- [`man bootc-uki-addon.8.md`](man/bootc-uki-addon.8.md) +- [`man bootc-uki-addon-list.8.md`](man/bootc-uki-addon-list.8.md) +- [`man bootc-uki-addon-add.8.md`](man/bootc-uki-addon-add.8.md) +- [`man bootc-uki-addon-remove.8.md`](man/bootc-uki-addon-remove.8.md) +- [`man bootc-uki-addon-list-referenced.8.md`](man/bootc-uki-addon-list-referenced.8.md) # More information diff --git a/docs/src/man/bootc-install-to-disk.8.md b/docs/src/man/bootc-install-to-disk.8.md index 776c01ab1..0948caca5 100644 --- a/docs/src/man/bootc-install-to-disk.8.md +++ b/docs/src/man/bootc-install-to-disk.8.md @@ -186,9 +186,13 @@ set `discoverable-partitions = true` in their install configuration Default: false -**--uki-addon**=*UKI_ADDON* +**--uki-addon**=*SCOPED* - Name of the UKI addons to install without the ".efi.addon" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) + Name of the local/scoped UKI addons to install without the ".efi.addon" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) + +**--global-uki-addon**=*GLOBAL* + + Name of the global UKI addons to install without the ".efi.addon" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) diff --git a/docs/src/man/bootc-install-to-existing-root.8.md b/docs/src/man/bootc-install-to-existing-root.8.md index 3ff11166e..4f285289a 100644 --- a/docs/src/man/bootc-install-to-existing-root.8.md +++ b/docs/src/man/bootc-install-to-existing-root.8.md @@ -227,9 +227,13 @@ of migrating the fstab entries. See the "Injecting kernel arguments" section abo Default: false -**--uki-addon**=*UKI_ADDON* +**--uki-addon**=*SCOPED* - Name of the UKI addons to install without the ".efi.addon" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) + Name of the local/scoped UKI addons to install without the ".efi.addon" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) + +**--global-uki-addon**=*GLOBAL* + + Name of the global UKI addons to install without the ".efi.addon" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) diff --git a/docs/src/man/bootc-install-to-filesystem.8.md b/docs/src/man/bootc-install-to-filesystem.8.md index 9549e156b..c5e013818 100644 --- a/docs/src/man/bootc-install-to-filesystem.8.md +++ b/docs/src/man/bootc-install-to-filesystem.8.md @@ -136,9 +136,13 @@ is currently expected to be empty by default. Default: false -**--uki-addon**=*UKI_ADDON* +**--uki-addon**=*SCOPED* - Name of the UKI addons to install without the ".efi.addon" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) + Name of the local/scoped UKI addons to install without the ".efi.addon" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) + +**--global-uki-addon**=*GLOBAL* + + Name of the global UKI addons to install without the ".efi.addon" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) diff --git a/docs/src/man/bootc-switch.8.md b/docs/src/man/bootc-switch.8.md index c32911e93..3d3bc082c 100644 --- a/docs/src/man/bootc-switch.8.md +++ b/docs/src/man/bootc-switch.8.md @@ -76,6 +76,14 @@ For shared `--apply` and `--soft-reboot` behavior, see Retain reference to currently booted image +**--uki-addon**=*SCOPED* + + Name of the local/scoped UKI addons to install without the ".efi.addon" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) + +**--global-uki-addon**=*GLOBAL* + + Name of the global UKI addons to install without the ".efi.addon" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) + # EXAMPLES diff --git a/docs/src/man/bootc-uki-addon-add.8.md b/docs/src/man/bootc-uki-addon-add.8.md new file mode 100644 index 000000000..bfd00b793 --- /dev/null +++ b/docs/src/man/bootc-uki-addon-add.8.md @@ -0,0 +1,49 @@ +# NAME + +bootc-uki-addon-add - Add a UKI Addon to the current deployment + +# SYNOPSIS + +**bootc uki-addon add** <*NAME*> <*ADDON_TYPE*> + +# DESCRIPTION + +**This command is experimental and subject to change.** + +Add a UKI addon from the currently booted image to the EFI System Partition. + +The addon must exist in the booted image under `/boot`. If the addon is +already installed, the command is a no-op. + +# OPTIONS + + +**NAME** + + Addon name to be provided without the `.efi.addon` suffix + + This argument is required. + +**ADDON_TYPE** + + This argument is required. + + + +# EXAMPLES + +Add a scoped addon (tied to the current deployment): + + bootc uki-addon add debug scoped + +Add a global addon (applies to all deployments): + + bootc uki-addon add site-config global + +# SEE ALSO + +**bootc-uki-addon**(8), **bootc-uki-addon-list**(8), **bootc-uki-addon-remove**(8) + +# VERSION + + diff --git a/docs/src/man/bootc-uki-addon-list-referenced.8.md b/docs/src/man/bootc-uki-addon-list-referenced.8.md new file mode 100644 index 000000000..9939325db --- /dev/null +++ b/docs/src/man/bootc-uki-addon-list-referenced.8.md @@ -0,0 +1,54 @@ +# NAME + +bootc-uki-addon-list-referenced - List all referenced UKI Addons across all deployments + +# SYNOPSIS + +**bootc uki-addon list-referenced** \[*OPTIONS...*\] + +# DESCRIPTION + +**This command is experimental and subject to change.** + +List all UKI addons referenced by EROFS images across all deployments. This +shows which addons each deployment's container image ships, regardless of +whether those addons are currently installed on the ESP. + +This is primarily useful for debugging and understanding which addons are +available in each deployed image. Garbage collection uses this information +internally to determine which global addons can be safely removed. + +# OPTIONS + + +**--json** + + Output in JSON format + + + +# EXAMPLES + +List all referenced addons: + + bootc uki-addon list-referenced + +Example output: + + Deployment cb82031a...: + fav-cmdline (global) + cmdline-extend (scoped (deployment cb82031a...)) + Deployment 1913e6ed...: + berserk (scoped (deployment 1913e6ed...)) + +List referenced addons in JSON format: + + bootc uki-addon list-referenced --json + +# SEE ALSO + +**bootc-uki-addon**(8), **bootc-uki-addon-list**(8), **bootc-uki-addon-add**(8), **bootc-uki-addon-remove**(8) + +# VERSION + + diff --git a/docs/src/man/bootc-uki-addon-list.8.md b/docs/src/man/bootc-uki-addon-list.8.md new file mode 100644 index 000000000..f371ed546 --- /dev/null +++ b/docs/src/man/bootc-uki-addon-list.8.md @@ -0,0 +1,59 @@ +# NAME + +bootc-uki-addon-list - List all installed UKI Addons + +# SYNOPSIS + +**bootc uki-addon list** \[*OPTIONS...*\] + +# DESCRIPTION + +**This command is experimental and subject to change.** + +List all installed UKI addons on the EFI System Partition, including both +scoped (per-deployment) and global addons. + +By default, output is human-readable with one addon per line. Use `--json` +for machine-readable output suitable for scripting. + +# OPTIONS + + +**--json** + + Output in JSON format + + + +# EXAMPLES + +List all installed addons: + + bootc uki-addon list + +List addons in JSON format: + + bootc uki-addon list --json + +Example JSON output: + +```json +[ + { + "name": "debug", + "addon_type": { "type": "scoped", "depl_id": "a1b2c3..." } + }, + { + "name": "site-config", + "addon_type": { "type": "global" } + } +] +``` + +# SEE ALSO + +**bootc-uki-addon**(8), **bootc-uki-addon-add**(8), **bootc-uki-addon-remove**(8) + +# VERSION + + diff --git a/docs/src/man/bootc-uki-addon-remove.8.md b/docs/src/man/bootc-uki-addon-remove.8.md new file mode 100644 index 000000000..7f4b17ec8 --- /dev/null +++ b/docs/src/man/bootc-uki-addon-remove.8.md @@ -0,0 +1,50 @@ +# NAME + +bootc-uki-addon-remove - Remove a UKI Addon + +# SYNOPSIS + +**bootc uki-addon remove** <*NAME*> \[*DEPLOYMENT_ID*\] + +# DESCRIPTION + +**This command is experimental and subject to change.** + +Remove a UKI addon from the EFI System Partition. + +For global addons, only the addon name is needed. For scoped addons, the +deployment ID is required to identify which deployment's addon to remove. +Use `bootc uki-addon list --json` to find deployment IDs. + +# OPTIONS + + +**NAME** + + Addon name to be provided without the `.efi.addon` suffix + + This argument is required. + +**DEPLOYMENT_ID** + + If removing a scoped addon, deployment_id is required. If removing a global addon, deployment_id is not required + + + +# EXAMPLES + +Remove a global addon: + + bootc uki-addon remove site-config + +Remove a scoped addon (get the deployment ID from `list --json`): + + bootc uki-addon remove debug a1b2c3d4e5f6... + +# SEE ALSO + +**bootc-uki-addon**(8), **bootc-uki-addon-list**(8), **bootc-uki-addon-add**(8) + +# VERSION + + diff --git a/docs/src/man/bootc-uki-addon.8.md b/docs/src/man/bootc-uki-addon.8.md new file mode 100644 index 000000000..f266007df --- /dev/null +++ b/docs/src/man/bootc-uki-addon.8.md @@ -0,0 +1,64 @@ +# NAME + +bootc-uki-addon - Manage UKI addons on the EFI System Partition + +# SYNOPSIS + +**bootc uki-addon** <*COMMAND*> + +# DESCRIPTION + +**This command is experimental and subject to change.** + +Manage UKI (Unified Kernel Image) addons on the EFI System Partition. + +UKI addons are PE binaries that systemd-stub loads alongside the main UKI at +boot. Each addon carries extra kernel command-line parameters that get merged +into the boot configuration. + +There are two types of addons: + +- **Scoped** addons are tied to a specific deployment. They are stored next to + the deployment's UKI and are automatically cleaned up by garbage collection + when the deployment is removed. + +- **Global** addons apply to every UKI on the ESP. They persist across + deployments and are not removed by garbage collection. + +This command requires the composefs backend with UKI boot. + + + + +# COMMANDS + +**list** +: List all installed UKI addons. See **bootc-uki-addon-list**(8). + +**add** +: Add a UKI addon from the booted image. See **bootc-uki-addon-add**(8). + +**remove** +: Remove a UKI addon from the ESP. See **bootc-uki-addon-remove**(8). + +# EXAMPLES + +List all installed addons: + + bootc uki-addon list + +Add a scoped addon from the booted image: + + bootc uki-addon add debug scoped + +Remove a global addon: + + bootc uki-addon remove site-config + +# SEE ALSO + +**bootc**(8), **bootc-uki-addon-list**(8), **bootc-uki-addon-add**(8), **bootc-uki-addon-remove**(8) + +# VERSION + + diff --git a/docs/src/man/bootc-upgrade.8.md b/docs/src/man/bootc-upgrade.8.md index 0a2face2b..473b72b29 100644 --- a/docs/src/man/bootc-upgrade.8.md +++ b/docs/src/man/bootc-upgrade.8.md @@ -67,6 +67,14 @@ For shared `--apply` and `--soft-reboot` behavior, see Upgrade to a different tag of the currently booted image +**--uki-addon**=*SCOPED* + + Name of the local/scoped UKI addons to install without the ".efi.addon" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) + +**--global-uki-addon**=*GLOBAL* + + Name of the global UKI addons to install without the ".efi.addon" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) + # EXAMPLES diff --git a/docs/src/man/bootc.8.md b/docs/src/man/bootc.8.md index c741f8f75..87affcaa0 100644 --- a/docs/src/man/bootc.8.md +++ b/docs/src/man/bootc.8.md @@ -37,6 +37,7 @@ For guides to building, installing, and managing bootable images, see | **bootc install** | Install the running container to a target | | **bootc container** | Operations which can be executed as part of a container build | | **bootc loader-entries** | Operations on Boot Loader Specification (BLS) entries | +| **bootc uki-addon** | Perform operations related to UKI Addons | diff --git a/tmt/plans/integration.fmf b/tmt/plans/integration.fmf index 2042e3cdb..b340d405c 100644 --- a/tmt/plans/integration.fmf +++ b/tmt/plans/integration.fmf @@ -340,6 +340,14 @@ execute: - /tmt/tests/tests/test-50-switch-zstd-chunked extra-skip_if_ostree: true +/plan-51-composefs-uki-addons: + summary: Test composefs UKI Addons + discover: + how: fmf + test: + - /tmt/tests/tests/test-51-composefs-uki-addons + extra-skip_if_ostree: true + /plan-52-install-repart: summary: Test bootc install to-disk with systemd-repart partitioning discover: diff --git a/tmt/tests/booted/tap.nu b/tmt/tests/booted/tap.nu index cbfc52da7..e51f8f863 100644 --- a/tmt/tests/booted/tap.nu +++ b/tmt/tests/booted/tap.nu @@ -85,7 +85,7 @@ rm -vrf /usr/lib/bootc/bound-images.d " } -export def make_uki_containerfile [containerfile: string, --erofs-version: string = ""] { +export def make_uki_containerfile [containerfile: string, --erofs-version: string = "", --addon-cmds: string = ""] { let erofs_version = if $erofs_version == "" { selected_erofs_version } else { @@ -142,6 +142,8 @@ export def make_uki_containerfile [containerfile: string, --erofs-version: strin --write-dumpfile-to /out/${kver}.dump \\ --seal-state ($seal_state) \\ --erofs-version ($erofs_version) + + ($addon_cmds) EOF FROM base-final diff --git a/tmt/tests/booted/test-composefs-gc-uki.nu b/tmt/tests/booted/test-composefs-gc-uki.nu index 3ea7d0c24..31230d1d1 100644 --- a/tmt/tests/booted/test-composefs-gc-uki.nu +++ b/tmt/tests/booted/test-composefs-gc-uki.nu @@ -30,11 +30,18 @@ def first_boot [] { RUN echo 'large-file-marker' | dd of=/usr/share/large-test-file conv=notrunc " - $containerfile = (tap make_uki_containerfile $containerfile) + let addon_cmds = " + mkdir -p /out/${kver}.efi.extra.d + ukify build --cmdline 'gc_test=1' --output /out/${kver}.efi.extra.d/gc-test.addon.efi + mkdir -p /out/loader/addons + ukify build --cmdline 'gc_test=global' --output /out/loader/addons/gc-test-global.addon.efi + " + + $containerfile = (tap make_uki_containerfile $containerfile --addon-cmds $addon_cmds) echo $containerfile | podman build -t localhost/bootc-first . -f - - bootc switch --transport containers-storage localhost/bootc-first + bootc switch --transport containers-storage --uki-addon gc-test --global-uki-addon gc-test-global localhost/bootc-first # Make sure we have the .boot EROFS let st = bootc status --json | from json @@ -51,7 +58,6 @@ def first_boot [] { # Find the UKI in the objects directory # Intentionally not using the dump-files API here - # min/max depth = 1 to not include addons (for now) let uki_sha = sha512sum $"/var/tmp/efi/EFI/Linux/bootc/($uki_prefix)($st.status.booted.composefs.verity).efi" | awk '{print $1}' let uki_in_objs = ^find /sysroot/composefs/objects -type f -exec sha512sum {} + | grep ($uki_sha) | awk '{print $2}' @@ -69,6 +75,11 @@ def second_boot [] { assert equal $booted.image.image "localhost/bootc-first" assert ($"/var/tmp/efi/EFI/Linux/bootc/($uki_prefix)(cat /var/boot0-verity).efi" | path exists) + # The scoped addon dir from boot 1 (bootc-first) should exist + let boot1_addon_dir = $"/var/tmp/efi/EFI/Linux/bootc/($uki_prefix)($st.status.booted.composefs.verity).efi.extra.d" + assert ($boot1_addon_dir | path exists) + assert ($"($boot1_addon_dir)/gc-test.addon.efi" | path exists) + echo $st.status.booted.composefs.verity | save /var/boot1-verity let path = cat /var/large-file-marker-objpath @@ -77,7 +88,7 @@ def second_boot [] { mut containerfile = echo " FROM localhost/bootc as base RUN echo 'second' > /usr/share/second - " + " $containerfile = (tap make_uki_containerfile $containerfile) @@ -105,7 +116,7 @@ def third_boot [] { mut containerfile = echo " FROM localhost/bootc as base RUN echo 'third' > /usr/share/third - " + " $containerfile = (tap make_uki_containerfile $containerfile) @@ -128,6 +139,14 @@ def fourth_boot [] { assert (not ($"/var/tmp/efi/EFI/Linux/bootc/($uki_prefix)(cat /var/boot1-verity).efi" | path exists)) assert ($"/var/tmp/efi/EFI/Linux/bootc/($uki_prefix)(cat /var/boot2-verity).efi" | path exists) + # The scoped addon dir from boot 1 (bootc-first) should be gone + let boot1_addon_dir = $"/var/tmp/efi/EFI/Linux/bootc/($uki_prefix)(cat /var/boot1-verity).efi.extra.d" + assert (not ($boot1_addon_dir | path exists)) + + # The global addon should also be gone + let global_addon = $"/var/tmp/efi/loader/addons/bootc_composefs-gc-test-global.addon.efi" + assert (not ($global_addon | path exists)) + mut containerfile = " FROM localhost/bootc as base RUN echo 'another file' > /usr/share/another-one diff --git a/tmt/tests/booted/test-composefs-uki-addons.nu b/tmt/tests/booted/test-composefs-uki-addons.nu new file mode 100644 index 000000000..c0b1d1a60 --- /dev/null +++ b/tmt/tests/booted/test-composefs-uki-addons.nu @@ -0,0 +1,229 @@ +# number: 51 +# tmt: +# summary: Test composefs UKI Addons +# duration: 30m +# extra: +# skip_if_ostree: true + + +use std assert +use tap.nu + +bootc status +let st = bootc status --json | from json +let booted = $st.status.booted.image + +let is_uki = (($st.status.booted.composefs.bootType | str downcase) == "uki") + +if not $is_uki { + exit 0 +} + +def first_boot [] { + bootc image copy-to-storage + + mut containerfile = $" + FROM localhost/bootc as base + RUN touch /usr/share/first + " + + let cmds = " + ukify build --cmdline 'johan=liebert' --output /out/${kver}.efi.extra.d/monster-cmdline.addon.efi + mkdir -p '/out/loader/addons' + ukify build --cmdline 'kenzo=tenma' --output /out/loader/addons/global-cmdline.addon.efi + " + + $containerfile = (tap make_uki_containerfile $containerfile --addon-cmds $cmds) + + echo $containerfile | podman build -t localhost/bootc-uki-addons . -f - + + # No addons should be included + bootc switch --transport containers-storage localhost/bootc-uki-addons + + tmt-reboot +} + +def second_boot [] { + mkdir /var/tmp/efi + mount /dev/disk/by-partlabel/EFI-SYSTEM /var/tmp/efi + + # Make sure no addons were included + assert ((^find /var/tmp/efi -type f -name '*addon.efi' | ^wc -l | str trim | into int) == 0) + + mut containerfile = $" + FROM localhost/bootc as base + RUN touch /usr/share/second + " + + let cmds = " + mkdir -p /out/${kver}.efi.extra.d + ukify build --cmdline 'johan=liebert' --output /out/${kver}.efi.extra.d/monster-cmdline.addon.efi + mkdir -p '/out/loader/addons' + ukify build --cmdline 'kenzo=tenma' --output /out/loader/addons/global-cmdline.addon.efi + + # /run/target is taken from tap make_uki_containerfile 'FROM base as sealed-uki' + CFS_DIGEST=$\(bootc compute-composefs-digest /run/target\) + ukify build --cmdline 'composefs=${CFS_DIGEST}' --output /out/loader/addons/global-cfs-cmdline.addon.efi + " + + $containerfile = (tap make_uki_containerfile $containerfile --addon-cmds $cmds) + + echo $containerfile | podman build -t localhost/bootc-uki-addons-2 . -f - + + # Include composefs cmdline in global addon + # Should fail + let result_global = (do { + bootc switch --transport containers-storage --global-uki-addon global-cfs-cmdline localhost/bootc-uki-addons-2 + } | complete) + + assert ($result_global.exit_code != 0) "Global addon with composefs= should be rejected" + + # Include two addons, but don't include the global composefs= cmdline + # should succeed + bootc switch --transport containers-storage --uki-addon monster-cmdline --global-uki-addon global-cmdline localhost/bootc-uki-addons-2 + + tmt-reboot +} + + +def third_boot [] { + mkdir /var/tmp/efi + mount /dev/disk/by-partlabel/EFI-SYSTEM /var/tmp/efi + + # We should have two addons + assert ((^find /var/tmp/efi -type f -name '*addon.efi' | ^wc -l | str trim | into int) == 2) + + # We should have those in the cmdline + assert (open /proc/cmdline | str contains "johan=liebert") + assert (open /proc/cmdline | str contains "kenzo=tenma") + + mut containerfile = $" + FROM localhost/bootc as base + RUN touch /usr/share/third + " + + # Put the composefs= in a local addon + let cmds = " + mkdir -p /out/${kver}.efi.extra.d + ukify build --cmdline 'berserk=guts' --output /out/${kver}.efi.extra.d/berserk-cmdline.addon.efi + + # /run/target is taken from tap make_uki_containerfile 'FROM base as sealed-uki' + CFS_DIGEST=$\(bootc compute-composefs-digest /run/target\) + ukify build --cmdline 'composefs=${CFS_DIGEST}' --output /out/${kver}.efi.extra.d/local-cfs-cmdline.addon.efi + + mkdir -p '/out/loader/addons' + ukify build --cmdline 'pink=floyd' --output /out/loader/addons/global-cmdline.addon.efi + " + + $containerfile = (tap make_uki_containerfile $containerfile --addon-cmds $cmds) + + echo $containerfile | podman build -t localhost/bootc-uki-addons-3 . -f - + + # Include two composefs cmdlines + # Should fail + let result_local = (do { + bootc switch --transport containers-storage --uki-addon local-cfs-cmdline localhost/bootc-uki-addons-3 + } | complete) + + assert ($result_local.exit_code != 0) "Two composefs cmdline should've been rejected" + + # Requesting a CLI addon that isn't present in the image must fail the switch + let missing_addon = (do { + bootc switch --transport containers-storage --uki-addon does-not-exist localhost/bootc-uki-addons-3 + } | complete) + assert ($missing_addon.exit_code != 0) "Switch requesting a non-existent UKI addon should fail" + + + # This should update the global cmdline because we have the same name + # Also this shouldn't include the local cmdline addon so we're good and this should pass + bootc switch --transport containers-storage --uki-addon berserk-cmdline localhost/bootc-uki-addons-3 + + tmt-reboot +} + +def fourth_boot [] { + mkdir /var/tmp/efi + mount /dev/disk/by-partlabel/EFI-SYSTEM /var/tmp/efi + + # We should have three addons + # One from the previous deployment + # One global addon and one from the current deployment + assert ((^find /var/tmp/efi -type f -name '*addon.efi' | ^wc -l | str trim | into int) == 3) + + # Addon should be present, but not for this deployment + assert (not (open /proc/cmdline | str contains "johan=liebert")) + # Global addon should've been updated + assert (not (open /proc/cmdline | str contains "kenzo=tenma")) + + assert (open /proc/cmdline | str contains "berserk=guts") + # Global addon should've been updated + assert (open /proc/cmdline | str contains "pink=floyd") + + # --- CLI tests --- + + # list --json should return 3 addons + let addons = bootc uki-addon list --json | from json + assert (($addons | length) == 3) + + # Verify the global addon is present + let globals = $addons | where addon_type.type == "global" + assert (($globals | length) == 1) + assert ($globals.0.name == "global-cmdline") + + # Verify we have two scoped addons + let scoped = $addons | where addon_type.type == "scoped" + assert (($scoped | length) == 2) + + # Human-readable list should not error + bootc uki-addon list + + # Remove the global addon + bootc uki-addon remove global-cmdline + let addons_after_remove = bootc uki-addon list --json | from json + assert (($addons_after_remove | length) == 2) + let globals_after = $addons_after_remove | where addon_type.type == "global" + assert (($globals_after | length) == 0) + + # Add it back from the booted image + bootc uki-addon add global-cmdline global + let addons_after_add = bootc uki-addon list --json | from json + assert (($addons_after_add | length) == 3) + let globals_readded = $addons_after_add | where addon_type.type == "global" + assert (($globals_readded | length) == 1) + assert ($globals_readded.0.name == "global-cmdline") + + # Adding the same addon again should be a no-op + bootc uki-addon add global-cmdline global + + # Remove a scoped addon from the old deployment + let old_scoped = $scoped | where name == "monster-cmdline" + assert (($old_scoped | length) == 1) + let old_depl_id = $old_scoped.0.addon_type.depl_id + bootc uki-addon remove monster-cmdline $old_depl_id + let addons_final = bootc uki-addon list --json | from json + assert (($addons_final | length) == 2) + + # Removing a non-existent addon should fail + let failed = (do { bootc uki-addon remove nonexistent-addon } | complete) + assert ($failed.exit_code != 0) + + # Adding an addon that carries a composefs= cmdline should be rejected. + let cfs_add = (do { bootc uki-addon add local-cfs-cmdline scoped } | complete) + assert ($cfs_add.exit_code != 0) "Adding an addon containing composefs= should be rejected" + + # The rejected add must not have installed anything + let addons_after_reject = bootc uki-addon list --json | from json + assert (($addons_after_reject | length) == 2) + + 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/tests.fmf b/tmt/tests/tests.fmf index c78d42d7d..b99c4044a 100644 --- a/tmt/tests/tests.fmf +++ b/tmt/tests/tests.fmf @@ -212,6 +212,11 @@ check: duration: 30m test: nu booted/test-switch-zstd-chunked.nu +/test-51-composefs-uki-addons: + summary: Test composefs UKI Addons + duration: 30m + test: nu booted/test-composefs-uki-addons.nu + /test-52-install-repart: summary: Test bootc install to-disk with systemd-repart partitioning duration: 30m