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..7ec116c344 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, renameat_with}; use fn_error_context::context; @@ -138,6 +138,40 @@ 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) { + if boot_dir.exists(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")?; + } else { + tracing::debug!( + "No global addons in the current deployment. Finalizing global UKI Addons" + ); + + renameat( + &boot_dir, + STAGED_GLOBAL_UKI_ADDONS_DIR, + &boot_dir, + GLOBAL_UKI_ADDONS_DIR, + ) + .context("Finalizing global UKI Addons")?; + } + } + 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..eddc2b7a97 --- /dev/null +++ b/crates/lib/src/bootc_composefs/uki_addon.rs @@ -0,0 +1,387 @@ +use std::collections::HashSet; +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, +} + +#[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 { + let type_str = match &self.addon_type { + UkiAddonType::Global => "global", + UkiAddonType::Scoped { .. } => "scoped", + }; + + // (label, value) rows; the first is the bulleted header line. + let mut rows: Vec<(&str, &str)> = vec![("Addon", self.name.as_str()), ("Type", type_str)]; + + if let UkiAddonType::Scoped { depl_id } = &self.addon_type { + rows.push(("Deployment", depl_id.as_str())); + } + + rows.push(("Staged", if self.staged { "yes" } else { "no" })); + + // Left-align the labels and pad so every value starts in the same column. + let max_label = rows + .iter() + .map(|(label, _)| label.chars().count()) + .max() + .unwrap_or(0); + + // Width of the "