diff --git a/contrib/packaging/finalize-uki b/contrib/packaging/finalize-uki index 7c54f1e2e6..b6774bf942 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 265fd58c4a..5163de1758 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::{ BLS_ENTRY_FILE_PREFIX, TYPE1_BOOT_DIR_PREFIX, TYPE1_ENT_PATH, TYPE1_ENT_PATH_STAGED, @@ -122,7 +124,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"; @@ -152,17 +154,17 @@ 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"; +/// Where all staged Global UKI Addons are kept +pub(crate) const STAGED_GLOBAL_UKI_ADDONS_DIR: &str = "loader/addons.staged"; + #[derive(thiserror::Error, Debug)] pub(crate) enum UKIDigestMismatch { #[error("The UKI has the wrong composefs= parameter (is '{actual}', should be '{expected}')")] @@ -220,6 +222,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; @@ -321,7 +326,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<'_> { @@ -553,10 +565,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 @@ -827,7 +847,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()?; @@ -1063,7 +1083,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. @@ -1259,8 +1279,13 @@ fn pe_output_dir( mounted_efi: &Path, file_path: &Utf8Path, uki_id: &Sha512HashValue, + is_upgrade: bool, ) -> std::path::PathBuf { if matches!(pe_type, PEType::GlobalUkiAddon) { + if is_upgrade { + return mounted_efi.join(STAGED_GLOBAL_UKI_ADDONS_DIR); + } + return mounted_efi.join(GLOBAL_UKI_ADDONS_DIR); } @@ -1283,106 +1308,210 @@ 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") } + } +} + +#[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 osrel = uki::get_text_section_buffered(uki_reader, ".osrel")?; + + let parsed_osrel = OsReleaseInfo::parse(&osrel); + + uki_reader.seek(SeekFrom::Start(0))?; + let boot_digest = compute_boot_digest_uki(uki_reader)?; + + uki_reader.seek(SeekFrom::Start(0))?; + + 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; + + 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"); + } }; - let mut boot_label: Option = None; + tracing::debug!("cmdline found in {pe_type:?}: {cmdline_str}"); - // UKI Extension might not even have a cmdline - // TODO: UKI Addon might also have a composefs= cmdline? - if matches!(pe_type, PEType::Uki) { - let cmdline = uki::get_cmdline_buffered(&mut uki_reader).context("Getting UKI cmdline")?; + let composefs_candidates = parse_uki_composefs_candidates(&cmdline_str) + .context("Parsing composefs kernel arguments")?; - 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(); + // 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(()); + }; - 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." - ); + // 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"); + } - // 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" - ); - } + let missing_verity_allowed_cmdline = uki_candidates_policy(&composefs_candidates)?; + let composefs_digest = primary_uki_candidate(&composefs_candidates).digest.clone(); - false if missing_verity_allowed_cmdline => { - tracing::warn!("UKI cmdline has composefs set as insecure"); - } + // 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:?}"); + }; - _ => { /* no-op */ } + 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-verity passed as option but UKI cmdline does not support it" + ); } - validate_uki_candidates( - &composefs_candidates, - boot_ids, - file_path.file_name().map(|name| name.to_string()), - )?; + false if missing_verity_allowed_cmdline => { + tracing::warn!("UKI cmdline has composefs set as insecure"); + } - uki_reader.seek(SeekFrom::Start(0))?; - let osrel = uki::get_text_section_buffered(&mut uki_reader, ".osrel")?; + _ => { /* no-op */ } + } - let parsed_osrel = OsReleaseInfo::parse(&osrel); + validate_uki_candidates(&composefs_candidates, boot_ids, Some(uki_name.into()))?; - uki_reader.seek(SeekFrom::Start(0))?; - let boot_digest = compute_boot_digest_uki(&mut uki_reader)?; + uki_info.composefs_digest = Some(composefs_digest); - 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, - }); + Ok(()) +} + +/// Copies Global UKI Addons from loader/addons to loader/addons.staged in case of +/// an upgrade/switch +#[context("Staging Global UKI Addons")] +fn stage_global_uki_addons( + esp_fd: &Dir, + entries_to_write: &Vec>, +) -> Result<()> { + if !entries_to_write + .iter() + .any(|ent| matches!(ent.pe_type, PEType::GlobalUkiAddon)) + { + // No Global UKI Addons to stage + return Ok(()); } - let final_pe_path = pe_output_dir(&pe_type, mounted_efi.as_ref(), file_path, uki_id); + let Some(global_addons_dir) = esp_fd.open_dir_optional(GLOBAL_UKI_ADDONS_DIR)? else { + // No current Global UKI Addons to copy + return Ok(()); + }; + + esp_fd + .create_dir_all(STAGED_GLOBAL_UKI_ADDONS_DIR) + .context("Creating directory for staged Global UKI Addons")?; + + let staged_addons_dir = esp_fd + .open_dir(STAGED_GLOBAL_UKI_ADDONS_DIR) + .context("Opening staged Global UKI Addons dir")?; + + // Now copy all addons to the staged directory + for ent in global_addons_dir + .entries_utf8() + .context("Reading entries in Global UKI directory")? + { + let entry = ent?; + let entry_name = entry.file_name().context("Reading file name")?; + global_addons_dir + .copy(&entry_name, &staged_addons_dir, &entry_name) + .with_context(|| format!("Copying addon {entry_name}"))?; + } + + 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, + is_upgrade: bool, +) -> 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, + is_upgrade, + ); 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))?; @@ -1397,7 +1526,7 @@ fn write_pe_to_esp( ) .context("fsync")?; - Ok(boot_label) + Ok(()) } fn uki_file_name(file_path: &Path) -> Result { @@ -1782,7 +1911,13 @@ 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()?; @@ -1792,33 +1927,132 @@ pub(crate) fn setup_composefs_uki_boot( // into a bootc system 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(), + }, + staged: false, + }); + } + + for addon in addon_opts.global.iter().flatten() { + addons.push(UkiAddonsList { + name: addon.into(), + addon_type: UkiAddonType::Global, + staged: false, + }); + } + ( 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(); + // 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, &host.status.staged)? + .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, + staged: false, + }); + } + + 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(), + }, + staged: false, + }); + } + } + ( find_booted_from_esp(&storage.physical_root)?, 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 { @@ -1831,10 +2065,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() @@ -1845,46 +2075,90 @@ 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; + let is_upgrade = matches!(setup_type, BootSetupType::Upgrade(..)); + + // Create a `ESP/loader/addons.staged` if we have Global UKI Addons to update + if is_upgrade { + stage_global_uki_addons(&esp_mount.fd, &entries_to_write)?; + } + + for entry in entries_to_write { + write_pe_to_esp(repo, &entry, &deploy_id, esp_mount.dir.path(), is_upgrade)?; + } + match bootloader.kind()? { BootloaderKind::GRUBClassic => { write_grub_uki_menuentry(&setup_type, boot_label, &deploy_id, &esp_device)? @@ -2351,7 +2625,13 @@ mod tests { // A UKI itself always lands directly in BOOTC_UKI_DIR. assert_eq!( - pe_output_dir(&PEType::Uki, mounted_efi, Utf8Path::new("foo.efi"), &uki_id), + pe_output_dir( + &PEType::Uki, + mounted_efi, + Utf8Path::new("foo.efi"), + &uki_id, + false + ), mounted_efi.join(BOOTC_UKI_DIR) ); @@ -2362,7 +2642,8 @@ mod tests { &PEType::UkiAddon, mounted_efi, Utf8Path::new("foo.efi.extra.d/bar.addon.efi"), - &uki_id + &uki_id, + false, ), mounted_efi .join(BOOTC_UKI_DIR) @@ -2376,10 +2657,23 @@ mod tests { &PEType::GlobalUkiAddon, mounted_efi, Utf8Path::new("bar.addon.efi"), - &uki_id + &uki_id, + false ), mounted_efi.join(GLOBAL_UKI_ADDONS_DIR) ); + + // On upgrade/switch global addons are written to `loader/addons.staged` + assert_eq!( + pe_output_dir( + &PEType::GlobalUkiAddon, + mounted_efi, + Utf8Path::new("bar.addon.efi"), + &uki_id, + true + ), + mounted_efi.join(STAGED_GLOBAL_UKI_ADDONS_DIR) + ); } #[test] diff --git a/crates/lib/src/bootc_composefs/finalize.rs b/crates/lib/src/bootc_composefs/finalize.rs index 642e8d6d40..c561a0a09d 100644 --- a/crates/lib/src/bootc_composefs/finalize.rs +++ b/crates/lib/src/bootc_composefs/finalize.rs @@ -1,6 +1,6 @@ use std::path::Path; -use crate::bootc_composefs::boot::BootType; +use crate::bootc_composefs::boot::{BootType, GLOBAL_UKI_ADDONS_DIR, STAGED_GLOBAL_UKI_ADDONS_DIR}; use crate::bootc_composefs::gc::{GCOpts, composefs_gc}; use crate::bootc_composefs::rollback::{rename_exchange_bls_entries, rename_exchange_user_cfg}; use crate::bootc_composefs::status::get_composefs_status; @@ -16,7 +16,7 @@ use cap_std_ext::dirext::CapStdExtDirExt; use composefs::generic_tree::{FileSystem, Stat}; use composefs_ctl::composefs; use etc_merge::{Diff, compute_diff, merge, traverse_etc}; -use rustix::fs::{Mode, OFlags, fsync}; +use rustix::fs::{Mode, OFlags, RenameFlags, fsync, renameat_with}; use fn_error_context::context; @@ -138,6 +138,26 @@ pub(crate) async fn composefs_backend_finalize( let boot_dir = storage.require_boot_dir()?; + // Un-stage Global UKI Addons (if present) as the penultimate step + if boot_dir.exists(STAGED_GLOBAL_UKI_ADDONS_DIR) { + tracing::debug!( + "Atomically exchanging {STAGED_GLOBAL_UKI_ADDONS_DIR} and {GLOBAL_UKI_ADDONS_DIR}" + ); + + renameat_with( + &boot_dir, + STAGED_GLOBAL_UKI_ADDONS_DIR, + &boot_dir, + GLOBAL_UKI_ADDONS_DIR, + RenameFlags::EXCHANGE, + ) + .context("Atomically exchanging staged Global UKI Addons")?; + + boot_dir + .remove_dir_all(STAGED_GLOBAL_UKI_ADDONS_DIR) + .context("Removing staged Global UKI Addons dir")?; + } + match booted_composefs.bootloader.kind()? { BootloaderKind::GRUBClassic => match staged_composefs.boot_type { BootType::Bls => { diff --git a/crates/lib/src/bootc_composefs/gc.rs b/crates/lib/src/bootc_composefs/gc.rs index 06fc1f090a..5bcfc0981f 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()? { @@ -212,6 +214,79 @@ fn unreferenced_boot_binaries<'a>( .collect() } +#[fn_error_context::context("GC Global UKI Addons")] +fn gc_global_uki_addons( + storage: &Storage, + booted_cfs: &BootedComposefs, + gc_opts: &GCOpts, + bootloader_entries: &Vec, +) -> Result<()> { + // GC always runs *after* finalizing deployments so there would be no staged deployment + // by definition + let currently_installed_addons = list_installed_uki_addons(storage, &None)?; + + if !currently_installed_addons + .iter() + .any(|addon| addon.addon_type == UkiAddonType::Global) + { + tracing::debug!("No bootc managed Global UKI Addon installed"); + return Ok(()); + } + + // Remove unreferenced global UKI Addons + // GC always runs *after* finalizing deployments so there would be no staged deployment + // by definition + let currently_referenced_addons = + list_referenced_uki_addons(booted_cfs, &bootloader_entries, &None)?; + 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}"))?; + } + } + + Ok(()) +} + pub(crate) struct GCOpts { pub(crate) dry_run: bool, pub(crate) prune_repo: bool, @@ -306,7 +381,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 +390,10 @@ pub(crate) async fn composefs_gc( } } + if booted_cfs_status.boot_type == BootType::Uki { + gc_global_uki_addons(storage, booted_cfs, &gc_opts, &bootloader_entries)?; + } + 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 fe5bc9c6a5..a33c80017e 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 0cb0b9ebea..f91c2dc63f 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 0000000000..0d5abbc3d3 --- /dev/null +++ b/crates/lib/src/bootc_composefs/uki_addon.rs @@ -0,0 +1,304 @@ +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, STAGED_GLOBAL_UKI_ADDONS_DIR}, + status::BootloaderEntry, + }, + composefs_consts::UKI_NAME_PREFIX, + spec::BootEntry, + 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, + /// Whether this addon is part of a staged deployment + pub staged: bool, +} + +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, + staged: bool, +) -> 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_type { + UkiAddonType::Global => { + let Some(addon_name) = addon_name.strip_prefix(UKI_NAME_PREFIX) else { + tracing::info!("Global UKI Addon not managed by bootc found: {addon_name}"); + continue; + }; + + addons.push(UkiAddonsList { + name: addon_name.to_string(), + addon_type: addon_type.clone(), + staged, + }); + } + UkiAddonType::Scoped { .. } => { + addons.push(UkiAddonsList { + name: addon_name.to_string(), + addon_type: addon_type.clone(), + staged, + }); + } + } + } + + 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>, + staged_deployment: &Option, +) -> Result> { + let mut addons_list: Vec = vec![]; + + for dir in [GLOBAL_UKI_ADDONS_DIR, STAGED_GLOBAL_UKI_ADDONS_DIR] { + if let Some(global_dir) = boot_dir.open_dir_optional(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, + staged: dir == STAGED_GLOBAL_UKI_ADDONS_DIR, + }); + } + } + } + } + + let Some(efi_linux) = boot_dir.open_dir_optional(EFI_LINUX)? else { + return Ok(addons_list); + }; + + let staged_deployment_id = match staged_deployment { + Some(entry) => Some(entry.require_composefs()?.verity.as_str()), + None => None, + }; + + 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()), + }, + staged: staged_deployment_id + .is_some_and(|staged_id| Some(staged_id) == depl_id), + }); + } + } + } + + Ok(addons_list) +} + +#[context("Listing UKI Addons")] +pub fn list_installed_uki_addons( + storage: &Storage, + staged_deployment: &Option, +) -> 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, false) + .context("Gathering global addons")?; + }; + + if let Some(global_dir) = esp.fd.open_dir_optional(STAGED_GLOBAL_UKI_ADDONS_DIR)? { + gather_addons_from_dir(&global_dir, &mut addons, UkiAddonType::Global, true) + .context("Gathering staged global addons")?; + }; + + let Some(bootc_uki_dir) = esp + .fd + .open_dir_optional(BOOTC_UKI_DIR) + .context("Opening UKI dir")? + else { + return Ok(addons); + }; + + let staged_deployment_id = match staged_deployment { + Some(ent) => Some(ent.require_composefs()?.verity.as_str()), + None => None, + }; + + 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(), + }, + staged_deployment_id.is_some_and(|verity| verity == depl_id), + )?; + } + + 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, + staged_deployment: &Option, +) -> 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), staged_deployment)?; + + // 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 0000000000..87ca2052a6 --- /dev/null +++ b/crates/lib/src/bootc_composefs/uki_addons_cli.rs @@ -0,0 +1,470 @@ +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::STAGED_GLOBAL_UKI_ADDONS_DIR; +use crate::spec::Host; +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, + host: &Host, + 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, &host.status.staged)?; + + 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, + addon_type, + } => { + let addons = list_installed_uki_addons(storage, &host.status.staged)?; + + let is_staged = host.status.staged.is_some(); + + match addon_type { + UkiAddonScope::Scoped => { + // If we have a staged deployment, we'll operate on that + let depl_id = match &host.status.staged { + Some(staged) => staged.require_composefs()?.verity.as_str(), + None => &*booted_cfs.cmdline.digest, + }; + + let found = addons.iter().any(|addon| { + matches!( + &addon.addon_type, + UkiAddonType::Scoped { depl_id: id } if id == depl_id + ) && addon.name == *addon_name + && addon.staged == is_staged + }); + + 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()); + } + } + + UkiAddonScope::Global => { + let found = addons.iter().any(|addon| { + addon.addon_type == UkiAddonType::Global + && addon.name == *addon_name + && addon.staged == is_staged + }); + + 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}"); + + let global_addons_dir = if is_staged { + STAGED_GLOBAL_UKI_ADDONS_DIR + } else { + GLOBAL_UKI_ADDONS_DIR + }; + + esp.fd + .remove_file(format!("{global_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_addons_dir) + .context("Opening global addons dir")? + .entries() + .context("Getting global addons dir entries")? + .count(); + + // Remove the directory if it's now empty. + if num_ents == 0 { + esp.fd + .remove_dir(global_addons_dir) + .context("Removing global addons dir")?; + + println!("Removed empty directory {global_addons_dir}"); + + // The staged global addon set is a transient overlay that + // finalize exchanges with the live set on the next boot. An + // empty overlay is collapsed rather than applied, so removing + // the last staged global addon leaves the currently active + // global addons in effect after reboot. + if is_staged { + println!( + "Note: '{addon_name}' was the last staged global addon. \ + An empty staged global addon set is not applied on boot, so \ + the currently active global addons remain in effect after \ + reboot. To drop all global addons, remove them while no \ + deployment is staged." + ); + } + } + } + } + } + + UkiAddonCliOpts::Add { + name: addon_name, + addon_type, + } => { + // If we have a staged deployment, we'll operate on that + let deployment_id = match &host.status.staged { + Some(staged) => staged.require_composefs()?.verity.as_str(), + None => &*booted_cfs.cmdline.digest, + }; + + let is_staged = host.status.staged.is_some(); + + // This should never fail + // This is either the staged/booted digest + let deployment_digest = Sha512HashValue::from_hex(deployment_id.as_bytes()) + .with_context(|| { + format!("Composefs deployment has bad FSVerity: {deployment_id}") + })?; + + let addons = list_installed_uki_addons(storage, &host.status.staged)?; + + let already_present = addons.iter().any(|addon| match addon_type { + UkiAddonScope::Global => { + addon.addon_type == UkiAddonType::Global + && addon.name == *addon_name + && addon.staged == is_staged + } + UkiAddonScope::Scoped => { + matches!( + &addon.addon_type, + UkiAddonType::Scoped { depl_id: id } if *id == deployment_digest.to_hex() + ) && addon.name == *addon_name + && addon.staged == is_staged + } + }); + + if already_present { + println!("Addon {addon_name} is already present. Nothing to do."); + return Ok(()); + } + + let linked_images = linked_erofs_images(&booted_cfs.repo, &deployment_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 => { + let global_addons_dir = if is_staged { + STAGED_GLOBAL_UKI_ADDONS_DIR + } else { + GLOBAL_UKI_ADDONS_DIR + }; + + esp.fd + .create_dir_all(global_addons_dir) + .context("Creating global addons directory")?; + + let global_addons_dir = esp + .fd + .open_dir(global_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(&deployment_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")?; + + match addon_type { + UkiAddonScope::Global => println!("Global UKI Addon {addon_name} added"), + UkiAddonScope::Scoped => { + println!( + "UKI Addon {addon_name} added to {} deployment", + if host.status.staged.is_some() { + "staged" + } else { + "booted" + } + ) + } + } + } + UkiAddonCliOpts::ListReferenced { json } => { + let referenced = list_referenced_uki_addons( + booted_cfs, + &list_bootloader_entries(storage)?, + &host.status.staged, + )?; + + 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 c0dcafe346..5bd05f0813 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 0a7c0e5987..18e1cdd45e 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -39,10 +39,13 @@ use ostree_ext::sysroot::SysrootLock; use schemars::schema_for; use serde::{Deserialize, Serialize}; +use crate::bootc_composefs::boot::BootType; 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::status::get_composefs_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 +55,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; @@ -101,7 +105,7 @@ pub(crate) struct DownloadOnlyOpts { /// This unlocks the staged deployment without fetching updates from the container image source. /// The deployment will be applied on the next shutdown or reboot. Use with --apply to /// reboot immediately. - #[clap(long, conflicts_with = "download_only")] + #[clap(long, conflicts_with_all = ["download_only", "scoped", "global"])] pub(crate) from_downloaded: bool, } @@ -115,7 +119,7 @@ pub(crate) struct UpgradeOpts { /// Check if an update is available without applying it. /// /// This only downloads updated metadata, not the full image layers. - #[clap(long, conflicts_with_all = ["apply", "download_only", "from_downloaded"])] + #[clap(long, conflicts_with_all = ["apply", "download_only", "from_downloaded", "scoped", "global"])] pub(crate) check: bool, /// Restart or reboot into the new target image. @@ -143,6 +147,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 +221,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 +988,46 @@ 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 from the currently booted deployment. If there is + /// a staged deployment present, the operation is performed on the staged + /// deployment's addons + Remove { + /// Addon name to be provided without the `.addon.efi` suffix + name: String, + /// The type of addon being removed, global or scoped + addon_type: UkiAddonScope, + }, + /// Add a UKI Addon to the currently booted deployment. If there is + /// a staged deployment present, the operation is performed on the staged + /// deployment + Add { + /// Addon name to be provided without the `.addon.efi` suffix + name: String, + /// The type of addon being added, global or scoped + 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 +1159,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 +1787,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 +2150,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 +2833,25 @@ 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) => { + let status = get_composefs_status(storage, &booted_cfs).await?; + + if status.require_composefs_booted()?.boot_type != BootType::Uki { + anyhow::bail!( + "Cannot perform UKI Addon operations on systems not booted via UKI" + ); + } + + handle_addon_cli_cmd(storage, &booted_cfs, &status, &opts) + } + } + } }; result.map(|()| CliExitStatus::Success) } diff --git a/crates/lib/src/composefs_consts.rs b/crates/lib/src/composefs_consts.rs index c9022acf54..7df0b873ae 100644 --- a/crates/lib/src/composefs_consts.rs +++ b/crates/lib/src/composefs_consts.rs @@ -46,6 +46,12 @@ pub(crate) const TYPE1_BOOT_DIR_PREFIX: &str = "bootc_composefs-"; pub(crate) const BLS_ENTRY_FILE_PREFIX: &str = "bootc_"; /// 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 03eeb22172..594be3fccc 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -395,6 +395,25 @@ 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 ".addon.efi" suffix. + /// This option can be provided multiple times if multiple addons are to be installed + /// (composefs backend only). On upgrade and switch, scoped addons already installed + /// are carried forward; passing this flag adds further addons on top. + #[clap(long = "uki-addon")] + #[serde(default, rename = "uki_addon")] + pub(crate) scoped: Option>, + + /// Name of the global UKI addons to install without the ".addon.efi" suffix. + /// This option can be provided multiple times if multiple addons are to be installed + /// (composefs backend only). On upgrade and switch, global addons already installed + /// are carried forward; passing this flag adds further addons on top. + #[clap(long = "global-uki-addon")] + #[serde(default, rename = "global_uki_addon")] + 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 +428,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 { @@ -442,9 +458,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(()) } @@ -1683,6 +1703,7 @@ async fn prepare_install( target_fs: Option, ) -> Result> { tracing::trace!("Preparing install"); + 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 /")?; @@ -1824,6 +1845,13 @@ async fn prepare_install( composefs_options.validate_bound_images(config_opts.bound_images, &rootfs)?; + 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"); + } + // Read the file eagerly so we error out early, and before the mount changes // below hide a file bind mounted under e.g. /tmp. We may re-exec further down // and run this again with those mounts in place, so pass the content to the @@ -3199,7 +3227,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(), stateroot).is_ok(), diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index fe4d0ebfa2..86348f2d36 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/bootc-composefs.7.md b/docs/src/bootc-composefs.7.md index 66c556c0c4..6b86e3f855 100644 --- a/docs/src/bootc-composefs.7.md +++ b/docs/src/bootc-composefs.7.md @@ -123,9 +123,17 @@ These remain [experimental](https://github.com/bootc-dev/bootc/blob/main/RELEASE and may change or be removed: - The `grub-cc` bootloader (`--bootloader=grub-cc`). -- UKI addons (`--uki-addon`). Addons are only installed by `bootc install`: - they aren't updated on upgrade, garbage collected, or reverted on - rollback. +- UKI addons (--uki-addon, --global-uki-addon). On upgrade and switch, + addons already installed (scoped or global) are carried forward, and any + passed via the flags are added. Global addons are garbage collected once + no deployment references them, scoped addons are garbage collected along with + their respective deployments. While a deployment is staged, `bootc uki-addon + add`/`remove` for global addons operate on a staged copy at + `loader/addons.staged`, which is applied to the live `loader/addons` on the + next boot. Removing the last global addon empties and deletes + `loader/addons.staged`, so nothing is applied on the next boot and the live + global addons are kept; remove the last global addon when no deployment is + staged. - [Unified storage](bootc-experimental-unified-storage.7.md). ## Limitations diff --git a/docs/src/man/bootc-install-to-disk.8.md b/docs/src/man/bootc-install-to-disk.8.md index a68ece0078..e834291388 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 ".addon.efi" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only). On upgrade and switch, scoped addons already installed are carried forward; passing this flag adds further addons on top + +**--global-uki-addon**=*GLOBAL* + + Name of the global UKI addons to install without the ".addon.efi" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only). On upgrade and switch, global addons already installed are carried forward; passing this flag adds further addons on top **--run-repart** 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 3ff11166ed..88e7912107 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 ".addon.efi" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only). On upgrade and switch, scoped addons already installed are carried forward; passing this flag adds further addons on top + +**--global-uki-addon**=*GLOBAL* + + Name of the global UKI addons to install without the ".addon.efi" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only). On upgrade and switch, global addons already installed are carried forward; passing this flag adds further addons on top diff --git a/docs/src/man/bootc-install-to-filesystem.8.md b/docs/src/man/bootc-install-to-filesystem.8.md index 9549e156b2..a380d93e29 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 ".addon.efi" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only). On upgrade and switch, scoped addons already installed are carried forward; passing this flag adds further addons on top + +**--global-uki-addon**=*GLOBAL* + + Name of the global UKI addons to install without the ".addon.efi" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only). On upgrade and switch, global addons already installed are carried forward; passing this flag adds further addons on top diff --git a/docs/src/man/bootc-switch.8.md b/docs/src/man/bootc-switch.8.md index c32911e938..d7e2474bb4 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 ".addon.efi" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only). On upgrade and switch, scoped addons already installed are carried forward; passing this flag adds further addons on top + +**--global-uki-addon**=*GLOBAL* + + Name of the global UKI addons to install without the ".addon.efi" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only). On upgrade and switch, global addons already installed are carried forward; passing this flag adds further addons on top + # 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 0000000000..68910090f6 --- /dev/null +++ b/docs/src/man/bootc-uki-addon-add.8.md @@ -0,0 +1,51 @@ +# 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 `.addon.efi` suffix + + This argument is required. + +**ADDON_TYPE** + + The type of addon being added, global or scoped + + 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 0000000000..9939325db8 --- /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 0000000000..f371ed5461 --- /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 0000000000..126752b144 --- /dev/null +++ b/docs/src/man/bootc-uki-addon-remove.8.md @@ -0,0 +1,69 @@ +# 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. + +When a deployment is staged (after `bootc upgrade` or `bootc switch`), removing a +global addon operates on the staged copy at `loader/addons.staged`, not the live +`loader/addons`. The staged copy is applied to `loader/addons` on the next boot. + +Removing the last global addon empties `loader/addons.staged`, so that directory +is deleted. With no `loader/addons.staged` left, nothing is applied on the next +boot and the live `loader/addons` is kept as-is, so the removed global addon +comes back. To remove the last global addon, do it when no deployment is staged. + +# OPTIONS + + +**NAME** + + Addon name to be provided without the `.addon.efi` suffix + + This argument is required. + +**ADDON_TYPE** + + The type of addon being removed, global or scoped + + This argument is 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... + +Removing the last global addon while a deployment is staged. This command +reports success, but after reboot `site-config` is still installed because +`loader/addons.staged` was deleted and nothing replaced the live `loader/addons`: + + bootc uki-addon remove site-config global + +Reboot first so no deployment is staged, then run the same command to remove it. + +# 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 0000000000..f6ca4b1eee --- /dev/null +++ b/docs/src/man/bootc-uki-addon.8.md @@ -0,0 +1,72 @@ +# 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. Addons can carry extra kernel command-line parameters and other resources +that are 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 removed by garbage collection once no remaining + deployment references them. + +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). + +**list-referenced** +: List UKI addons referenced by deployed images. See **bootc-uki-addon-list-referenced**(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 + +List all referenced addons: + + bootc uki-addon list-referenced + +# SEE ALSO + +**bootc**(8), **bootc-uki-addon-list**(8), **bootc-uki-addon-add**(8), **bootc-uki-addon-remove**(8), **bootc-uki-addon-list-referenced**(8) + +# VERSION + + diff --git a/docs/src/man/bootc-upgrade.8.md b/docs/src/man/bootc-upgrade.8.md index 0a2face2bc..57c741020e 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 ".addon.efi" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only). On upgrade and switch, scoped addons already installed are carried forward; passing this flag adds further addons on top + +**--global-uki-addon**=*GLOBAL* + + Name of the global UKI addons to install without the ".addon.efi" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only). On upgrade and switch, global addons already installed are carried forward; passing this flag adds further addons on top + # EXAMPLES diff --git a/docs/src/man/bootc.8.md b/docs/src/man/bootc.8.md index c741f8f758..87affcaa07 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 90e02dc28b..04774a3b59 100644 --- a/tmt/plans/integration.fmf +++ b/tmt/plans/integration.fmf @@ -344,6 +344,14 @@ execute: because: only the image_mode test image installs bootc-tests 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 cbfc52da79..e51f8f8633 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 3ea7d0c245..31230d1d16 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 0000000000..213d7a3578 --- /dev/null +++ b/tmt/tests/booted/test-composefs-uki-addons.nu @@ -0,0 +1,268 @@ +# 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 + + # A switch/upgrade must *stage* the global addon under loader/addons.staged + # and leave the live loader/addons dir untouched until the next boot. + # Remount the ESP so our view is coherent with the mount bootc wrote through. + umount /var/tmp/efi + mount /dev/disk/by-partlabel/EFI-SYSTEM /var/tmp/efi + + assert ("/var/tmp/efi/loader/addons.staged/global-cmdline.addon.efi" | path exists) "Global addon should be staged under loader/addons.staged" + # This is the first global addon, so the live dir must not hold it yet + assert (not ("/var/tmp/efi/loader/addons/global-cmdline.addon.efi" | path exists)) "Live loader/addons must not be updated before reboot" + + 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) + + # finalize should have atomically promoted the staged global addon into the + # live loader/addons dir and removed the staged dir. + assert (not ("/var/tmp/efi/loader/addons.staged" | path exists)) "Staged loader/addons.staged must be gone after reboot" + assert ("/var/tmp/efi/loader/addons/global-cmdline.addon.efi" | path exists) "Global addon must be promoted to live loader/addons after reboot" + + # 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 + + # --- Test: removing the last global addon while a deployment is staged --- + # The switch above staged a deployment and carried the global addon forward + # into loader/addons.staged, while the live loader/addons is left intact. + # Remount the ESP for a view coherent with bootc's own mount. + umount /var/tmp/efi + mount /dev/disk/by-partlabel/EFI-SYSTEM /var/tmp/efi + + assert ("/var/tmp/efi/loader/addons.staged/global-cmdline.addon.efi" | path exists) "Global addon should be staged after switch" + assert ("/var/tmp/efi/loader/addons/global-cmdline.addon.efi" | path exists) "Live global addon must remain while staged" + + # Removing the last global addon while staged operates on loader/addons.staged: + # it empties and deletes that dir and warns that the removal won't take effect, + # leaving the live loader/addons untouched. + let remove_result = (do { bootc uki-addon remove global-cmdline global } | complete) + assert ($remove_result.exit_code == 0) "Removing the staged global addon should succeed" + assert ($remove_result.stdout | str contains "last staged global addon") "Should warn about removing the last staged global addon" + + umount /var/tmp/efi + mount /dev/disk/by-partlabel/EFI-SYSTEM /var/tmp/efi + + assert (not ("/var/tmp/efi/loader/addons.staged" | path exists)) "loader/addons.staged must be deleted after removing the last staged global addon" + assert ("/var/tmp/efi/loader/addons/global-cmdline.addon.efi" | path exists) "Live global addon must be kept" + + # Re-stage the global addon so fourth_boot sees the same state as before this + # check. With a deployment staged, `add` writes back into loader/addons.staged. + bootc uki-addon add global-cmdline global + + umount /var/tmp/efi + mount /dev/disk/by-partlabel/EFI-SYSTEM /var/tmp/efi + + assert ("/var/tmp/efi/loader/addons.staged/global-cmdline.addon.efi" | path exists) "Global addon should be re-staged" + + 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 global + 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 + + # 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 28300bfebe..ed74af7185 100644 --- a/tmt/tests/tests.fmf +++ b/tmt/tests/tests.fmf @@ -216,6 +216,11 @@ check: because: only the image_mode test image installs bootc-tests test: bootc-integration-tests booted switch-zstd-chunked +/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