From f9ee463d97df7a517da5d5acb273f1979e067688 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Wed, 9 Sep 2026 12:54:07 +0530 Subject: [PATCH 01/18] uki-addon: Update addons on update/switch Introduce a function to gather all currently installed addons, scoped and global. On upgrade/switch, gather all installed addons and if an addon with the same name is found in the upgrade image, update that particular addon automatically Signed-off-by: Pragyan Poudyal --- crates/lib/src/bootc_composefs/boot.rs | 24 ++++-- crates/lib/src/bootc_composefs/mod.rs | 1 + crates/lib/src/bootc_composefs/uki_addon.rs | 96 +++++++++++++++++++++ 3 files changed, 115 insertions(+), 6 deletions(-) create mode 100644 crates/lib/src/bootc_composefs/uki_addon.rs diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 7723ed4da..33d84d8c8 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -100,6 +100,7 @@ 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::list_installed_uki_addons; use crate::bootc_kargs::compute_new_kargs; use crate::composefs_consts::{TYPE1_BOOT_DIR_PREFIX, TYPE1_ENT_PATH, TYPE1_ENT_PATH_STAGED}; use crate::parsers::bls_config::{BLSConfig, BLSConfigType, EFIKey}; @@ -1775,7 +1776,10 @@ 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 addons_to_update; + + let (esp_device, bootloader, missing_fsverity_allowed, uki_addons) = match setup_type + { BootSetupType::Setup((root_setup, state, postfetch, allow_missing_fsverity)) => { state.require_no_kargs_for_uki()?; @@ -1797,15 +1801,23 @@ pub(crate) fn setup_composefs_uki_boot( let root_dev = bootc_blockdev::list_dev_by_dir(&storage.physical_root)?; let esp_dev = root_dev.find_first_colocated_esp()?; + let installed_addons = list_installed_uki_addons(storage, booted_cfs)?; + + // If we find addons (that are currently installed) in the new image as well, + // we will update them + // + // TODO: This has a weird edge case where a local addon and global addon can have + // the same name. We can add a container lint for this + addons_to_update = installed_addons + .into_iter() + .map(|a| a.name) + .collect::>(); + ( esp_dev.path(), bootloader, booted_cfs.cmdline.allow_missing_fsverity, - // TODO: We never (re)install UKI addons on upgrade, only on initial - // `install`. This is especially relevant for global addons (see the - // TODO on `GLOBAL_UKI_ADDONS_DIR`): if a newer image changes or drops - // one, the ESP copy is never reconciled. - None, + Some(&addons_to_update), ) } }; diff --git a/crates/lib/src/bootc_composefs/mod.rs b/crates/lib/src/bootc_composefs/mod.rs index fe5bc9c6a..00ae9f34e 100644 --- a/crates/lib/src/bootc_composefs/mod.rs +++ b/crates/lib/src/bootc_composefs/mod.rs @@ -15,5 +15,6 @@ 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 update; pub(crate) mod utils; diff --git a/crates/lib/src/bootc_composefs/uki_addon.rs b/crates/lib/src/bootc_composefs/uki_addon.rs new file mode 100644 index 000000000..c59b649fb --- /dev/null +++ b/crates/lib/src/bootc_composefs/uki_addon.rs @@ -0,0 +1,96 @@ +#![allow(dead_code)] +use anyhow::{Context, Result}; +use cap_std_ext::cap_std::fs::Dir; +use cap_std_ext::dirext::CapStdExtDirExt; +use fn_error_context::context; +use ostree_ext::composefs_boot::bootloader::{EFI_ADDON_DIR_EXT, EFI_ADDON_FILE_EXT}; + +use crate::{ + bootc_composefs::boot::{BOOTC_UKI_DIR, GLOBAL_UKI_ADDONS_DIR}, + composefs_consts::UKI_NAME_PREFIX, + store::{BootedComposefs, Storage}, +}; + +#[derive(Debug, Clone)] +pub enum UkiAddonType { + Scoped { depl_id: String }, + Global, +} + +#[derive(Debug, Clone)] +pub struct UkiAddonsList { + pub name: String, + pub addon_type: UkiAddonType, +} + +fn gather_addons_from_dir( + dir: &Dir, + addons: &mut Vec, + addon_type: UkiAddonType, +) -> Result<()> { + for ent in dir.entries_utf8()? { + let ent = ent?; + let filename = ent.file_name()?; + + if let Some(addon_name) = filename.strip_suffix(EFI_ADDON_FILE_EXT) { + addons.push(UkiAddonsList { + name: addon_name.to_string(), + addon_type: addon_type.clone(), + }); + }; + } + + Ok(()) +} + +#[context("Listing UKI Addons")] +pub fn list_installed_uki_addons( + storage: &Storage, + booted_composefs: &BootedComposefs, +) -> 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)?; + }; + + for ent in esp + .fd + .open_dir(BOOTC_UKI_DIR) + .context("Opening UKI dir")? + .entries_utf8() + .context("Reading UKI dir entries")? + { + let ent = ent?; + let filename = ent.file_name()?; + + if !ent.file_type()?.is_dir() { + continue; + } + + let Some(dir_name) = filename.strip_suffix(EFI_ADDON_DIR_EXT) else { + continue; + }; + + let depl_id = dir_name.strip_prefix(UKI_NAME_PREFIX).unwrap_or(dir_name); + + let dir = esp + .fd + .open_dir(format!("{BOOTC_UKI_DIR}/{filename}")) + .with_context(|| format!("Opening {filename}"))?; + + gather_addons_from_dir( + &dir, + &mut addons, + UkiAddonType::Scoped { + depl_id: depl_id.to_string(), + }, + )?; + } + + Ok(addons) +} From 46ba15b0057ef56a55960f6b27064b4b054628da Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Wed, 9 Sep 2026 14:04:32 +0530 Subject: [PATCH 02/18] uki/addon: Support global addons We were partially supporting global addons, but they were lumped in with scoped/local addons. Add a new cli option to composefs installs called `--global-uki-addon` which would determine which global addon to install. Signed-off-by: Pragyan Poudyal --- crates/lib/src/bootc_composefs/boot.rs | 74 +++++++++++++------ crates/lib/src/bootc_composefs/uki_addon.rs | 7 +- crates/lib/src/install.rs | 12 ++- docs/src/man/bootc-install-to-disk.8.md | 6 +- .../man/bootc-install-to-existing-root.8.md | 6 +- docs/src/man/bootc-install-to-filesystem.8.md | 6 +- 6 files changed, 79 insertions(+), 32 deletions(-) diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 33d84d8c8..07b8b7c71 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -100,7 +100,7 @@ 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::list_installed_uki_addons; +use crate::bootc_composefs::uki_addon::{UkiAddonType, UkiAddonsList, list_installed_uki_addons}; use crate::bootc_kargs::compute_new_kargs; use crate::composefs_consts::{TYPE1_BOOT_DIR_PREFIX, TYPE1_ENT_PATH, TYPE1_ENT_PATH_STAGED}; use crate::parsers::bls_config::{BLSConfig, BLSConfigType, EFIKey}; @@ -1776,8 +1776,6 @@ pub(crate) fn setup_composefs_uki_boot( boot_ids: &ExpectedBootImageIds, entries: Vec>, ) -> Result<(String, Sha512HashValue)> { - let addons_to_update; - let (esp_device, bootloader, missing_fsverity_allowed, uki_addons) = match setup_type { BootSetupType::Setup((root_setup, state, postfetch, allow_missing_fsverity)) => { @@ -1786,11 +1784,33 @@ pub(crate) fn setup_composefs_uki_boot( // Locate ESP partition device by walking up to the root disk(s) let esp_part = root_setup.device_info.find_first_colocated_esp()?; + let mut addons: Vec = vec![]; + + if let Some(local_addons) = &state.composefs_options.uki_addon { + for addon in local_addons { + addons.push(UkiAddonsList { + name: addon.into(), + addon_type: UkiAddonType::Scoped { + depl_id: id.to_hex(), + }, + }); + } + }; + + if let Some(global_addons) = &state.composefs_options.global_uki_addon { + for addon in global_addons { + addons.push(UkiAddonsList { + name: addon.into(), + addon_type: UkiAddonType::Global, + }); + } + }; + ( esp_part.path(), postfetch.detected_bootloader.clone(), allow_missing_fsverity, - state.composefs_options.uki_addon.as_ref(), + addons, ) } @@ -1801,23 +1821,13 @@ pub(crate) fn setup_composefs_uki_boot( let root_dev = bootc_blockdev::list_dev_by_dir(&storage.physical_root)?; let esp_dev = root_dev.find_first_colocated_esp()?; - let installed_addons = list_installed_uki_addons(storage, booted_cfs)?; - - // If we find addons (that are currently installed) in the new image as well, - // we will update them - // - // TODO: This has a weird edge case where a local addon and global addon can have - // the same name. We can add a container lint for this - addons_to_update = installed_addons - .into_iter() - .map(|a| a.name) - .collect::>(); + let installed_addons = list_installed_uki_addons(storage)?; ( esp_dev.path(), bootloader, booted_cfs.cmdline.allow_missing_fsverity, - Some(&addons_to_update), + installed_addons, ) } }; @@ -1837,10 +1847,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() @@ -1854,8 +1860,32 @@ pub(crate) fn setup_composefs_uki_boot( anyhow::anyhow!("UKI addon doesn't end with {EFI_ADDON_DIR_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.iter().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.iter().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; + } + } } } diff --git a/crates/lib/src/bootc_composefs/uki_addon.rs b/crates/lib/src/bootc_composefs/uki_addon.rs index c59b649fb..cfd89b84e 100644 --- a/crates/lib/src/bootc_composefs/uki_addon.rs +++ b/crates/lib/src/bootc_composefs/uki_addon.rs @@ -8,7 +8,7 @@ use ostree_ext::composefs_boot::bootloader::{EFI_ADDON_DIR_EXT, EFI_ADDON_FILE_E use crate::{ bootc_composefs::boot::{BOOTC_UKI_DIR, GLOBAL_UKI_ADDONS_DIR}, composefs_consts::UKI_NAME_PREFIX, - store::{BootedComposefs, Storage}, + store::Storage, }; #[derive(Debug, Clone)] @@ -44,10 +44,7 @@ fn gather_addons_from_dir( } #[context("Listing UKI Addons")] -pub fn list_installed_uki_addons( - storage: &Storage, - booted_composefs: &BootedComposefs, -) -> Result> { +pub fn list_installed_uki_addons(storage: &Storage) -> Result> { let mut addons = vec![]; let Ok(esp) = storage.require_esp() else { diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 8f9f80fc4..116f04e16 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -409,12 +409,19 @@ pub(crate) struct InstallComposefsOpts { #[serde(default)] pub(crate) allow_missing_verity: bool, - /// Name of the UKI addons to install without the ".efi.addon" suffix. + /// Name of the local/scoped UKI addons to install without the ".efi.addon" suffix. /// This option can be provided multiple times if multiple addons are to be installed /// (composefs backend only). - #[clap(long)] + #[clap(long, requires = "composefs_backend")] #[serde(default)] pub(crate) uki_addon: Option>, + + /// Name of the global UKI addons to install without the ".efi.addon" suffix. + /// This option can be provided multiple times if multiple addons are to be installed + /// (composefs backend only). + #[clap(long, requires = "composefs_backend")] + #[serde(default)] + pub(crate) global_uki_addon: Option>, } impl InstallComposefsOpts { @@ -3149,6 +3156,7 @@ mod tests { composefs_backend, allow_missing_verity, uki_addon, + global_uki_addon: None, }; assert_eq!( opts.validate(bootloader.as_ref()).is_ok(), diff --git a/docs/src/man/bootc-install-to-disk.8.md b/docs/src/man/bootc-install-to-disk.8.md index 776c01ab1..a8dcb941c 100644 --- a/docs/src/man/bootc-install-to-disk.8.md +++ b/docs/src/man/bootc-install-to-disk.8.md @@ -188,7 +188,11 @@ set `discoverable-partitions = true` in their install configuration **--uki-addon**=*UKI_ADDON* - Name of the UKI addons to install without the ".efi.addon" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) + Name of the local/scoped UKI addons to install without the ".efi.addon" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) + +**--global-uki-addon**=*GLOBAL_UKI_ADDON* + + Name of the global UKI addons to install without the ".efi.addon" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) diff --git a/docs/src/man/bootc-install-to-existing-root.8.md b/docs/src/man/bootc-install-to-existing-root.8.md index 3ff11166e..e16677621 100644 --- a/docs/src/man/bootc-install-to-existing-root.8.md +++ b/docs/src/man/bootc-install-to-existing-root.8.md @@ -229,7 +229,11 @@ of migrating the fstab entries. See the "Injecting kernel arguments" section abo **--uki-addon**=*UKI_ADDON* - Name of the UKI addons to install without the ".efi.addon" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) + Name of the local/scoped UKI addons to install without the ".efi.addon" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) + +**--global-uki-addon**=*GLOBAL_UKI_ADDON* + + Name of the global UKI addons to install without the ".efi.addon" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) diff --git a/docs/src/man/bootc-install-to-filesystem.8.md b/docs/src/man/bootc-install-to-filesystem.8.md index 9549e156b..ab0bbbf2a 100644 --- a/docs/src/man/bootc-install-to-filesystem.8.md +++ b/docs/src/man/bootc-install-to-filesystem.8.md @@ -138,7 +138,11 @@ is currently expected to be empty by default. **--uki-addon**=*UKI_ADDON* - Name of the UKI addons to install without the ".efi.addon" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) + Name of the local/scoped UKI addons to install without the ".efi.addon" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) + +**--global-uki-addon**=*GLOBAL_UKI_ADDON* + + Name of the global UKI addons to install without the ".efi.addon" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) From c429341f5eb0602963a0fe5d20d44b5955e62e38 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Wed, 9 Sep 2026 15:20:13 +0530 Subject: [PATCH 03/18] cfs/upgrade/switch: Handle UKI Addons Here is what we do now with UKI Addons - Accept `--uki-addon` and `--global-uki-addon` cli options for bootc switch/upgrade commands - If we find an installed addon with the same name as the one in the new image, we update it Signed-off-by: Pragyan Poudyal --- crates/lib/src/bootc_composefs/boot.rs | 84 ++++++++++++++----- crates/lib/src/bootc_composefs/switch.rs | 1 + crates/lib/src/bootc_composefs/update.rs | 8 +- crates/lib/src/cli.rs | 9 ++ crates/lib/src/install.rs | 45 ++++++---- docs/src/man/bootc-install-to-disk.8.md | 4 +- .../man/bootc-install-to-existing-root.8.md | 4 +- docs/src/man/bootc-install-to-filesystem.8.md | 4 +- docs/src/man/bootc-switch.8.md | 8 ++ docs/src/man/bootc-upgrade.8.md | 8 ++ 10 files changed, 128 insertions(+), 47 deletions(-) diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 07b8b7c71..6d95e3bce 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -119,7 +119,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"; @@ -318,7 +318,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<'_> { @@ -821,7 +828,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()?; @@ -1786,25 +1793,23 @@ pub(crate) fn setup_composefs_uki_boot( let mut addons: Vec = vec![]; - if let Some(local_addons) = &state.composefs_options.uki_addon { - for addon in local_addons { - addons.push(UkiAddonsList { - name: addon.into(), - addon_type: UkiAddonType::Scoped { - depl_id: id.to_hex(), - }, - }); - } - }; + let addon_opts = &state.composefs_options.uki_addon_opts; - if let Some(global_addons) = &state.composefs_options.global_uki_addon { - for addon in global_addons { - addons.push(UkiAddonsList { - name: addon.into(), - addon_type: UkiAddonType::Global, - }); - } - }; + for addon in addon_opts.scoped.iter().flatten() { + addons.push(UkiAddonsList { + name: addon.into(), + addon_type: UkiAddonType::Scoped { + depl_id: id.to_hex(), + }, + }); + } + + for addon in addon_opts.global.iter().flatten() { + addons.push(UkiAddonsList { + name: addon.into(), + addon_type: UkiAddonType::Global, + }); + } ( esp_part.path(), @@ -1814,14 +1819,47 @@ pub(crate) fn setup_composefs_uki_boot( ) } - BootSetupType::Upgrade((storage, booted_cfs, host)) => { + BootSetupType::Upgrade((storage, booted_cfs, host, uki_addon_opts)) => { let bootloader = host.require_composefs_booted()?.bootloader.clone(); // Locate ESP partition device by walking up to the root disk(s) let root_dev = bootc_blockdev::list_dev_by_dir(&storage.physical_root)?; let esp_dev = root_dev.find_first_colocated_esp()?; - let installed_addons = list_installed_uki_addons(storage)?; + // These are the currently installed addons which we will update automatically + // if we find in the new image + let mut installed_addons = list_installed_uki_addons(storage)?; + + let target_depl_id = id.to_hex(); + + if let Some(addons) = &uki_addon_opts { + for addon in addons.global.iter().flatten() { + installed_addons.push(UkiAddonsList { + name: addon.into(), + addon_type: UkiAddonType::Global, + }); + } + + for addon in addons.scoped.iter().flatten() { + installed_addons.push(UkiAddonsList { + name: addon.into(), + addon_type: UkiAddonType::Scoped { + depl_id: target_depl_id.clone(), + }, + }); + } + } + + // Only keep addons referenced by the current deployment + let installed_addons = installed_addons + .into_iter() + .filter(|addon| match &addon.addon_type { + UkiAddonType::Scoped { depl_id } => { + *depl_id == *booted_cfs.cmdline.digest || *depl_id == target_depl_id + } + UkiAddonType::Global => true, + }) + .collect::>(); ( esp_dev.path(), diff --git a/crates/lib/src/bootc_composefs/switch.rs b/crates/lib/src/bootc_composefs/switch.rs index 0cb0b9ebe..f91c2dc63 100644 --- a/crates/lib/src/bootc_composefs/switch.rs +++ b/crates/lib/src/bootc_composefs/switch.rs @@ -34,6 +34,7 @@ pub(crate) async fn switch_composefs( use_unified: false, quiet: opts.quiet, prog, + uki_addon_opts: opts.uki_addon_opts.clone(), }; if opts.download_opts.from_downloaded { diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index c0dcafe34..5bd05f081 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -15,6 +15,7 @@ use ostree_ext::container::ManifestDiff; use crate::bootc_composefs::finalize::get_etc_diff; use crate::bootc_composefs::gc::GCOpts; +use crate::install::UkiAddonOpts; use crate::spec::BootloaderKind; use crate::{ bootc_composefs::{ @@ -234,6 +235,8 @@ pub(crate) struct DoUpgradeOpts { pub(crate) quiet: bool, /// Structured (JSON-Lines) progress sink; see `--progress-fd`. pub(crate) prog: ProgressWriter, + /// The UKI Addons to install from the new image (if any) + pub(crate) uki_addon_opts: UkiAddonOpts, } async fn apply_upgrade( @@ -335,7 +338,7 @@ pub(crate) async fn do_upgrade( let (boot_digest, deploy_id) = match boot_type { BootType::Bls => ( setup_composefs_bls_boot( - BootSetupType::Upgrade((storage, booted_cfs, &host)), + BootSetupType::Upgrade((storage, booted_cfs, &host, None)), &repo, &provisional_deploy_id, provisional_format, @@ -347,7 +350,7 @@ pub(crate) async fn do_upgrade( BootType::Uki => print_uki_dumpfile_diff_on_mismatch( setup_composefs_uki_boot( - BootSetupType::Upgrade((storage, booted_cfs, &host)), + BootSetupType::Upgrade((storage, booted_cfs, &host, Some(&opts.uki_addon_opts))), &repo, &provisional_deploy_id, &boot_ids, @@ -503,6 +506,7 @@ pub(crate) async fn upgrade_composefs( use_unified: false, quiet: opts.quiet, prog, + uki_addon_opts: opts.uki_addon_opts, }; if opts.download_opts.from_downloaded { diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 0a7c0e598..75bb142f7 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -52,6 +52,7 @@ use crate::bootc_composefs::{ update::upgrade_composefs, }; use crate::deploy::{MergeState, RequiredHostSpec}; +use crate::install::UkiAddonOpts; use crate::podstorage::set_additional_image_store; use crate::progress_jsonl::{ProgressWriter, RawProgressFd}; use crate::spec::FilesystemOverlayAccessMode; @@ -143,6 +144,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 +218,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. diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 116f04e16..e70769704 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -395,6 +395,23 @@ pub(crate) struct InstallConfigOpts { pub(crate) bootloader: Option, } +#[derive(Debug, Default, Clone, clap::Parser, Serialize, Deserialize, PartialEq, Eq)] +pub(crate) struct UkiAddonOpts { + /// Name of the local/scoped UKI addons to install without the ".efi.addon" suffix. + /// This option can be provided multiple times if multiple addons are to be installed + /// (composefs backend only). + #[clap(long = "uki-addon", requires = "composefs_backend")] + #[serde(default)] + pub(crate) scoped: Option>, + + /// Name of the global UKI addons to install without the ".efi.addon" suffix. + /// This option can be provided multiple times if multiple addons are to be installed + /// (composefs backend only). + #[clap(long = "global-uki-addon", requires = "composefs_backend")] + #[serde(default)] + pub(crate) global: Option>, +} + #[derive(Debug, Default, Clone, clap::Parser, Serialize, Deserialize, PartialEq, Eq)] pub(crate) struct InstallComposefsOpts { /// Use the composefs backend instead of ostree. This is the default for images with a UKI, @@ -409,19 +426,9 @@ pub(crate) struct InstallComposefsOpts { #[serde(default)] pub(crate) allow_missing_verity: bool, - /// Name of the local/scoped UKI addons to install without the ".efi.addon" suffix. - /// This option can be provided multiple times if multiple addons are to be installed - /// (composefs backend only). - #[clap(long, requires = "composefs_backend")] - #[serde(default)] - pub(crate) uki_addon: Option>, - - /// Name of the global UKI addons to install without the ".efi.addon" suffix. - /// This option can be provided multiple times if multiple addons are to be installed - /// (composefs backend only). - #[clap(long, requires = "composefs_backend")] - #[serde(default)] - pub(crate) global_uki_addon: Option>, + #[clap(flatten)] + #[serde(flatten)] + pub(crate) uki_addon_opts: UkiAddonOpts, } impl InstallComposefsOpts { @@ -439,9 +446,13 @@ impl InstallComposefsOpts { "--allow-missing-verity requires the composefs backend" ); anyhow::ensure!( - self.uki_addon.is_none(), + self.uki_addon_opts.scoped.is_none(), "--uki-addon requires the composefs backend" ); + anyhow::ensure!( + self.uki_addon_opts.global.is_none(), + "--global-uki-addon requires the composefs backend" + ); } Ok(()) } @@ -3155,8 +3166,10 @@ mod tests { let opts = InstallComposefsOpts { composefs_backend, allow_missing_verity, - uki_addon, - global_uki_addon: None, + uki_addon_opts: UkiAddonOpts { + scoped: uki_addon, + global: None, + }, }; assert_eq!( opts.validate(bootloader.as_ref()).is_ok(), diff --git a/docs/src/man/bootc-install-to-disk.8.md b/docs/src/man/bootc-install-to-disk.8.md index a8dcb941c..0948caca5 100644 --- a/docs/src/man/bootc-install-to-disk.8.md +++ b/docs/src/man/bootc-install-to-disk.8.md @@ -186,11 +186,11 @@ set `discoverable-partitions = true` in their install configuration Default: false -**--uki-addon**=*UKI_ADDON* +**--uki-addon**=*SCOPED* Name of the local/scoped UKI addons to install without the ".efi.addon" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) -**--global-uki-addon**=*GLOBAL_UKI_ADDON* +**--global-uki-addon**=*GLOBAL* Name of the global UKI addons to install without the ".efi.addon" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) diff --git a/docs/src/man/bootc-install-to-existing-root.8.md b/docs/src/man/bootc-install-to-existing-root.8.md index e16677621..4f285289a 100644 --- a/docs/src/man/bootc-install-to-existing-root.8.md +++ b/docs/src/man/bootc-install-to-existing-root.8.md @@ -227,11 +227,11 @@ of migrating the fstab entries. See the "Injecting kernel arguments" section abo Default: false -**--uki-addon**=*UKI_ADDON* +**--uki-addon**=*SCOPED* Name of the local/scoped UKI addons to install without the ".efi.addon" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) -**--global-uki-addon**=*GLOBAL_UKI_ADDON* +**--global-uki-addon**=*GLOBAL* Name of the global UKI addons to install without the ".efi.addon" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) diff --git a/docs/src/man/bootc-install-to-filesystem.8.md b/docs/src/man/bootc-install-to-filesystem.8.md index ab0bbbf2a..c5e013818 100644 --- a/docs/src/man/bootc-install-to-filesystem.8.md +++ b/docs/src/man/bootc-install-to-filesystem.8.md @@ -136,11 +136,11 @@ is currently expected to be empty by default. Default: false -**--uki-addon**=*UKI_ADDON* +**--uki-addon**=*SCOPED* Name of the local/scoped UKI addons to install without the ".efi.addon" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) -**--global-uki-addon**=*GLOBAL_UKI_ADDON* +**--global-uki-addon**=*GLOBAL* Name of the global UKI addons to install without the ".efi.addon" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) diff --git a/docs/src/man/bootc-switch.8.md b/docs/src/man/bootc-switch.8.md index c32911e93..3d3bc082c 100644 --- a/docs/src/man/bootc-switch.8.md +++ b/docs/src/man/bootc-switch.8.md @@ -76,6 +76,14 @@ For shared `--apply` and `--soft-reboot` behavior, see Retain reference to currently booted image +**--uki-addon**=*SCOPED* + + Name of the local/scoped UKI addons to install without the ".efi.addon" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) + +**--global-uki-addon**=*GLOBAL* + + Name of the global UKI addons to install without the ".efi.addon" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) + # EXAMPLES diff --git a/docs/src/man/bootc-upgrade.8.md b/docs/src/man/bootc-upgrade.8.md index 0a2face2b..473b72b29 100644 --- a/docs/src/man/bootc-upgrade.8.md +++ b/docs/src/man/bootc-upgrade.8.md @@ -67,6 +67,14 @@ For shared `--apply` and `--soft-reboot` behavior, see Upgrade to a different tag of the currently booted image +**--uki-addon**=*SCOPED* + + Name of the local/scoped UKI addons to install without the ".efi.addon" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) + +**--global-uki-addon**=*GLOBAL* + + Name of the global UKI addons to install without the ".efi.addon" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) + # EXAMPLES From 01d573ecb5dcc9adc76c22dc5d3913bfffd4f04e Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Thu, 10 Sep 2026 09:56:33 +0530 Subject: [PATCH 04/18] tmt: Add tests for UKI Addons Signed-off-by: Pragyan Poudyal --- contrib/packaging/finalize-uki | 11 ++ tmt/plans/integration.fmf | 8 + tmt/tests/booted/tap.nu | 4 +- tmt/tests/booted/test-composefs-uki-addons.nu | 138 ++++++++++++++++++ tmt/tests/tests.fmf | 5 + 5 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 tmt/tests/booted/test-composefs-uki-addons.nu diff --git a/contrib/packaging/finalize-uki b/contrib/packaging/finalize-uki index 7c54f1e2e..b6774bf94 100755 --- a/contrib/packaging/finalize-uki +++ b/contrib/packaging/finalize-uki @@ -35,6 +35,17 @@ if [[ -f "${uki_src}/${kver}.dump" ]]; then cp "${uki_src}/${kver}.dump" /boot fi +# If we have a UKI Addon dir, add it +if [[ -d "${uki_src}/${kver}.efi.extra.d" ]]; then + cp -r "${uki_src}/${kver}.efi.extra.d" /boot/EFI/Linux +fi + +# If we have a global UKI Addon dir, add it +if [[ -d "${uki_src}/loader/addons" ]]; then + mkdir -p /boot/loader + cp -r "${uki_src}/loader/addons" /boot/loader +fi + # NOTE: We used to create a symlink from /usr/lib/modules/${kver}/${kver}.efi to the UKI # for tooling compatibility. However, composefs-boot's find_uki_components() doesn't # handle symlinks correctly and fails with "is not a regular file". The UKI is already diff --git a/tmt/plans/integration.fmf b/tmt/plans/integration.fmf index 2042e3cdb..b340d405c 100644 --- a/tmt/plans/integration.fmf +++ b/tmt/plans/integration.fmf @@ -340,6 +340,14 @@ execute: - /tmt/tests/tests/test-50-switch-zstd-chunked extra-skip_if_ostree: true +/plan-51-composefs-uki-addons: + summary: Test composefs UKI Addons + discover: + how: fmf + test: + - /tmt/tests/tests/test-51-composefs-uki-addons + extra-skip_if_ostree: true + /plan-52-install-repart: summary: Test bootc install to-disk with systemd-repart partitioning discover: diff --git a/tmt/tests/booted/tap.nu b/tmt/tests/booted/tap.nu index cbfc52da7..e51f8f863 100644 --- a/tmt/tests/booted/tap.nu +++ b/tmt/tests/booted/tap.nu @@ -85,7 +85,7 @@ rm -vrf /usr/lib/bootc/bound-images.d " } -export def make_uki_containerfile [containerfile: string, --erofs-version: string = ""] { +export def make_uki_containerfile [containerfile: string, --erofs-version: string = "", --addon-cmds: string = ""] { let erofs_version = if $erofs_version == "" { selected_erofs_version } else { @@ -142,6 +142,8 @@ export def make_uki_containerfile [containerfile: string, --erofs-version: strin --write-dumpfile-to /out/${kver}.dump \\ --seal-state ($seal_state) \\ --erofs-version ($erofs_version) + + ($addon_cmds) EOF FROM base-final diff --git a/tmt/tests/booted/test-composefs-uki-addons.nu b/tmt/tests/booted/test-composefs-uki-addons.nu new file mode 100644 index 000000000..d6f8b61db --- /dev/null +++ b/tmt/tests/booted/test-composefs-uki-addons.nu @@ -0,0 +1,138 @@ +# 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 + " + + $containerfile = (tap make_uki_containerfile $containerfile --addon-cmds $cmds) + + echo $containerfile | podman build -t localhost/bootc-uki-addons-2 . -f - + + # Include two addons + bootc switch --transport containers-storage --uki-addon monster-cmdline --global-uki-addon global-cmdline localhost/bootc-uki-addons-2 + + tmt-reboot +} + + +def third_boot [] { + mkdir /var/tmp/efi + mount /dev/disk/by-partlabel/EFI-SYSTEM /var/tmp/efi + + # We should have two addons + assert ((^find /var/tmp/efi -type f -name '*addon.efi' | ^wc -l | str trim | into int) == 2) + + # We should have those in the cmdline + assert (open /proc/cmdline | str contains "johan=liebert") + assert (open /proc/cmdline | str contains "kenzo=tenma") + + mut containerfile = $" + FROM localhost/bootc as base + RUN touch /usr/share/third + " + + let cmds = " + mkdir -p /out/${kver}.efi.extra.d + ukify build --cmdline 'berserk=guts' --output /out/${kver}.efi.extra.d/berserk-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 - + + # This should update the global cmdline because we have the same name + bootc switch --transport containers-storage --uki-addon berserk-cmdline localhost/bootc-uki-addons-3 + + tmt-reboot +} + +def fourth_boot [] { + mkdir /var/tmp/efi + mount /dev/disk/by-partlabel/EFI-SYSTEM /var/tmp/efi + + # We should have three addons + # One from the previous deployment + # One global addon and one from the current deployment + assert ((^find /var/tmp/efi -type f -name '*addon.efi' | ^wc -l | str trim | into int) == 3) + + # Addon should be present, but not for this deployment + assert (not (open /proc/cmdline | str contains "johan=liebert")) + # Global addon should've been updated + assert (not (open /proc/cmdline | str contains "kenzo=tenma")) + + assert (open /proc/cmdline | str contains "berserk=guts") + # Global addon should've been updated + assert (open /proc/cmdline | str contains "pink=floyd") + + tap ok +} + +def main [] { + match $env.TMT_REBOOT_COUNT? { + null | "0" => first_boot, + "1" => second_boot, + "2" => third_boot, + "3" => fourth_boot, + $o => { error make { msg: $"Invalid TMT_REBOOT_COUNT ($o)" } }, + } +} diff --git a/tmt/tests/tests.fmf b/tmt/tests/tests.fmf index c78d42d7d..b99c4044a 100644 --- a/tmt/tests/tests.fmf +++ b/tmt/tests/tests.fmf @@ -212,6 +212,11 @@ check: duration: 30m test: nu booted/test-switch-zstd-chunked.nu +/test-51-composefs-uki-addons: + summary: Test composefs UKI Addons + duration: 30m + test: nu booted/test-composefs-uki-addons.nu + /test-52-install-repart: summary: Test bootc install to-disk with systemd-repart partitioning duration: 30m From 86b4d235b3fefd23f9fcfe876a1d3b6b1dfa634a Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Thu, 10 Sep 2026 13:47:41 +0530 Subject: [PATCH 05/18] uki-addon: Add docs for UKI Addons Assisted-by: AI Signed-off-by: Pragyan Poudyal --- crates/lib/src/bootc_composefs/boot.rs | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 6d95e3bce..d0cee24bc 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -149,15 +149,12 @@ pub(crate) const BOOTC_UKI_DIR: &str = "EFI/Linux/bootc"; /// deployment, so they're neither namespaced by deployment verity nor cleaned up by GC. /// /// TODO: This directory is shared, unscoped machine state (any systemd-stub UKI on the -/// ESP will load whatever's here), but we currently treat it like deployment-owned -/// content: we blindly overwrite same-named files with no ownership tracking, we only -/// (re)install addons on `install` (not on upgrade, see `uki_addons` being hardcoded to -/// `None` for `BootSetupType::Upgrade` below), and GC never removes stale entries here. -/// Before recommending this feature for real use we should track which files here are -/// bootc-owned, reconcile that set on every upgrade (installing newly-selected addons, -/// removing ones we own that are no longer selected/present), and decide/document how -/// this interacts with deployment rollback (a global addon update isn't reverted by -/// rolling back to an older deployment). +/// ESP will load whatever's here). Addons are now (re)installed on upgrade/switch +/// (installed addons are auto-updated when the new image has a matching filename), +/// but we still blindly overwrite same-named (global) files with no ownership tracking. +/// Before recommending this feature for wider use we should track which global addon +/// files are bootc-owned, and decide/document how this interacts with deployment +/// rollback (a global addon update isn't reverted by rolling back to an older deployment). pub(crate) const GLOBAL_UKI_ADDONS_DIR: &str = "loader/addons"; #[derive(thiserror::Error, Debug)] From 90ca19e84d3da53febea09e2829a32b89a0ccdc8 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Thu, 10 Sep 2026 13:59:27 +0530 Subject: [PATCH 06/18] global-uki-addons: Add prefix to name Prefix global addon filenames with the bootc identifier in the ESP so we can distinguish bootc-managed global addons from third-party ones Signed-off-by: Pragyan Poudyal --- crates/lib/src/bootc_composefs/boot.rs | 25 +++++++++++++++++++++---- crates/lib/src/composefs_consts.rs | 6 ++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index d0cee24bc..7449dfd55 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -554,10 +554,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 @@ -1383,6 +1391,15 @@ fn write_pe_to_esp( .as_str(), }; + // Prefix global Uki Addons for identification + let pe_name = if matches!(pe_type, PEType::GlobalUkiAddon) { + &get_global_uki_addon_name(pe_name) + } else if matches!(pe_type, PEType::UkiAddon) { + &get_scoped_uki_addon_name(pe_name) + } else { + pe_name + }; + uki_reader.seek(SeekFrom::Start(0))?; pe_dir .atomic_replace_with(pe_name, |writer| std::io::copy(&mut uki_reader, writer)) diff --git a/crates/lib/src/composefs_consts.rs b/crates/lib/src/composefs_consts.rs index 03ccc5310..7a9c56d06 100644 --- a/crates/lib/src/composefs_consts.rs +++ b/crates/lib/src/composefs_consts.rs @@ -43,6 +43,12 @@ pub(crate) const BOOTC_FINALIZE_STAGED_SERVICE: &str = "bootc-finalize-staged.se pub(crate) const TYPE1_BOOT_DIR_PREFIX: &str = "bootc_composefs-"; /// The prefix for names of UKI and UKI Addons +/// +/// The actual name of a scoped UKI Addon is NOT prefixed, +/// only its directory name is prefixed +/// +/// The actual name of a global UKI Addon IS prefixed, since +/// they all live in ESP/loader/addons pub(crate) const UKI_NAME_PREFIX: &str = TYPE1_BOOT_DIR_PREFIX; /// Prefix for OCI tags owned by bootc in the composefs repository. From 87b533f430befe31c8cbbe2173a3656a8c755e04 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Thu, 10 Sep 2026 14:03:50 +0530 Subject: [PATCH 07/18] uki-addon: Add CLI for managing UKI Addons Add `bootc uki-addon` subcommand with three operations: - `bootc uki-addon list`: List installed UKI addons Supports `--json` for JSON output - `bootc uki-addon add `: Install an addon from the booted image onto the ESP - `bootc uki-addon remove [deployment_id]`: Remove an addon Add Display and Serialize to UkiAddonType/UkiAddonsList Signed-off-by: Pragyan Poudyal --- crates/lib/src/bootc_composefs/boot.rs | 3 +- crates/lib/src/bootc_composefs/mod.rs | 1 + crates/lib/src/bootc_composefs/uki_addon.rs | 54 +++- .../lib/src/bootc_composefs/uki_addons_cli.rs | 270 ++++++++++++++++++ crates/lib/src/cli.rs | 45 +++ 5 files changed, 363 insertions(+), 10 deletions(-) create mode 100644 crates/lib/src/bootc_composefs/uki_addons_cli.rs diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 7449dfd55..80ad39de1 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -1797,8 +1797,7 @@ 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) = match setup_type { BootSetupType::Setup((root_setup, state, postfetch, allow_missing_fsverity)) => { state.require_no_kargs_for_uki()?; diff --git a/crates/lib/src/bootc_composefs/mod.rs b/crates/lib/src/bootc_composefs/mod.rs index 00ae9f34e..a33c80017 100644 --- a/crates/lib/src/bootc_composefs/mod.rs +++ b/crates/lib/src/bootc_composefs/mod.rs @@ -16,5 +16,6 @@ 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/uki_addon.rs b/crates/lib/src/bootc_composefs/uki_addon.rs index cfd89b84e..6fcd0bda2 100644 --- a/crates/lib/src/bootc_composefs/uki_addon.rs +++ b/crates/lib/src/bootc_composefs/uki_addon.rs @@ -1,9 +1,12 @@ #![allow(dead_code)] +use std::fmt; + use anyhow::{Context, Result}; use cap_std_ext::cap_std::fs::Dir; use cap_std_ext::dirext::CapStdExtDirExt; use fn_error_context::context; use ostree_ext::composefs_boot::bootloader::{EFI_ADDON_DIR_EXT, EFI_ADDON_FILE_EXT}; +use serde::Serialize; use crate::{ bootc_composefs::boot::{BOOTC_UKI_DIR, GLOBAL_UKI_ADDONS_DIR}, @@ -11,18 +14,34 @@ use crate::{ store::Storage, }; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "type", rename_all = "lowercase")] pub enum UkiAddonType { Scoped { depl_id: String }, Global, } -#[derive(Debug, Clone)] +impl fmt::Display for UkiAddonType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + UkiAddonType::Global => write!(f, "global"), + UkiAddonType::Scoped { depl_id } => write!(f, "scoped (deployment {depl_id})"), + } + } +} + +#[derive(Debug, Clone, Serialize)] pub struct UkiAddonsList { pub name: String, pub addon_type: UkiAddonType, } +impl fmt::Display for UkiAddonsList { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} ({})", self.name, self.addon_type) + } +} + fn gather_addons_from_dir( dir: &Dir, addons: &mut Vec, @@ -32,12 +51,30 @@ fn gather_addons_from_dir( let ent = ent?; let filename = ent.file_name()?; - if let Some(addon_name) = filename.strip_suffix(EFI_ADDON_FILE_EXT) { - addons.push(UkiAddonsList { - name: addon_name.to_string(), - addon_type: addon_type.clone(), - }); + let Some(addon_name) = filename.strip_suffix(EFI_ADDON_FILE_EXT) else { + continue; }; + + match addon_name.strip_prefix(UKI_NAME_PREFIX) { + Some(addon_name) => { + addons.push(UkiAddonsList { + name: addon_name.to_string(), + addon_type: addon_type.clone(), + }); + } + None => match addon_type { + UkiAddonType::Scoped { .. } => { + addons.push(UkiAddonsList { + name: addon_name.to_string(), + addon_type: addon_type.clone(), + }); + } + // We only prefix global UKI Addons for identification + UkiAddonType::Global => { + tracing::info!("Global UKI Addon not managed by bootc found: {addon_name}") + } + }, + } } Ok(()) @@ -52,7 +89,8 @@ pub fn list_installed_uki_addons(storage: &Storage) -> Result }; if let Some(global_dir) = esp.fd.open_dir_optional(GLOBAL_UKI_ADDONS_DIR)? { - gather_addons_from_dir(&global_dir, &mut addons, UkiAddonType::Global)?; + gather_addons_from_dir(&global_dir, &mut addons, UkiAddonType::Global) + .context("Gathering global addons")?; }; for ent in esp diff --git a/crates/lib/src/bootc_composefs/uki_addons_cli.rs b/crates/lib/src/bootc_composefs/uki_addons_cli.rs new file mode 100644 index 000000000..3bf48cfdf --- /dev/null +++ b/crates/lib/src/bootc_composefs/uki_addons_cli.rs @@ -0,0 +1,270 @@ +use std::path::Path; + +use anyhow::{Context, Result}; +use bootc_mount::tempmount::TempMount; +use camino::Utf8PathBuf; +use cap_std_ext::cap_std::fs::Dir; +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 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, + }, + uki_addon::{UkiAddonType, list_installed_uki_addons}, + }, + cli::{UkiAddonCliOpts, UkiAddonScope}, + store::{BootedComposefs, Storage}, +}; + +#[context("Verifying {addon_type:?} {addon_name} addon exists")] +fn verify_addon_exists( + boot_dir: &Dir, + addon_name: &str, + addon_type: UkiAddonScope, +) -> Result { + let mut path = Utf8PathBuf::from("boot"); + + match addon_type { + UkiAddonScope::Global => { + let addons_dir = boot_dir.open_dir(GLOBAL_UKI_ADDONS_DIR)?; + + for entry in addons_dir.entries_utf8()? { + let entry = entry?; + let filename = entry.file_name()?; + + if let Some(name) = filename.strip_suffix(EFI_ADDON_FILE_EXT) { + if name == addon_name { + return Ok(path.join(GLOBAL_UKI_ADDONS_DIR).join(filename)); + } + }; + } + } + + UkiAddonScope::Scoped => { + path = path.join(EFI_LINUX); + + for entry in boot_dir + .open_dir(EFI_LINUX) + .context("Opening EFI/Linux")? + .entries_utf8()? + { + let entry = entry?; + + if !entry.file_type()?.is_dir() { + continue; + } + + let dirname = entry.file_name()?; + + if !dirname.ends_with(EFI_ADDON_DIR_EXT) { + continue; + } + + path.push(&dirname); + + for addon_ent in entry.open_dir()?.entries()? { + let addon_ent = addon_ent?; + let filename = addon_ent.file_name()?; + + if let Some(name) = filename.strip_suffix(EFI_ADDON_FILE_EXT) { + if name == addon_name { + return Ok(path.join(filename)); + } + }; + } + + path.pop(); + } + } + }; + + anyhow::bail!("{addon_name} not found"); +} + +pub(crate) fn handle_addon_cli_cmd( + storage: &Storage, + booted_cfs: &BootedComposefs, + opts: &UkiAddonCliOpts, +) -> Result<()> { + let Ok(esp) = storage.require_esp() else { + anyhow::bail!("ESP not found"); + }; + + match opts { + UkiAddonCliOpts::List { json } => { + let addons = list_installed_uki_addons(storage)?; + + if *json { + return serde_json::to_writer(std::io::stdout(), &addons) + .context("Writing JSON output"); + } + + if addons.is_empty() { + println!("No UKI addons installed"); + return Ok(()); + } + + for addon in &addons { + println!("{addon}"); + } + } + UkiAddonCliOpts::Remove { + name: addon_name, + deployment_id, + } => { + let addons = list_installed_uki_addons(storage)?; + + match deployment_id { + Some(depl_id) => { + let found = addons.iter().any(|addon| { + matches!( + &addon.addon_type, + UkiAddonType::Scoped { depl_id: id } if id == depl_id + ) && addon.name == *addon_name + }); + + if !found { + anyhow::bail!( + "No addon found with the name {addon_name} for deployment {depl_id}" + ); + } + + let addon_path = Path::new(BOOTC_UKI_DIR) + .join(get_uki_addon_dir_name(depl_id)) + .join(get_scoped_uki_addon_name(addon_name)); + + esp.fd + .remove_file(addon_path) + .with_context(|| format!("Failed to remove addon {addon_name}"))?; + + println!("Removed addon {addon_name}"); + } + + // Removing a global addon + None => { + let found = addons.iter().any(|addon| { + addon.addon_type == UkiAddonType::Global && addon.name == *addon_name + }); + + if !found { + anyhow::bail!("No Global addon found with the name {addon_name}"); + } + + let full_addon_name = get_global_uki_addon_name(addon_name); + + tracing::debug!("Removing Global UKI Addon {full_addon_name}"); + + esp.fd + .remove_file(format!("{GLOBAL_UKI_ADDONS_DIR}/{full_addon_name}")) + .with_context(|| format!("Removing global addon {full_addon_name}"))?; + + println!("Removed Global Addon {addon_name}"); + } + } + } + + UkiAddonCliOpts::Add { + name: addon_name, + addon_type, + } => { + // This should never fail + let booted_digest = Sha512HashValue::from_hex(booted_cfs.cmdline.digest.as_bytes()) + .context("Booted composefs has bad FSVerity")?; + + let addons = list_installed_uki_addons(storage)?; + + let already_present = addons.iter().any(|addon| match addon_type { + UkiAddonScope::Global => { + addon.addon_type == UkiAddonType::Global && addon.name == *addon_name + } + UkiAddonScope::Scoped => { + matches!( + &addon.addon_type, + UkiAddonType::Scoped { depl_id: id } if *id == booted_digest.to_hex() + ) && addon.name == *addon_name + } + }); + + if already_present { + println!("Addon {addon_name} is already present. Nothing to do."); + return Ok(()); + } + + let linked_images = linked_erofs_images(&booted_cfs.repo, &booted_digest) + .context("Finding linked EROFS for booted deployment")?; + + let Some(non_bootable_img) = linked_images.iter().find(|img| !img.bootable) else { + anyhow::bail!("No non-bootable image found. Cannot gather UKI Addons"); + }; + + tracing::debug!("non_bootable_img: {non_bootable_img:#?}"); + + // Now we mount the img, and copy from /boot + let composefs_mnt_fd = booted_cfs + .repo + .mount(&non_bootable_img.id.to_hex()) + .context("Failed to mount composefs image")?; + + let composefs = TempMount::mount_fd(composefs_mnt_fd) + .context("Attaching composefs image to temporary directory")?; + + let cfs_boot_dir = composefs + .fd + .open_dir("boot") + .context("Opening boot directory in composefs image")?; + + // Make sure the addon actually exists in the image + // before creating directories + let addon_path = verify_addon_exists(&cfs_boot_dir, addon_name, *addon_type)?; + + match addon_type { + UkiAddonScope::Global => { + esp.fd + .create_dir_all(GLOBAL_UKI_ADDONS_DIR) + .context("Creating global addons directory")?; + + let global_addons_dir = esp + .fd + .open_dir(GLOBAL_UKI_ADDONS_DIR) + .context("Opening global addons dir")?; + + composefs + .fd + .copy( + addon_path, + &global_addons_dir, + get_global_uki_addon_name(addon_name), + ) + .context("Copying global addon")?; + } + UkiAddonScope::Scoped => { + let dir_path = Path::new(BOOTC_UKI_DIR) + .join(get_uki_addon_dir_name(&booted_digest.to_hex())); + + esp.fd + .create_dir_all(&dir_path) + .context("Creating addons directory")?; + + let to_dir = esp + .fd + .open_dir(&dir_path) + .context("Opening addons directory")?; + + composefs + .fd + .copy(addon_path, &to_dir, get_scoped_uki_addon_name(addon_name)) + .context("Copying addon")?; + } + } + } + } + + Ok(()) +} diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 75bb142f7..4bab517b7 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -43,6 +43,7 @@ use crate::bootc_composefs::delete::delete_composefs_deployment; use crate::bootc_composefs::gc::{GCOpts, composefs_gc}; use crate::bootc_composefs::soft_reboot::{prepare_soft_reboot_composefs, reset_soft_reboot}; use crate::bootc_composefs::state::get_usr_overlay_status; +use crate::bootc_composefs::uki_addons_cli::handle_addon_cli_cmd; use crate::bootc_composefs::{ digest::{compute_composefs_digest, new_temp_composefs_repo}, finalize::{composefs_backend_finalize, get_etc_diff}, @@ -985,6 +986,36 @@ impl InternalsOpts { const GENERATOR_BIN: &'static str = "bootc-systemd-generator"; } +#[derive(Debug, Clone, Copy, clap::ValueEnum, PartialEq, Eq)] +pub(crate) enum UkiAddonScope { + Global, + Scoped, +} + +#[derive(Debug, clap::Subcommand, PartialEq, Eq)] +pub(crate) enum UkiAddonCliOpts { + /// List all installed UKI Addons + List { + /// Output in JSON format + #[clap(long)] + json: bool, + }, + /// Remove a UKI Addon + Remove { + /// Addon name to be provided without the `.efi.addon` suffix + name: String, + /// If removing a scoped addon, deployment_id is required. + /// If removing a global addon, deployment_id is not required. + deployment_id: Option, + }, + /// Add a UKI Addon to the current deployment + Add { + /// Addon name to be provided without the `.efi.addon` suffix + name: String, + addon_type: UkiAddonScope, + }, +} + /// Deploy and transactionally in-place with bootable container images. /// /// The `bootc` project currently uses ostree-containers as a backend @@ -1116,6 +1147,9 @@ pub(crate) enum Opt { }, #[clap(hide = true)] DeleteDeployment { depl_id: String }, + + #[clap(subcommand)] + UkiAddon(UkiAddonCliOpts), } /// Ensure we've entered a mount namespace, so that we can remount @@ -2777,6 +2811,17 @@ async fn run_from_opt(opt: Opt) -> Result { } } } + Opt::UkiAddon(opts) => { + let storage = &get_storage().await?; + match storage.kind()? { + BootedStorageKind::Ostree(_) => { + anyhow::bail!("UKI Addons are only supported for Composefs Backend") + } + BootedStorageKind::Composefs(booted_cfs) => { + handle_addon_cli_cmd(storage, &booted_cfs, &opts) + } + } + } }; result.map(|()| CliExitStatus::Success) } From c48946c355295688100602895a785744e093b408 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Thu, 10 Sep 2026 15:58:07 +0530 Subject: [PATCH 08/18] tmt: Update UKI Addon tests - Add GC tests for Addons - Add CLI tests Signed-off-by: Pragyan Poudyal --- tmt/tests/booted/test-composefs-gc-uki.nu | 23 +++++++-- tmt/tests/booted/test-composefs-uki-addons.nu | 50 ++++++++++++++++++- 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/tmt/tests/booted/test-composefs-gc-uki.nu b/tmt/tests/booted/test-composefs-gc-uki.nu index 3ea7d0c24..4b4dbf7e4 100644 --- a/tmt/tests/booted/test-composefs-gc-uki.nu +++ b/tmt/tests/booted/test-composefs-gc-uki.nu @@ -30,11 +30,16 @@ 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 + " + + $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 localhost/bootc-first # Make sure we have the .boot EROFS let st = bootc status --json | from json @@ -51,7 +56,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 +73,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 +86,7 @@ def second_boot [] { mut containerfile = echo " FROM localhost/bootc as base RUN echo 'second' > /usr/share/second - " + " $containerfile = (tap make_uki_containerfile $containerfile) @@ -96,6 +105,10 @@ def third_boot [] { assert (not ($"/var/tmp/efi/EFI/Linux/bootc/($uki_prefix)(cat /var/boot0-verity).efi" | path exists)) assert ($"/var/tmp/efi/EFI/Linux/bootc/($uki_prefix)(cat /var/boot1-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)) + echo $st.status.booted.composefs.verity | save /var/boot2-verity # this is not deleted yet @@ -105,7 +118,7 @@ def third_boot [] { mut containerfile = echo " FROM localhost/bootc as base RUN echo 'third' > /usr/share/third - " + " $containerfile = (tap make_uki_containerfile $containerfile) diff --git a/tmt/tests/booted/test-composefs-uki-addons.nu b/tmt/tests/booted/test-composefs-uki-addons.nu index d6f8b61db..6e16b1235 100644 --- a/tmt/tests/booted/test-composefs-uki-addons.nu +++ b/tmt/tests/booted/test-composefs-uki-addons.nu @@ -123,7 +123,55 @@ def fourth_boot [] { assert (open /proc/cmdline | str contains "berserk=guts") # Global addon should've been updated assert (open /proc/cmdline | str contains "pink=floyd") - + + # --- CLI tests --- + + # list --json should return 3 addons + let addons = bootc uki-addon list --json | from json + assert (($addons | length) == 3) + + # Verify the global addon is present + let globals = $addons | where addon_type.type == "global" + assert (($globals | length) == 1) + assert ($globals.0.name == "global-cmdline") + + # Verify we have two scoped addons + let scoped = $addons | where addon_type.type == "scoped" + assert (($scoped | length) == 2) + + # Human-readable list should not error + bootc uki-addon list + + # Remove the global addon + bootc uki-addon remove global-cmdline + let addons_after_remove = bootc uki-addon list --json | from json + assert (($addons_after_remove | length) == 2) + let globals_after = $addons_after_remove | where addon_type.type == "global" + assert (($globals_after | length) == 0) + + # Add it back from the booted image + bootc uki-addon add global-cmdline global + let addons_after_add = bootc uki-addon list --json | from json + assert (($addons_after_add | length) == 3) + let globals_readded = $addons_after_add | where addon_type.type == "global" + assert (($globals_readded | length) == 1) + assert ($globals_readded.0.name == "global-cmdline") + + # Adding the same addon again should be a no-op + bootc uki-addon add global-cmdline global + + # Remove a scoped addon from the old deployment + let old_scoped = $scoped | where name == "monster-cmdline" + assert (($old_scoped | length) == 1) + let old_depl_id = $old_scoped.0.addon_type.depl_id + bootc uki-addon remove monster-cmdline $old_depl_id + let addons_final = bootc uki-addon list --json | from json + assert (($addons_final | length) == 2) + + # Removing a non-existent addon should fail + let failed = (do { bootc uki-addon remove nonexistent-addon } | complete) + assert ($failed.exit_code != 0) + tap ok } From 0c611c958185f2a072d70b3a0d68ceae8d9a565b Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Thu, 10 Sep 2026 16:19:55 +0530 Subject: [PATCH 09/18] manpage: Update manpages for uki-addons Mark UKI Addons as experimental Assisted-by: Claude-Code (Opus) Signed-off-by: Pragyan Poudyal --- crates/lib/src/cli.rs | 1 + docs/src/man/bootc-uki-addon-add.8.md | 49 ++++++++++++++++++ docs/src/man/bootc-uki-addon-list.8.md | 59 ++++++++++++++++++++++ docs/src/man/bootc-uki-addon-remove.8.md | 50 ++++++++++++++++++ docs/src/man/bootc-uki-addon.8.md | 64 ++++++++++++++++++++++++ docs/src/man/bootc.8.md | 1 + 6 files changed, 224 insertions(+) create mode 100644 docs/src/man/bootc-uki-addon-add.8.md create mode 100644 docs/src/man/bootc-uki-addon-list.8.md create mode 100644 docs/src/man/bootc-uki-addon-remove.8.md create mode 100644 docs/src/man/bootc-uki-addon.8.md diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 4bab517b7..4f96479a5 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -1148,6 +1148,7 @@ pub(crate) enum Opt { #[clap(hide = true)] DeleteDeployment { depl_id: String }, + /// Perform operations related to UKI Addons #[clap(subcommand)] UkiAddon(UkiAddonCliOpts), } diff --git a/docs/src/man/bootc-uki-addon-add.8.md b/docs/src/man/bootc-uki-addon-add.8.md new file mode 100644 index 000000000..bfd00b793 --- /dev/null +++ b/docs/src/man/bootc-uki-addon-add.8.md @@ -0,0 +1,49 @@ +# NAME + +bootc-uki-addon-add - Add a UKI Addon to the current deployment + +# SYNOPSIS + +**bootc uki-addon add** <*NAME*> <*ADDON_TYPE*> + +# DESCRIPTION + +**This command is experimental and subject to change.** + +Add a UKI addon from the currently booted image to the EFI System Partition. + +The addon must exist in the booted image under `/boot`. If the addon is +already installed, the command is a no-op. + +# OPTIONS + + +**NAME** + + Addon name to be provided without the `.efi.addon` suffix + + This argument is required. + +**ADDON_TYPE** + + This argument is required. + + + +# EXAMPLES + +Add a scoped addon (tied to the current deployment): + + bootc uki-addon add debug scoped + +Add a global addon (applies to all deployments): + + bootc uki-addon add site-config global + +# SEE ALSO + +**bootc-uki-addon**(8), **bootc-uki-addon-list**(8), **bootc-uki-addon-remove**(8) + +# VERSION + + diff --git a/docs/src/man/bootc-uki-addon-list.8.md b/docs/src/man/bootc-uki-addon-list.8.md new file mode 100644 index 000000000..f371ed546 --- /dev/null +++ b/docs/src/man/bootc-uki-addon-list.8.md @@ -0,0 +1,59 @@ +# NAME + +bootc-uki-addon-list - List all installed UKI Addons + +# SYNOPSIS + +**bootc uki-addon list** \[*OPTIONS...*\] + +# DESCRIPTION + +**This command is experimental and subject to change.** + +List all installed UKI addons on the EFI System Partition, including both +scoped (per-deployment) and global addons. + +By default, output is human-readable with one addon per line. Use `--json` +for machine-readable output suitable for scripting. + +# OPTIONS + + +**--json** + + Output in JSON format + + + +# EXAMPLES + +List all installed addons: + + bootc uki-addon list + +List addons in JSON format: + + bootc uki-addon list --json + +Example JSON output: + +```json +[ + { + "name": "debug", + "addon_type": { "type": "scoped", "depl_id": "a1b2c3..." } + }, + { + "name": "site-config", + "addon_type": { "type": "global" } + } +] +``` + +# SEE ALSO + +**bootc-uki-addon**(8), **bootc-uki-addon-add**(8), **bootc-uki-addon-remove**(8) + +# VERSION + + diff --git a/docs/src/man/bootc-uki-addon-remove.8.md b/docs/src/man/bootc-uki-addon-remove.8.md new file mode 100644 index 000000000..7f4b17ec8 --- /dev/null +++ b/docs/src/man/bootc-uki-addon-remove.8.md @@ -0,0 +1,50 @@ +# NAME + +bootc-uki-addon-remove - Remove a UKI Addon + +# SYNOPSIS + +**bootc uki-addon remove** <*NAME*> \[*DEPLOYMENT_ID*\] + +# DESCRIPTION + +**This command is experimental and subject to change.** + +Remove a UKI addon from the EFI System Partition. + +For global addons, only the addon name is needed. For scoped addons, the +deployment ID is required to identify which deployment's addon to remove. +Use `bootc uki-addon list --json` to find deployment IDs. + +# OPTIONS + + +**NAME** + + Addon name to be provided without the `.efi.addon` suffix + + This argument is required. + +**DEPLOYMENT_ID** + + If removing a scoped addon, deployment_id is required. If removing a global addon, deployment_id is not required + + + +# EXAMPLES + +Remove a global addon: + + bootc uki-addon remove site-config + +Remove a scoped addon (get the deployment ID from `list --json`): + + bootc uki-addon remove debug a1b2c3d4e5f6... + +# SEE ALSO + +**bootc-uki-addon**(8), **bootc-uki-addon-list**(8), **bootc-uki-addon-add**(8) + +# VERSION + + diff --git a/docs/src/man/bootc-uki-addon.8.md b/docs/src/man/bootc-uki-addon.8.md new file mode 100644 index 000000000..f266007df --- /dev/null +++ b/docs/src/man/bootc-uki-addon.8.md @@ -0,0 +1,64 @@ +# NAME + +bootc-uki-addon - Manage UKI addons on the EFI System Partition + +# SYNOPSIS + +**bootc uki-addon** <*COMMAND*> + +# DESCRIPTION + +**This command is experimental and subject to change.** + +Manage UKI (Unified Kernel Image) addons on the EFI System Partition. + +UKI addons are PE binaries that systemd-stub loads alongside the main UKI at +boot. Each addon carries extra kernel command-line parameters that get merged +into the boot configuration. + +There are two types of addons: + +- **Scoped** addons are tied to a specific deployment. They are stored next to + the deployment's UKI and are automatically cleaned up by garbage collection + when the deployment is removed. + +- **Global** addons apply to every UKI on the ESP. They persist across + deployments and are not removed by garbage collection. + +This command requires the composefs backend with UKI boot. + + + + +# COMMANDS + +**list** +: List all installed UKI addons. See **bootc-uki-addon-list**(8). + +**add** +: Add a UKI addon from the booted image. See **bootc-uki-addon-add**(8). + +**remove** +: Remove a UKI addon from the ESP. See **bootc-uki-addon-remove**(8). + +# EXAMPLES + +List all installed addons: + + bootc uki-addon list + +Add a scoped addon from the booted image: + + bootc uki-addon add debug scoped + +Remove a global addon: + + bootc uki-addon remove site-config + +# SEE ALSO + +**bootc**(8), **bootc-uki-addon-list**(8), **bootc-uki-addon-add**(8), **bootc-uki-addon-remove**(8) + +# VERSION + + diff --git a/docs/src/man/bootc.8.md b/docs/src/man/bootc.8.md index c741f8f75..87affcaa0 100644 --- a/docs/src/man/bootc.8.md +++ b/docs/src/man/bootc.8.md @@ -37,6 +37,7 @@ For guides to building, installing, and managing bootable images, see | **bootc install** | Install the running container to a target | | **bootc container** | Operations which can be executed as part of a container build | | **bootc loader-entries** | Operations on Boot Loader Specification (BLS) entries | +| **bootc uki-addon** | Perform operations related to UKI Addons | From db64bbf15aedf178f5193c60edd1a0d0ff2d25b9 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Thu, 10 Sep 2026 17:50:02 +0530 Subject: [PATCH 10/18] cli: Remove composefs_backend requirement from UkiAddonOpts UkiAddonOpts is now flattened into both upgrade and switch commands, which don't have a --composefs-backend flag. The `requires = "composefs_backend"` constraint causes a panic at clap validation time because the referenced argument doesn't exist in those command contexts. This is generally safe as the options are ignored for ostree installs anyway Signed-off-by: Pragyan Poudyal --- crates/lib/src/install.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index e70769704..ba03ec584 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -400,14 +400,14 @@ pub(crate) struct UkiAddonOpts { /// Name of the local/scoped UKI addons to install without the ".efi.addon" suffix. /// This option can be provided multiple times if multiple addons are to be installed /// (composefs backend only). - #[clap(long = "uki-addon", requires = "composefs_backend")] + #[clap(long = "uki-addon")] #[serde(default)] pub(crate) scoped: Option>, /// Name of the global UKI addons to install without the ".efi.addon" suffix. /// This option can be provided multiple times if multiple addons are to be installed /// (composefs backend only). - #[clap(long = "global-uki-addon", requires = "composefs_backend")] + #[clap(long = "global-uki-addon")] #[serde(default)] pub(crate) global: Option>, } From 6ad99fab501ef4d91778eacad2ab9e685cfc371b Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Fri, 11 Sep 2026 10:40:24 +0530 Subject: [PATCH 11/18] uki-addon: Remove addon dir if empty Signed-off-by: Pragyan Poudyal --- .../lib/src/bootc_composefs/uki_addons_cli.rs | 40 ++++++++++++++++++- tmt/tests/booted/test-composefs-gc-uki.nu | 8 ++-- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/crates/lib/src/bootc_composefs/uki_addons_cli.rs b/crates/lib/src/bootc_composefs/uki_addons_cli.rs index 3bf48cfdf..47e19e1c0 100644 --- a/crates/lib/src/bootc_composefs/uki_addons_cli.rs +++ b/crates/lib/src/bootc_composefs/uki_addons_cli.rs @@ -141,10 +141,31 @@ pub(crate) fn handle_addon_cli_cmd( .join(get_scoped_uki_addon_name(addon_name)); esp.fd - .remove_file(addon_path) + .remove_file(&addon_path) .with_context(|| format!("Failed to remove addon {addon_name}"))?; println!("Removed addon {addon_name}"); + + let addons_dir = addon_path + .parent() + .expect("Expected addon path to have a parent"); + + let num_ents = esp + .fd + .open_dir(addons_dir) + .context("Opening addons dir")? + .entries() + .context("Getting addons dir entries")? + .count(); + + // Remove directory if empty + if num_ents == 0 { + esp.fd.remove_dir(&addons_dir).with_context(|| { + format!("Removing addons dir: {}", addons_dir.display()) + })?; + + println!("Removed empty directory {}", addons_dir.display()); + } } // Removing a global addon @@ -166,6 +187,23 @@ pub(crate) fn handle_addon_cli_cmd( .with_context(|| format!("Removing global addon {full_addon_name}"))?; println!("Removed Global Addon {addon_name}"); + + let num_ents = esp + .fd + .open_dir(GLOBAL_UKI_ADDONS_DIR) + .context("Opening global addons dir")? + .entries() + .context("Getting global addons dir entries")? + .count(); + + // Remove directory if empty + if num_ents == 0 { + esp.fd + .remove_dir(GLOBAL_UKI_ADDONS_DIR) + .context("Removing global addons dir")?; + + println!("Removed empty directory {GLOBAL_UKI_ADDONS_DIR}"); + } } } } diff --git a/tmt/tests/booted/test-composefs-gc-uki.nu b/tmt/tests/booted/test-composefs-gc-uki.nu index 4b4dbf7e4..9be4aa94c 100644 --- a/tmt/tests/booted/test-composefs-gc-uki.nu +++ b/tmt/tests/booted/test-composefs-gc-uki.nu @@ -105,10 +105,6 @@ def third_boot [] { assert (not ($"/var/tmp/efi/EFI/Linux/bootc/($uki_prefix)(cat /var/boot0-verity).efi" | path exists)) assert ($"/var/tmp/efi/EFI/Linux/bootc/($uki_prefix)(cat /var/boot1-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)) - echo $st.status.booted.composefs.verity | save /var/boot2-verity # this is not deleted yet @@ -141,6 +137,10 @@ 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)) + mut containerfile = " FROM localhost/bootc as base RUN echo 'another file' > /usr/share/another-one From 3ad0a2ca87d943850d54a358e30e98f77befbba9 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Fri, 11 Sep 2026 11:58:39 +0530 Subject: [PATCH 12/18] uki: Parse composefs cmdline from UKI addons as well Extract cmdline parsing from write_pe_to_esp into parse_uki_cmdline so it runs for both the UKI and UKI addons. The `composefs=` parameter can now be found in the main UKI or a scoped addon - At most one composefs= cmdline across all PE binaries (UKI + addons). A second one is rejected even if the digest matches. - Global UKI addons must never contain `composefs=` cmdline - At least one `composefs=` cmdline must be found or the install fails Signed-off-by: Pragyan Poudyal --- crates/lib/src/bootc_composefs/boot.rs | 240 ++++++++++++++++--------- 1 file changed, 153 insertions(+), 87 deletions(-) diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 80ad39de1..61b437c32 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -94,6 +94,7 @@ 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}; @@ -214,6 +215,9 @@ pub(crate) fn print_uki_dumpfile_diff( let dumpfile_name = mismatch .uki_name() .and_then(|x| x.strip_suffix(EFI_EXT).map(|x| format!("{x}.dump"))); + // .uki_name + // .strip_suffix(EFI_EXT) + // .map(|x| format!("{x}.dump")); let Some(dumpfile_name) = &dumpfile_name else { return; @@ -1069,7 +1073,7 @@ struct UKIInfo { version: Option, os_id: Option, boot_digest: String, - composefs_digest: Sha512HashValue, + composefs_digest: Option, } /// The EROFS format a UKI composefs kernel argument claims for its digest. @@ -1289,113 +1293,158 @@ fn pe_output_dir( } } -/// Writes a PortableExecutable to ESP along with any PE specific or Global addons -#[context("Writing {file_path} to ESP")] -fn write_pe_to_esp( +/// Return a UKI/UKI Addon as a file descriptor from a `Type2Entry` boot entry +fn file_from_type2_entry( repo: &crate::store::ComposefsRepository, - file: &RegularFile, - file_path: &Utf8Path, - pe_type: PEType, - uki_id: &Sha512HashValue, - boot_ids: &ExpectedBootImageIds, - missing_fsverity_allowed: bool, - mounted_efi: impl AsRef, -) -> Result> { - let mut uki_reader = match file { + entry: &Type2Entry, +) -> Result { + match &entry.file { RegularFile::Inline(..) => { // UKI/Addons would always be large enough to be an external object anyhow::bail!("File too small to be UKI/Addon") } RegularFile::External(id, ..) | RegularFile::ExternalNoVerity(id, ..) => { - std::fs::File::from(repo.open_object(id)?) + Ok(std::fs::File::from(repo.open_object(id)?)) } RegularFile::Sparse(..) => { anyhow::bail!("Sparse file cannot be a UKI/Addon") } - }; - - let mut boot_label: Option = None; + } +} - // UKI Extension might not even have a cmdline - // TODO: UKI Addon might also have a composefs= cmdline? +#[context("Parsing UKI cmdline from {uki_name}")] +/// Makes sure there is no composefs= cmdline in a global UKI Addon +/// Makes sure if we already have a parsed cmdline, we don't find another one +fn parse_uki_cmdline( + pe_type: &PEType, + missing_fsverity_allowed: bool, + boot_ids: &ExpectedBootImageIds, + uki_reader: &mut R, + uki_name: &str, + uki_info: &mut UKIInfo, +) -> Result<()> { + // We expect this in the UKI itself and not in addons if matches!(pe_type, PEType::Uki) { - let cmdline = uki::get_cmdline_buffered(&mut uki_reader).context("Getting UKI cmdline")?; + let osrel = uki::get_text_section_buffered(uki_reader, ".osrel")?; - let composefs_candidates = parse_uki_composefs_candidates(&cmdline) - .context("Parsing composefs kernel arguments")?; - let missing_verity_allowed_cmdline = uki_candidates_policy(&composefs_candidates)?; - let composefs_digest = primary_uki_candidate(&composefs_candidates).digest.clone(); + let parsed_osrel = OsReleaseInfo::parse(&osrel); - anyhow::ensure!( - !missing_verity_allowed_cmdline || missing_fsverity_allowed, - "The UKI requests insecure composefs operation, but this repository requires fs-verity. Use --allow-missing-verity only when missing fs-verity is explicitly supported for this install." - ); + uki_reader.seek(SeekFrom::Start(0))?; + let boot_digest = compute_boot_digest_uki(uki_reader)?; - // If the UKI cmdline does not match what the user has passed as cmdline option - // NOTE: This will only be checked for new installs and now upgrades/switches - match missing_fsverity_allowed { - true if !missing_verity_allowed_cmdline => { - tracing::warn!( - "--allow-missing-verity passed as option but UKI cmdline does not support it" - ); - } + uki_reader.seek(SeekFrom::Start(0))?; - false if missing_verity_allowed_cmdline => { - tracing::warn!("UKI cmdline has composefs set as insecure"); - } + uki_info.boot_label = + uki::get_boot_label_buffered(uki_reader).context("Getting UKI boot label")?; + uki_info.version = parsed_osrel.get_version(); + uki_info.os_id = parsed_osrel.get_value(&["ID"]); + uki_info.boot_digest = boot_digest; + + uki_reader.seek(SeekFrom::Start(0))?; + } + + // UKI Addon might not even have a cmdline + let cmdline = uki::get_cmdline_buffered(uki_reader); - _ => { /* no-op */ } + let cmdline_str = match cmdline { + Ok(ref cmdline) => cmdline, + Err(uki::UkiError::MissingSection(..)) => { + // No .cmdline section here + // We might find it in another PE binary + return Ok(()); } + Err(e) => { + return Err(e).context("Getting UKI cmdline"); + } + }; - validate_uki_candidates( - &composefs_candidates, - boot_ids, - file_path.file_name().map(|name| name.to_string()), - )?; + tracing::debug!("cmdline found in {pe_type:?}: {cmdline_str}"); - uki_reader.seek(SeekFrom::Start(0))?; - let osrel = uki::get_text_section_buffered(&mut uki_reader, ".osrel")?; + let composefs_candidates = parse_uki_composefs_candidates(&cmdline_str) + .context("Parsing composefs kernel arguments")?; - let parsed_osrel = OsReleaseInfo::parse(&osrel); + // We could have a cmdline in an Addon, so don't early error out if we don't find it + // immediately + if composefs_candidates.is_empty() { + return Ok(()); + }; - uki_reader.seek(SeekFrom::Start(0))?; - let boot_digest = compute_boot_digest_uki(&mut uki_reader)?; + // Make sure there's no composefs= in a global UKI Addon + if matches!(pe_type, PEType::GlobalUkiAddon) { + anyhow::bail!("Composefs cmdline {cmdline:?} found in a Global UKI Addon"); + } - uki_reader.seek(SeekFrom::Start(0))?; - boot_label = Some(UKIInfo { - boot_label: uki::get_boot_label_buffered(&mut uki_reader) - .context("Getting UKI boot label")?, - version: parsed_osrel.get_version(), - os_id: parsed_osrel.get_value(&["ID"]), - boot_digest, - composefs_digest, - }); + let missing_verity_allowed_cmdline = uki_candidates_policy(&composefs_candidates)?; + let composefs_digest = primary_uki_candidate(&composefs_candidates).digest.clone(); + + // Already found a cmdline, outright refuse another cmdline found in an addon + // or otherwise, even if they're the same + if let Some(found_cmdline) = &uki_info.composefs_digest { + anyhow::bail!("Already had cmdline {found_cmdline:?}, found another {composefs_digest:?}"); + }; + + anyhow::ensure!( + !missing_verity_allowed_cmdline || missing_fsverity_allowed, + "The UKI requests insecure composefs operation, but this repository requires fs-verity. + Use --allow-missing-verity only when missing fs-verity is explicitly supported for this install." + ); + + // If the UKI cmdline does not match what the user has passed as cmdline option + // NOTE: This will only be checked for new installs and now upgrades/switches + match missing_fsverity_allowed { + true if !missing_verity_allowed_cmdline => { + tracing::warn!( + "--allow-missing-fsverity passed as option but UKI cmdline does not support it" + ); + } + + false if missing_verity_allowed_cmdline => { + tracing::warn!("UKI cmdline has composefs set as insecure"); + } + + _ => { /* no-op */ } } - let final_pe_path = pe_output_dir(&pe_type, mounted_efi.as_ref(), file_path, uki_id); + validate_uki_candidates(&composefs_candidates, boot_ids, Some(uki_name.into()))?; + + uki_info.composefs_digest = Some(composefs_digest); + + Ok(()) +} + +/// Writes a PortableExecutable to ESP along with any PE specific or Global addons +#[context("Writing {} to ESP", entry.file_path.display())] +fn write_pe_to_esp( + repo: &crate::store::ComposefsRepository, + entry: &Type2Entry, + uki_id: &Sha512HashValue, + mounted_efi: impl AsRef, +) -> Result<()> { + let mut uki_reader = file_from_type2_entry(repo, entry)?; + + let file_path = Utf8Path::from_path(&entry.file_path) + .ok_or_else(|| anyhow::anyhow!("Path is not valid UTf8"))?; + + let final_pe_path = pe_output_dir(&entry.pe_type, mounted_efi.as_ref(), file_path, uki_id); create_dir_all(&final_pe_path).with_context(|| format!("Creating {final_pe_path:?}"))?; let pe_dir = Dir::open_ambient_dir(&final_pe_path, ambient_authority()) .with_context(|| format!("Opening {final_pe_path:?}"))?; - let pe_name_owned; - let pe_name = match pe_type { - PEType::Uki => { - pe_name_owned = get_uki_name(&boot_label.as_ref().unwrap().composefs_digest.to_hex()); - &pe_name_owned - } + let pe_name = match entry.pe_type { + PEType::Uki => get_uki_name(&uki_id.to_hex()), PEType::UkiAddon | PEType::GlobalUkiAddon => file_path .components() .last() .ok_or_else(|| anyhow::anyhow!("Failed to get UKI Addon file name"))? - .as_str(), + .to_string(), }; // Prefix global Uki Addons for identification - let pe_name = if matches!(pe_type, PEType::GlobalUkiAddon) { - &get_global_uki_addon_name(pe_name) - } else if matches!(pe_type, PEType::UkiAddon) { - &get_scoped_uki_addon_name(pe_name) + 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 }; @@ -1412,7 +1461,7 @@ fn write_pe_to_esp( ) .context("fsync")?; - Ok(boot_label) + Ok(()) } fn uki_file_name(file_path: &Path) -> Result { @@ -1885,7 +1934,15 @@ pub(crate) fn setup_composefs_uki_boot( 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 { @@ -1943,35 +2000,44 @@ pub(crate) fn setup_composefs_uki_boot( let utf8_file_path = Utf8Path::from_path(&entry.file_path) .ok_or_else(|| anyhow::anyhow!("Path is not valid UTf8"))?; - let ret = write_pe_to_esp( - &repo, - &entry.file, - utf8_file_path, - entry.pe_type, - &id, - boot_ids, + let mut uki_reader = file_from_type2_entry(repo, &entry)?; + + parse_uki_cmdline( + &entry.pe_type, missing_fsverity_allowed, - esp_mount.dir.path(), + boot_ids, + &mut uki_reader, + utf8_file_path + .file_name() + .context("Filename not found for PE binary")?, + &mut uki_info, )?; - if let Some(label) = ret { - uki_info = Some(label); - } + entries_to_write.push(entry); } }; } - let uki_info = - uki_info.ok_or_else(|| anyhow::anyhow!("Failed to get version and boot label from UKI"))?; + let Some(deploy_id) = uki_info.composefs_digest else { + anyhow::bail!("No composefs cmdline found in UKI or UKI Addons"); + }; + + if uki_info.boot_label.is_empty() { + anyhow::bail!("Failed to get boot label from UKI"); + } let UKIInfo { boot_label, version, os_id, boot_digest, - composefs_digest: deploy_id, + .. } = uki_info; + for entry in entries_to_write { + write_pe_to_esp(repo, &entry, &deploy_id, esp_mount.dir.path())?; + } + match bootloader.kind()? { BootloaderKind::GRUBClassic => { write_grub_uki_menuentry(&setup_type, boot_label, &deploy_id, &esp_device)? From bbe7aca0ee17af8e87c09f2aff21e74860a3dce4 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Fri, 11 Sep 2026 12:07:56 +0530 Subject: [PATCH 13/18] uki-addon: Refuse to remove addons containing `composefs=` cmdline Before removing a scoped addon, parse its PE binary and check for a composefs= kernel parameter. If found, early exit as removing that addon would make the system unbootable Global addons are not checked because `composefs=` is rejected at install time for global addons Signed-off-by: Pragyan Poudyal --- .../lib/src/bootc_composefs/uki_addons_cli.rs | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/crates/lib/src/bootc_composefs/uki_addons_cli.rs b/crates/lib/src/bootc_composefs/uki_addons_cli.rs index 47e19e1c0..19d131f1c 100644 --- a/crates/lib/src/bootc_composefs/uki_addons_cli.rs +++ b/crates/lib/src/bootc_composefs/uki_addons_cli.rs @@ -7,7 +7,11 @@ use cap_std_ext::cap_std::fs::Dir; use fn_error_context::context; use ostree_ext::{ composefs::fsverity::{FsVerityHashValue, Sha512HashValue}, - composefs_boot::bootloader::{EFI_ADDON_DIR_EXT, EFI_ADDON_FILE_EXT}, + composefs_boot::{ + bootloader::{EFI_ADDON_DIR_EXT, EFI_ADDON_FILE_EXT}, + cmdline::ComposefsCmdline as ComposefsBootCmdline, + uki, + }, composefs_oci::linked_erofs_images, }; @@ -140,6 +144,31 @@ pub(crate) fn handle_addon_cli_cmd( .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}"))?; From eb4841e174c415bbe1656a1f8c8690369c62d7f0 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Fri, 11 Sep 2026 13:12:24 +0530 Subject: [PATCH 14/18] tmt: Add composefs= cmdline validation tests for UKI addons Test that bootc rejects composefs= in the wrong places - Build a global addon containing composefs= from `bootc compute-composefs-digest`, attempt switch with --global-uki-addon, assert failure - Build a scoped addon containing composefs= alongside the UKI (which already has it), attempt switch with --uki-addon, assert failure due to duplicate composefs= It's a shame that we can't test UKI Addon only cmdline without piling on a bunch of hacks since `bootc container ukify` unconditionally puts the cmdline inside the UKI Signed-off-by: Pragyan Poudyal --- tmt/tests/booted/test-composefs-uki-addons.nu | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/tmt/tests/booted/test-composefs-uki-addons.nu b/tmt/tests/booted/test-composefs-uki-addons.nu index 6e16b1235..297d78d65 100644 --- a/tmt/tests/booted/test-composefs-uki-addons.nu +++ b/tmt/tests/booted/test-composefs-uki-addons.nu @@ -60,13 +60,26 @@ def second_boot [] { 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 two addons + # Include composefs cmdline in global addon + # Should fail + let result_global = (do { + bootc switch --transport containers-storage --global-uki-addon global-cfs-cmdline localhost/bootc-uki-addons-2 + } | complete) + + assert ($result_global.exit_code != 0) "Global addon with composefs= should be rejected" + + # Include two addons, but don't include the global composefs= cmdline + # should succeed bootc switch --transport containers-storage --uki-addon monster-cmdline --global-uki-addon global-cmdline localhost/bootc-uki-addons-2 tmt-reboot @@ -89,9 +102,15 @@ def third_boot [] { 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 " @@ -100,7 +119,16 @@ def third_boot [] { 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" + # This should update the global cmdline because we have the same name + # Also this shouldn't include the local cmdline addon so we're good and this should pass bootc switch --transport containers-storage --uki-addon berserk-cmdline localhost/bootc-uki-addons-3 tmt-reboot From d2da99d1255f5af6ad01a7b374dd820569df299b Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Tue, 15 Sep 2026 15:43:49 +0530 Subject: [PATCH 15/18] uki-addons: Add `ListReferenced` cmd option This option lists all the UKI Addons across all deployments. Also, it associates global addons with the deployments that reference them. It is useful for GC-ing global UKI Addons if no deployments refer to them Signed-off-by: Pragyan Poudyal --- crates/lib/src/bootc_composefs/uki_addon.rs | 144 +++++++++++++++++- .../lib/src/bootc_composefs/uki_addons_cli.rs | 28 +++- crates/lib/src/cli.rs | 6 + .../man/bootc-uki-addon-list-referenced.8.md | 54 +++++++ 4 files changed, 227 insertions(+), 5 deletions(-) create mode 100644 docs/src/man/bootc-uki-addon-list-referenced.8.md diff --git a/crates/lib/src/bootc_composefs/uki_addon.rs b/crates/lib/src/bootc_composefs/uki_addon.rs index 6fcd0bda2..7eca455cd 100644 --- a/crates/lib/src/bootc_composefs/uki_addon.rs +++ b/crates/lib/src/bootc_composefs/uki_addon.rs @@ -1,17 +1,24 @@ -#![allow(dead_code)] 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_boot::bootloader::{EFI_ADDON_DIR_EXT, EFI_ADDON_FILE_EXT}; +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, GLOBAL_UKI_ADDONS_DIR}, + bootc_composefs::{ + boot::{BOOTC_UKI_DIR, EFI_LINUX, GLOBAL_UKI_ADDONS_DIR}, + status::BootloaderEntry, + }, composefs_consts::UKI_NAME_PREFIX, - store::Storage, + store::{BootedComposefs, Storage}, }; #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -80,6 +87,69 @@ fn gather_addons_from_dir( Ok(()) } +/// Gathers UKI Addons (Global + Scoped) from the ESP +#[context("Gathering addons from filesystem")] +pub fn gather_addons_from_filesystem( + boot_dir: &Dir, + depl_id: Option<&str>, +) -> Result> { + let mut addons_list: Vec = vec![]; + + if let Some(global_dir) = boot_dir.open_dir_optional(GLOBAL_UKI_ADDONS_DIR)? { + for entry in global_dir.entries_utf8()? { + let entry = entry?; + let filename = entry.file_name()?; + + if let Some(name) = filename.strip_suffix(EFI_ADDON_FILE_EXT) { + addons_list.push(UkiAddonsList { + name: name.to_string(), + addon_type: UkiAddonType::Global, + }); + } + } + } + + let Some(efi_linux) = boot_dir.open_dir_optional(EFI_LINUX)? else { + return Ok(addons_list); + }; + + for entry in efi_linux.entries_utf8()? { + let entry = entry?; + + if !entry.file_type()?.is_dir() { + continue; + } + + let dirname = entry.file_name()?; + + // This will usually be the kernel version + let Some(..) = dirname.strip_suffix(EFI_ADDON_DIR_EXT) else { + continue; + }; + + let dir = efi_linux + .open_dir(&dirname) + .with_context(|| format!("Opening {dirname}"))?; + + for addon_ent in dir.entries_utf8()? { + let addon_ent = addon_ent?; + let filename = addon_ent.file_name()?; + + if let Some(name) = filename.strip_suffix(EFI_ADDON_FILE_EXT) { + addons_list.push(UkiAddonsList { + name: name.to_string(), + addon_type: UkiAddonType::Scoped { + // We can't always have the deployment id with us + depl_id: depl_id.map(|x| x.to_string()).unwrap_or("".into()), + }, + }); + } + } + } + + Ok(addons_list) +} + #[context("Listing UKI Addons")] pub fn list_installed_uki_addons(storage: &Storage) -> Result> { let mut addons = vec![]; @@ -129,3 +199,69 @@ pub fn list_installed_uki_addons(storage: &Storage) -> Result Ok(addons) } + +/// Go through all the EROFS images and get all the referenced UKI Addons +/// +/// Returns a list of tuple of (EROFS verity, List of referenced addons) +#[context("Getting all referenced UKI Addons")] +pub fn list_referenced_uki_addons( + booted_cfs: &BootedComposefs, + bootloader_entries: &Vec, +) -> Result)>> { + let mut all_referenced_addons: Vec<(String, Vec)> = vec![]; + + for entry in bootloader_entries { + let verity = Sha512HashValue::from_hex(&entry.fsverity); + + let verity = match verity { + Ok(v) => v, + Err(e) => { + tracing::warn!( + "Invalid fsverity found in bootloader entry {}: {e:?}", + entry.fsverity + ); + continue; + } + }; + + let linked_erofs = match linked_erofs_images(&booted_cfs.repo, &verity) { + Ok(v) => v, + // Skip entries with no linked EROFS image, but propagate any other error + // TODO: Update with proper error downcasted match once we have + // https://github.com/composefs/composefs-rs/pull/400 released + Err(e) + if e.chain() + .any(|c| c.to_string().contains("No EROFS image found")) => + { + tracing::debug!("No EROFS image found for {}", entry.fsverity); + continue; + } + Err(e) => return Err(e), + }; + + let Some(non_bootable) = linked_erofs.iter().find(|e| !e.bootable) else { + tracing::debug!("No non-bootable EROFS found for {}", entry.fsverity); + continue; + }; + + let composefs_mnt_fd = booted_cfs + .repo + .mount(&non_bootable.id.to_hex()) + .context("Failed to mount composefs image")?; + + let composefs = TempMount::mount_fd(composefs_mnt_fd) + .context("Attaching composefs image to temporary directory")?; + + let cfs_boot_dir = composefs + .fd + .open_dir("boot") + .context("Opening boot directory in composefs image")?; + + let addons_list = gather_addons_from_filesystem(&cfs_boot_dir, Some(&entry.fsverity))?; + + // We work with the bootable fsverity everywhere + all_referenced_addons.push((entry.fsverity.clone(), addons_list)); + } + + Ok(all_referenced_addons) +} diff --git a/crates/lib/src/bootc_composefs/uki_addons_cli.rs b/crates/lib/src/bootc_composefs/uki_addons_cli.rs index 19d131f1c..bae7ab88b 100644 --- a/crates/lib/src/bootc_composefs/uki_addons_cli.rs +++ b/crates/lib/src/bootc_composefs/uki_addons_cli.rs @@ -21,7 +21,8 @@ use crate::{ BOOTC_UKI_DIR, EFI_LINUX, GLOBAL_UKI_ADDONS_DIR, get_global_uki_addon_name, get_scoped_uki_addon_name, get_uki_addon_dir_name, }, - uki_addon::{UkiAddonType, list_installed_uki_addons}, + status::list_bootloader_entries, + uki_addon::{UkiAddonType, list_installed_uki_addons, list_referenced_uki_addons}, }, cli::{UkiAddonCliOpts, UkiAddonScope}, store::{BootedComposefs, Storage}, @@ -331,6 +332,31 @@ pub(crate) fn handle_addon_cli_cmd( } } } + UkiAddonCliOpts::ListReferenced { json } => { + let referenced = + list_referenced_uki_addons(booted_cfs, &list_bootloader_entries(storage)?)?; + + if *json { + return serde_json::to_writer(std::io::stdout(), &referenced) + .context("Writing JSON output"); + } + + if referenced.is_empty() { + println!("No referenced UKI addons found"); + return Ok(()); + } + + for (verity, addons) in &referenced { + println!("Deployment {verity}:"); + if addons.is_empty() { + println!(" (none)"); + } else { + for addon in addons { + println!(" {addon}"); + } + } + } + } } Ok(()) diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 4f96479a5..199b165fa 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -1014,6 +1014,12 @@ pub(crate) enum UkiAddonCliOpts { name: String, addon_type: UkiAddonScope, }, + /// List all referenced UKI Addons across all deployments + ListReferenced { + /// Output in JSON format + #[clap(long)] + json: bool, + }, } /// Deploy and transactionally in-place with bootable container images. diff --git a/docs/src/man/bootc-uki-addon-list-referenced.8.md b/docs/src/man/bootc-uki-addon-list-referenced.8.md new file mode 100644 index 000000000..9939325db --- /dev/null +++ b/docs/src/man/bootc-uki-addon-list-referenced.8.md @@ -0,0 +1,54 @@ +# NAME + +bootc-uki-addon-list-referenced - List all referenced UKI Addons across all deployments + +# SYNOPSIS + +**bootc uki-addon list-referenced** \[*OPTIONS...*\] + +# DESCRIPTION + +**This command is experimental and subject to change.** + +List all UKI addons referenced by EROFS images across all deployments. This +shows which addons each deployment's container image ships, regardless of +whether those addons are currently installed on the ESP. + +This is primarily useful for debugging and understanding which addons are +available in each deployed image. Garbage collection uses this information +internally to determine which global addons can be safely removed. + +# OPTIONS + + +**--json** + + Output in JSON format + + + +# EXAMPLES + +List all referenced addons: + + bootc uki-addon list-referenced + +Example output: + + Deployment cb82031a...: + fav-cmdline (global) + cmdline-extend (scoped (deployment cb82031a...)) + Deployment 1913e6ed...: + berserk (scoped (deployment 1913e6ed...)) + +List referenced addons in JSON format: + + bootc uki-addon list-referenced --json + +# SEE ALSO + +**bootc-uki-addon**(8), **bootc-uki-addon-list**(8), **bootc-uki-addon-add**(8), **bootc-uki-addon-remove**(8) + +# VERSION + + From a6e9aa9d432888c8c390e771ebedd21d3311a22c Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Tue, 15 Sep 2026 17:53:38 +0530 Subject: [PATCH 16/18] uki-addons: GC Global UKI Addons Similar to how we GC UKIs and scoped UKI Addons, GC Global UKI addons if we have no EROFS images remaining that hold a reference to them, which means that the deployments that depended on the Global addons have been removed Signed-off-by: Pragyan Poudyal --- crates/lib/src/bootc_composefs/gc.rs | 59 +++++++++++++++++++-- crates/lib/src/bootc_composefs/uki_addon.rs | 9 +++- tmt/tests/booted/test-composefs-gc-uki.nu | 8 ++- 3 files changed, 69 insertions(+), 7 deletions(-) diff --git a/crates/lib/src/bootc_composefs/gc.rs b/crates/lib/src/bootc_composefs/gc.rs index 06fc1f090..6dda7d017 100644 --- a/crates/lib/src/bootc_composefs/gc.rs +++ b/crates/lib/src/bootc_composefs/gc.rs @@ -17,6 +17,11 @@ use ostree_ext::composefs_oci::linked_erofs_images; use rustix::fs::AtFlags; use rustix::fs::{statat, unlinkat}; +use crate::bootc_composefs::boot::GLOBAL_UKI_ADDONS_DIR; +use crate::bootc_composefs::boot::get_global_uki_addon_name; +use crate::bootc_composefs::uki_addon::UkiAddonType; +use crate::bootc_composefs::uki_addon::list_installed_uki_addons; +use crate::bootc_composefs::uki_addon::list_referenced_uki_addons; use crate::{ bootc_composefs::{ boot::{BOOTC_UKI_DIR, BootType, get_type1_dir_name, get_uki_addon_dir_name, get_uki_name}, @@ -157,9 +162,6 @@ fn delete_kernel_initrd(storage: &Storage, dir_to_delete: &str, dry_run: bool) - fn delete_uki(storage: &Storage, uki_id: &str, dry_run: bool) -> Result<()> { let esp_mnt = storage.require_esp()?; - // NOTE: We don't delete global addons here (see `GLOBAL_UKI_ADDONS_DIR`) - // Which is fine as global addons don't belong to any single deployment, but it also - // means they're never cleaned up at all: see the TODO on `GLOBAL_UKI_ADDONS_DIR`. let uki_dir = esp_mnt.fd.open_dir(BOOTC_UKI_DIR)?; for entry in uki_dir.entries_utf8()? { @@ -306,7 +308,7 @@ pub(crate) async fn composefs_gc( ) } - for (ty, verity) in unreferenced_boot_binaries { + for (ty, verity) in &unreferenced_boot_binaries { match ty { BootType::Bls => { delete_kernel_initrd(storage, &get_type1_dir_name(verity), gc_opts.dry_run)? @@ -315,6 +317,55 @@ pub(crate) async fn composefs_gc( } } + // Remove unreferenced global UKI Addons + let currently_referenced_addons = list_referenced_uki_addons(booted_cfs, &bootloader_entries)?; + let currently_installed_addons = list_installed_uki_addons(storage)?; + let mut unreferenced_global_addons = vec![]; + + tracing::debug!("currently_referenced_addons: {currently_referenced_addons:#?}"); + tracing::debug!("currently_installed_addons: {currently_installed_addons:#?}"); + + for installed_addon in ¤tly_installed_addons { + // We handle scoped UKI Addons along with the UKI itself + if installed_addon.addon_type != UkiAddonType::Global { + continue; + } + + let is_referenced = currently_referenced_addons.iter().any(|(_, refs)| { + refs.iter().any(|r| { + r.addon_type == installed_addon.addon_type && r.name == installed_addon.name + }) + }); + + if !is_referenced { + unreferenced_global_addons.push(installed_addon.name.clone()); + } + } + + tracing::debug!("Unreferenced Global Addons: {unreferenced_global_addons:?}"); + + if !unreferenced_global_addons.is_empty() { + let global_uki_dir = storage + .require_esp()? + .fd + .open_dir(GLOBAL_UKI_ADDONS_DIR) + .context("Opening global UKI Addons dir")?; + + for addon in &unreferenced_global_addons { + let global_addon_name = get_global_uki_addon_name(&addon); + + tracing::debug!("Deleting Global UKI Addon: {}", addon); + + if gc_opts.dry_run { + continue; + } + + global_uki_dir + .remove_file(&global_addon_name) + .with_context(|| format!("Removing global addon {global_addon_name}"))?; + } + } + if !gc_opts.prune_repo { return Ok(GcResult::default()); } diff --git a/crates/lib/src/bootc_composefs/uki_addon.rs b/crates/lib/src/bootc_composefs/uki_addon.rs index 7eca455cd..f3fe87c39 100644 --- a/crates/lib/src/bootc_composefs/uki_addon.rs +++ b/crates/lib/src/bootc_composefs/uki_addon.rs @@ -163,10 +163,15 @@ pub fn list_installed_uki_addons(storage: &Storage) -> Result .context("Gathering global addons")?; }; - for ent in esp + let Some(bootc_uki_dir) = esp .fd - .open_dir(BOOTC_UKI_DIR) + .open_dir_optional(BOOTC_UKI_DIR) .context("Opening UKI dir")? + else { + return Ok(addons); + }; + + for ent in bootc_uki_dir .entries_utf8() .context("Reading UKI dir entries")? { diff --git a/tmt/tests/booted/test-composefs-gc-uki.nu b/tmt/tests/booted/test-composefs-gc-uki.nu index 9be4aa94c..31230d1d1 100644 --- a/tmt/tests/booted/test-composefs-gc-uki.nu +++ b/tmt/tests/booted/test-composefs-gc-uki.nu @@ -33,13 +33,15 @@ def first_boot [] { 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 --uki-addon gc-test 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 @@ -141,6 +143,10 @@ def fourth_boot [] { 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 From b895f3815255b4dbdbd12ea162fe513559c1d8e6 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Wed, 30 Sep 2026 12:39:59 +0530 Subject: [PATCH 17/18] uki-addon: Validate and atomically write on `add` Reject an addon whose cmdline embeds a `composefs=` or `composefs.digest=` argument before copying it. An addon added this way must not be able to override the composefs digest. Write the addon with `atomic_replace_with` and fsync the destination directory, so a crash mid add can't leave a truncated PE on the ESP. Add a tmt test to verify. Signed-off-by: Pragyan Poudyal --- .../lib/src/bootc_composefs/uki_addons_cli.rs | 66 ++++++++++++++----- tmt/tests/booted/test-composefs-uki-addons.nu | 8 +++ 2 files changed, 59 insertions(+), 15 deletions(-) diff --git a/crates/lib/src/bootc_composefs/uki_addons_cli.rs b/crates/lib/src/bootc_composefs/uki_addons_cli.rs index bae7ab88b..08af9edf0 100644 --- a/crates/lib/src/bootc_composefs/uki_addons_cli.rs +++ b/crates/lib/src/bootc_composefs/uki_addons_cli.rs @@ -1,9 +1,10 @@ +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; +use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt}; use fn_error_context::context; use ostree_ext::{ composefs::fsverity::{FsVerityHashValue, Sha512HashValue}, @@ -292,7 +293,37 @@ pub(crate) fn handle_addon_cli_cmd( // before creating directories let addon_path = verify_addon_exists(&cfs_boot_dir, addon_name, *addon_type)?; - match addon_type { + // Make sure this addon doesn't contain a composefs= or composefs.digest= cmdline + let mut addon_file = composefs + .fd + .open(&addon_path) + .with_context(|| format!("Opening UKI Addon at {addon_path}"))?; + + // UKI Addon might not even have a cmdline + let cmdline = uki::get_cmdline_buffered(&mut addon_file); + + match cmdline { + Ok(ref cmdline_str) => { + let cfs_cmdline_info = + ComposefsBootCmdline::::from_cmdline(cmdline_str) + .context("Parsing composefs=")?; + + if let Some(cfs_cmdline) = cfs_cmdline_info { + anyhow::bail!( + "composefs cmdline {cfs_cmdline:?} found in UKI Addon {addon_name}. Refusing to add" + ); + } + } + Err(uki::UkiError::MissingSection(..)) => { + // This is fine, no cmdline section + // so it's just a no op + } + Err(e) => { + return Err(e).context("Getting UKI cmdline"); + } + }; + + let (dest_dir, dest_name) = match addon_type { UkiAddonScope::Global => { esp.fd .create_dir_all(GLOBAL_UKI_ADDONS_DIR) @@ -303,14 +334,7 @@ pub(crate) fn handle_addon_cli_cmd( .open_dir(GLOBAL_UKI_ADDONS_DIR) .context("Opening global addons dir")?; - composefs - .fd - .copy( - addon_path, - &global_addons_dir, - get_global_uki_addon_name(addon_name), - ) - .context("Copying global addon")?; + (global_addons_dir, get_global_uki_addon_name(addon_name)) } UkiAddonScope::Scoped => { let dir_path = Path::new(BOOTC_UKI_DIR) @@ -325,12 +349,24 @@ pub(crate) fn handle_addon_cli_cmd( .open_dir(&dir_path) .context("Opening addons directory")?; - composefs - .fd - .copy(addon_path, &to_dir, get_scoped_uki_addon_name(addon_name)) - .context("Copying addon")?; + (to_dir, get_scoped_uki_addon_name(addon_name)) } - } + }; + + addon_file + .seek(SeekFrom::Start(0)) + .context("Seeking to start of addon")?; + + dest_dir + .atomic_replace_with(&dest_name, |writer| std::io::copy(&mut addon_file, writer)) + .with_context(|| format!("Writing addon {dest_name}"))?; + + rustix::fs::fsync( + dest_dir + .reopen_as_ownedfd() + .context("Reopening as owned fd")?, + ) + .context("fsync")?; } UkiAddonCliOpts::ListReferenced { json } => { let referenced = diff --git a/tmt/tests/booted/test-composefs-uki-addons.nu b/tmt/tests/booted/test-composefs-uki-addons.nu index 297d78d65..7805a1da9 100644 --- a/tmt/tests/booted/test-composefs-uki-addons.nu +++ b/tmt/tests/booted/test-composefs-uki-addons.nu @@ -200,6 +200,14 @@ def fourth_boot [] { 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 } From 2aea32e6bbaa3f201b24cb2b2d6c5363c84af707 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Wed, 30 Sep 2026 15:24:01 +0530 Subject: [PATCH 18/18] install/uki-addons: Bail if ostree / not found `--uki-addon` was a no-op on ostree backend, now we bail if it's passed during ostree installs/upgrades Also, bail if we fail to find an addon in the image instead of just logging as info Update manpage links Signed-off-by: Pragyan Poudyal --- crates/lib/src/bootc_composefs/boot.rs | 94 ++++++++++++++----- crates/lib/src/cli.rs | 9 ++ crates/lib/src/install.rs | 8 ++ docs/src/SUMMARY.md | 5 + tmt/tests/booted/test-composefs-uki-addons.nu | 7 ++ 5 files changed, 99 insertions(+), 24 deletions(-) diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 61b437c32..624d214e5 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -1846,7 +1846,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()?; @@ -1878,6 +1884,7 @@ pub(crate) fn setup_composefs_uki_boot( postfetch.detected_bootloader.clone(), allow_missing_fsverity, addons, + vec![], ) } @@ -1890,20 +1897,30 @@ pub(crate) fn setup_composefs_uki_boot( // These are the currently installed addons which we will update automatically // if we find in the new image - let mut installed_addons = list_installed_uki_addons(storage)?; + // + // Only keep addons referenced by the current deployment + let installed_addons = list_installed_uki_addons(storage)? + .into_iter() + .filter(|addon| match &addon.addon_type { + UkiAddonType::Scoped { depl_id } => *depl_id == *booted_cfs.cmdline.digest, + UkiAddonType::Global => true, + }) + .collect::>(); let target_depl_id = id.to_hex(); + let mut addons_via_cli: Vec = vec![]; + if let Some(addons) = &uki_addon_opts { for addon in addons.global.iter().flatten() { - installed_addons.push(UkiAddonsList { + addons_via_cli.push(UkiAddonsList { name: addon.into(), addon_type: UkiAddonType::Global, }); } for addon in addons.scoped.iter().flatten() { - installed_addons.push(UkiAddonsList { + addons_via_cli.push(UkiAddonsList { name: addon.into(), addon_type: UkiAddonType::Scoped { depl_id: target_depl_id.clone(), @@ -1912,26 +1929,51 @@ pub(crate) fn setup_composefs_uki_boot( } } - // Only keep addons referenced by the current deployment - let installed_addons = installed_addons - .into_iter() - .filter(|addon| match &addon.addon_type { - UkiAddonType::Scoped { depl_id } => { - *depl_id == *booted_cfs.cmdline.digest || *depl_id == target_depl_id - } - UkiAddonType::Global => true, - }) - .collect::>(); - ( esp_dev.path(), bootloader, booted_cfs.cmdline.allow_missing_fsverity, + 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 = UKIInfo { @@ -1965,16 +2007,18 @@ 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}") })?; match entry.pe_type { PEType::Uki => unreachable!("Outer match should've only caught UKI Addons"), PEType::UkiAddon => { - let found = uki_addons.iter().any(|addon| { - matches!(addon.addon_type, UkiAddonType::Scoped { .. }) - && addon.name == addon_name - }); + 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}"); @@ -1982,10 +2026,12 @@ pub(crate) fn setup_composefs_uki_boot( } } PEType::GlobalUkiAddon => { - let found = uki_addons.iter().any(|addon| { - matches!(addon.addon_type, UkiAddonType::Global) - && addon.name == addon_name - }); + 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!( diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 199b165fa..6ab348ca4 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -1781,6 +1781,10 @@ async fn switch(opts: SwitchOpts) -> Result<()> { let storage = &get_storage().await?; match storage.kind()? { BootedStorageKind::Ostree(booted_ostree) => { + if opts.uki_addon_opts.scoped.is_some() || opts.uki_addon_opts.global.is_some() { + anyhow::bail!("UKI Addon options are only supported for composefs backend"); + } + switch_ostree(opts, storage, &booted_ostree).await } BootedStorageKind::Composefs(booted_cfs) => { @@ -2140,6 +2144,11 @@ async fn run_from_opt(opt: Opt) -> Result { let storage = &get_storage().await?; match storage.kind()? { BootedStorageKind::Ostree(booted_ostree) => { + if opts.uki_addon_opts.scoped.is_some() || opts.uki_addon_opts.global.is_some() + { + anyhow::bail!("UKI Addon options are only supported for composefs backend"); + } + upgrade(opts, storage, &booted_ostree).await } BootedStorageKind::Composefs(booted_cfs) => { diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index ba03ec584..b6ca12d3d 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -1660,6 +1660,14 @@ async fn prepare_install( target_fs: Option, ) -> Result> { tracing::trace!("Preparing install"); + + if !composefs_options.composefs_backend + && (composefs_options.uki_addon_opts.scoped.is_some() + || composefs_options.uki_addon_opts.global.is_some()) + { + anyhow::bail!("UKI Addons are only supported on composefs backends"); + } + let allow_missing_verity_explicit = composefs_options.allow_missing_verity; let rootfs = cap_std::fs::Dir::open_ambient_dir("/", cap_std::ambient_authority()) .context("Opening /")?; diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index fe4d0ebfa..86348f2d3 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -92,6 +92,11 @@ - [install reset](bootc-experimental-install-reset.7.md) - [--progress-fd](bootc-experimental-progress-fd.7.md) - [container export](bootc-experimental-container-export.7.md) +- [`man bootc-uki-addon.8.md`](man/bootc-uki-addon.8.md) +- [`man bootc-uki-addon-list.8.md`](man/bootc-uki-addon-list.8.md) +- [`man bootc-uki-addon-add.8.md`](man/bootc-uki-addon-add.8.md) +- [`man bootc-uki-addon-remove.8.md`](man/bootc-uki-addon-remove.8.md) +- [`man bootc-uki-addon-list-referenced.8.md`](man/bootc-uki-addon-list-referenced.8.md) # More information diff --git a/tmt/tests/booted/test-composefs-uki-addons.nu b/tmt/tests/booted/test-composefs-uki-addons.nu index 7805a1da9..c0b1d1a60 100644 --- a/tmt/tests/booted/test-composefs-uki-addons.nu +++ b/tmt/tests/booted/test-composefs-uki-addons.nu @@ -127,6 +127,13 @@ def third_boot [] { 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