From 51b1938e523bede33460de4189495f260f5194cf Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Wed, 9 Sep 2026 12:54:07 +0530 Subject: [PATCH 01/21] 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 265fd58c4a..709d51308e 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::{ BLS_ENTRY_FILE_PREFIX, TYPE1_BOOT_DIR_PREFIX, TYPE1_ENT_PATH, TYPE1_ENT_PATH_STAGED, @@ -1782,7 +1783,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()?; @@ -1803,15 +1807,23 @@ pub(crate) fn setup_composefs_uki_boot( BootSetupType::Upgrade((storage, booted_cfs, host)) => { let bootloader = host.require_composefs_booted()?.bootloader.clone(); + 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::>(); + ( find_booted_from_esp(&storage.physical_root)?, bootloader, booted_cfs.cmdline.allow_missing_fsverity, - // TODO: We never (re)install UKI addons on upgrade, only on initial - // `install`. This is especially relevant for global addons (see the - // TODO on `GLOBAL_UKI_ADDONS_DIR`): if a newer image changes or drops - // one, the ESP copy is never reconciled. - None, + Some(&addons_to_update), ) } }; diff --git a/crates/lib/src/bootc_composefs/mod.rs b/crates/lib/src/bootc_composefs/mod.rs index fe5bc9c6a5..00ae9f34e8 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 0000000000..c59b649fb7 --- /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 ba15ab66d84518cf42cde88fe52781ca6e3484e4 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Wed, 9 Sep 2026 14:04:32 +0530 Subject: [PATCH 02/21] 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 709d51308e..03bed7ff34 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::{ BLS_ENTRY_FILE_PREFIX, TYPE1_BOOT_DIR_PREFIX, TYPE1_ENT_PATH, TYPE1_ENT_PATH_STAGED, @@ -1783,8 +1783,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)) => { @@ -1796,34 +1794,46 @@ pub(crate) fn setup_composefs_uki_boot( // into a bootc system let esp_part = root_setup.device_info.find_first_colocated_esp()?; + let mut addons: Vec = vec![]; + + 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, ) } BootSetupType::Upgrade((storage, booted_cfs, host)) => { let bootloader = host.require_composefs_booted()?.bootloader.clone(); - 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)?; ( find_booted_from_esp(&storage.physical_root)?, bootloader, booted_cfs.cmdline.allow_missing_fsverity, - Some(&addons_to_update), + installed_addons, ) } }; @@ -1843,10 +1853,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() @@ -1860,8 +1866,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 c59b649fb7..cfd89b84e0 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 03eeb22172..2c51d72021 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 { @@ -3200,6 +3207,7 @@ mod tests { composefs_backend, allow_missing_verity, uki_addon, + global_uki_addon: None, }; assert_eq!( opts.validate(bootloader.as_ref(), stateroot).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 a68ece0078..a3291835b5 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) **--run-repart** diff --git a/docs/src/man/bootc-install-to-existing-root.8.md b/docs/src/man/bootc-install-to-existing-root.8.md index 3ff11166ed..e16677621e 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 9549e156b2..ab0bbbf2a5 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 75b9f691e9a6587144ca6eb38e62a00e80f31709 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Wed, 9 Sep 2026 15:20:13 +0530 Subject: [PATCH 03/21] 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 | 8 +- .../man/bootc-install-to-existing-root.8.md | 8 +- docs/src/man/bootc-install-to-filesystem.8.md | 8 +- docs/src/man/bootc-switch.8.md | 8 ++ docs/src/man/bootc-upgrade.8.md | 8 ++ 10 files changed, 134 insertions(+), 53 deletions(-) diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 03bed7ff34..d1adb79c9f 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -123,7 +123,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"; @@ -322,7 +322,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<'_> { @@ -828,7 +835,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()?; @@ -1796,25 +1803,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(), @@ -1824,10 +1829,43 @@ 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(); - 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::>(); ( find_booted_from_esp(&storage.physical_root)?, diff --git a/crates/lib/src/bootc_composefs/switch.rs b/crates/lib/src/bootc_composefs/switch.rs index 0cb0b9ebea..f91c2dc63f 100644 --- a/crates/lib/src/bootc_composefs/switch.rs +++ b/crates/lib/src/bootc_composefs/switch.rs @@ -34,6 +34,7 @@ pub(crate) async fn switch_composefs( use_unified: false, quiet: opts.quiet, prog, + uki_addon_opts: opts.uki_addon_opts.clone(), }; if opts.download_opts.from_downloaded { diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index c0dcafe346..5bd05f0813 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -15,6 +15,7 @@ use ostree_ext::container::ManifestDiff; use crate::bootc_composefs::finalize::get_etc_diff; use crate::bootc_composefs::gc::GCOpts; +use crate::install::UkiAddonOpts; use crate::spec::BootloaderKind; use crate::{ bootc_composefs::{ @@ -234,6 +235,8 @@ pub(crate) struct DoUpgradeOpts { pub(crate) quiet: bool, /// Structured (JSON-Lines) progress sink; see `--progress-fd`. pub(crate) prog: ProgressWriter, + /// The UKI Addons to install from the new image (if any) + pub(crate) uki_addon_opts: UkiAddonOpts, } async fn apply_upgrade( @@ -335,7 +338,7 @@ pub(crate) async fn do_upgrade( let (boot_digest, deploy_id) = match boot_type { BootType::Bls => ( setup_composefs_bls_boot( - BootSetupType::Upgrade((storage, booted_cfs, &host)), + BootSetupType::Upgrade((storage, booted_cfs, &host, None)), &repo, &provisional_deploy_id, provisional_format, @@ -347,7 +350,7 @@ pub(crate) async fn do_upgrade( BootType::Uki => print_uki_dumpfile_diff_on_mismatch( setup_composefs_uki_boot( - BootSetupType::Upgrade((storage, booted_cfs, &host)), + BootSetupType::Upgrade((storage, booted_cfs, &host, Some(&opts.uki_addon_opts))), &repo, &provisional_deploy_id, &boot_ids, @@ -503,6 +506,7 @@ pub(crate) async fn upgrade_composefs( use_unified: false, quiet: opts.quiet, prog, + uki_addon_opts: opts.uki_addon_opts, }; if opts.download_opts.from_downloaded { diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 0a7c0e5987..75bb142f74 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 2c51d72021..f6140d36ab 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 ".addon.efi" 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 ".addon.efi" 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 { @@ -449,9 +456,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(()) } @@ -3206,8 +3217,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(), stateroot).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 a3291835b5..f5bd31f684 100644 --- a/docs/src/man/bootc-install-to-disk.8.md +++ b/docs/src/man/bootc-install-to-disk.8.md @@ -186,13 +186,13 @@ 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) + Name of the local/scoped UKI addons to install without the ".addon.efi" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) -**--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) + Name of the global UKI addons to install without the ".addon.efi" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) **--run-repart** diff --git a/docs/src/man/bootc-install-to-existing-root.8.md b/docs/src/man/bootc-install-to-existing-root.8.md index e16677621e..e20c8d207b 100644 --- a/docs/src/man/bootc-install-to-existing-root.8.md +++ b/docs/src/man/bootc-install-to-existing-root.8.md @@ -227,13 +227,13 @@ of migrating the fstab entries. See the "Injecting kernel arguments" section abo Default: false -**--uki-addon**=*UKI_ADDON* +**--uki-addon**=*SCOPED* - Name of the 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) + Name of the local/scoped UKI addons to install without the ".addon.efi" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) -**--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) + Name of the global UKI addons to install without the ".addon.efi" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) diff --git a/docs/src/man/bootc-install-to-filesystem.8.md b/docs/src/man/bootc-install-to-filesystem.8.md index ab0bbbf2a5..2ec425e99d 100644 --- a/docs/src/man/bootc-install-to-filesystem.8.md +++ b/docs/src/man/bootc-install-to-filesystem.8.md @@ -136,13 +136,13 @@ 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) + Name of the local/scoped UKI addons to install without the ".addon.efi" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) -**--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) + Name of the global UKI addons to install without the ".addon.efi" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) diff --git a/docs/src/man/bootc-switch.8.md b/docs/src/man/bootc-switch.8.md index c32911e938..04c2a1dea0 100644 --- a/docs/src/man/bootc-switch.8.md +++ b/docs/src/man/bootc-switch.8.md @@ -76,6 +76,14 @@ For shared `--apply` and `--soft-reboot` behavior, see Retain reference to currently booted image +**--uki-addon**=*SCOPED* + + Name of the local/scoped UKI addons to install without the ".addon.efi" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) + +**--global-uki-addon**=*GLOBAL* + + Name of the global UKI addons to install without the ".addon.efi" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) + # EXAMPLES diff --git a/docs/src/man/bootc-upgrade.8.md b/docs/src/man/bootc-upgrade.8.md index 0a2face2bc..3c2d7bfa2f 100644 --- a/docs/src/man/bootc-upgrade.8.md +++ b/docs/src/man/bootc-upgrade.8.md @@ -67,6 +67,14 @@ For shared `--apply` and `--soft-reboot` behavior, see Upgrade to a different tag of the currently booted image +**--uki-addon**=*SCOPED* + + Name of the local/scoped UKI addons to install without the ".addon.efi" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) + +**--global-uki-addon**=*GLOBAL* + + Name of the global UKI addons to install without the ".addon.efi" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) + # EXAMPLES From 8dbb639c039766404fb8431a4b6363004029445b Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Thu, 10 Sep 2026 09:56:33 +0530 Subject: [PATCH 04/21] 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 7c54f1e2e6..b6774bf942 100755 --- a/contrib/packaging/finalize-uki +++ b/contrib/packaging/finalize-uki @@ -35,6 +35,17 @@ if [[ -f "${uki_src}/${kver}.dump" ]]; then cp "${uki_src}/${kver}.dump" /boot fi +# If we have a UKI Addon dir, add it +if [[ -d "${uki_src}/${kver}.efi.extra.d" ]]; then + cp -r "${uki_src}/${kver}.efi.extra.d" /boot/EFI/Linux +fi + +# If we have a global UKI Addon dir, add it +if [[ -d "${uki_src}/loader/addons" ]]; then + mkdir -p /boot/loader + cp -r "${uki_src}/loader/addons" /boot/loader +fi + # NOTE: We used to create a symlink from /usr/lib/modules/${kver}/${kver}.efi to the UKI # for tooling compatibility. However, composefs-boot's find_uki_components() doesn't # handle symlinks correctly and fails with "is not a regular file". The UKI is already diff --git a/tmt/plans/integration.fmf b/tmt/plans/integration.fmf index 90e02dc28b..04774a3b59 100644 --- a/tmt/plans/integration.fmf +++ b/tmt/plans/integration.fmf @@ -344,6 +344,14 @@ execute: because: only the image_mode test image installs bootc-tests extra-skip_if_ostree: true +/plan-51-composefs-uki-addons: + summary: Test composefs UKI Addons + discover: + how: fmf + test: + - /tmt/tests/tests/test-51-composefs-uki-addons + extra-skip_if_ostree: true + /plan-52-install-repart: summary: Test bootc install to-disk with systemd-repart partitioning discover: diff --git a/tmt/tests/booted/tap.nu b/tmt/tests/booted/tap.nu index cbfc52da79..e51f8f8633 100644 --- a/tmt/tests/booted/tap.nu +++ b/tmt/tests/booted/tap.nu @@ -85,7 +85,7 @@ rm -vrf /usr/lib/bootc/bound-images.d " } -export def make_uki_containerfile [containerfile: string, --erofs-version: string = ""] { +export def make_uki_containerfile [containerfile: string, --erofs-version: string = "", --addon-cmds: string = ""] { let erofs_version = if $erofs_version == "" { selected_erofs_version } else { @@ -142,6 +142,8 @@ export def make_uki_containerfile [containerfile: string, --erofs-version: strin --write-dumpfile-to /out/${kver}.dump \\ --seal-state ($seal_state) \\ --erofs-version ($erofs_version) + + ($addon_cmds) EOF FROM base-final diff --git a/tmt/tests/booted/test-composefs-uki-addons.nu b/tmt/tests/booted/test-composefs-uki-addons.nu new file mode 100644 index 0000000000..d6f8b61db2 --- /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 28300bfebe..ed74af7185 100644 --- a/tmt/tests/tests.fmf +++ b/tmt/tests/tests.fmf @@ -216,6 +216,11 @@ check: because: only the image_mode test image installs bootc-tests test: bootc-integration-tests booted switch-zstd-chunked +/test-51-composefs-uki-addons: + summary: Test composefs UKI Addons + duration: 30m + test: nu booted/test-composefs-uki-addons.nu + /test-52-install-repart: summary: Test bootc install to-disk with systemd-repart partitioning duration: 30m From a41cc20fa3bb12c8f743a8b0a269a33a2dacf2b0 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Thu, 10 Sep 2026 13:47:41 +0530 Subject: [PATCH 05/21] 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 d1adb79c9f..33b3c2d037 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -153,15 +153,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 94dc9b930fd3f9cac13185f2589140591c8d2337 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Thu, 10 Sep 2026 13:59:27 +0530 Subject: [PATCH 06/21] 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 33b3c2d037..7d32408692 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -558,10 +558,18 @@ pub(crate) fn get_uki_addon_dir_name(depl_verity: &str) -> String { format!("{UKI_NAME_PREFIX}{depl_verity}{EFI_ADDON_DIR_EXT}") } -#[allow(dead_code)] -/// Returns the name of a UKI Addon given verity digest -pub(crate) fn get_uki_addon_file_name(depl_verity: &str) -> String { - format!("{UKI_NAME_PREFIX}{depl_verity}{EFI_ADDON_FILE_EXT}") +/// Returns the name of a scoped/local UKI Addon directory given name +/// with or without the `.addon.efi` prefix +pub(crate) fn get_scoped_uki_addon_name(name: &str) -> String { + let name_wo_suffix = name.strip_suffix(EFI_ADDON_FILE_EXT).unwrap_or(name); + format!("{name_wo_suffix}{EFI_ADDON_FILE_EXT}") +} + +/// Returns the name of a global UKI Addon directory given name +/// with or without the `.addon.efi` prefix +pub(crate) fn get_global_uki_addon_name(name: &str) -> String { + let name_wo_suffix = name.strip_suffix(EFI_ADDON_FILE_EXT).unwrap_or(name); + format!("{UKI_NAME_PREFIX}{name_wo_suffix}{EFI_ADDON_FILE_EXT}") } /// Compute SHA256Sum of VMlinuz + Initrd @@ -1390,6 +1398,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 c9022acf54..7df0b873ae 100644 --- a/crates/lib/src/composefs_consts.rs +++ b/crates/lib/src/composefs_consts.rs @@ -46,6 +46,12 @@ pub(crate) const TYPE1_BOOT_DIR_PREFIX: &str = "bootc_composefs-"; pub(crate) const BLS_ENTRY_FILE_PREFIX: &str = "bootc_"; /// The prefix for names of UKI and UKI Addons +/// +/// The actual name of a scoped UKI Addon is NOT prefixed, +/// only its directory name is prefixed +/// +/// The actual name of a global UKI Addon IS prefixed, since +/// they all live in ESP/loader/addons pub(crate) const UKI_NAME_PREFIX: &str = TYPE1_BOOT_DIR_PREFIX; /// Prefix for OCI tags owned by bootc in the composefs repository. From 6ba006858e78fb58b08329120e7d216d6384f46b Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Thu, 10 Sep 2026 14:03:50 +0530 Subject: [PATCH 07/21] 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 | 53 +++- .../lib/src/bootc_composefs/uki_addons_cli.rs | 270 ++++++++++++++++++ crates/lib/src/cli.rs | 55 ++++ 5 files changed, 372 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 7d32408692..bf2f7be08e 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -1804,8 +1804,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 00ae9f34e8..a33c80017e 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 cfd89b84e0..9d3395c704 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,29 @@ 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_type { + UkiAddonType::Global => { + let Some(addon_name) = addon_name.strip_prefix(UKI_NAME_PREFIX) else { + tracing::info!("Global UKI Addon not managed by bootc found: {addon_name}"); + continue; + }; + + addons.push(UkiAddonsList { + name: addon_name.to_string(), + addon_type: addon_type.clone(), + }); + } + UkiAddonType::Scoped { .. } => { + addons.push(UkiAddonsList { + name: addon_name.to_string(), + addon_type: addon_type.clone(), + }); + } + } } Ok(()) @@ -52,7 +88,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 0000000000..3bf48cfdfd --- /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 75bb142f74..f7631c6dff 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -39,10 +39,13 @@ use ostree_ext::sysroot::SysrootLock; use schemars::schema_for; use serde::{Deserialize, Serialize}; +use crate::bootc_composefs::boot::BootType; use crate::bootc_composefs::delete::delete_composefs_deployment; use crate::bootc_composefs::gc::{GCOpts, composefs_gc}; use crate::bootc_composefs::soft_reboot::{prepare_soft_reboot_composefs, reset_soft_reboot}; use crate::bootc_composefs::state::get_usr_overlay_status; +use crate::bootc_composefs::status::get_composefs_status; +use crate::bootc_composefs::uki_addons_cli::handle_addon_cli_cmd; use crate::bootc_composefs::{ digest::{compute_composefs_digest, new_temp_composefs_repo}, finalize::{composefs_backend_finalize, get_etc_diff}, @@ -985,6 +988,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 +1149,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 +2813,25 @@ async fn run_from_opt(opt: Opt) -> Result { } } } + Opt::UkiAddon(opts) => { + let storage = &get_storage().await?; + match storage.kind()? { + BootedStorageKind::Ostree(_) => { + anyhow::bail!("UKI Addons are only supported for Composefs Backend") + } + BootedStorageKind::Composefs(booted_cfs) => { + let status = get_composefs_status(storage, &booted_cfs).await?; + + if status.require_composefs_booted()?.boot_type != BootType::Uki { + anyhow::bail!( + "Cannot perform UKI Addon operations on systems not booted via UKI" + ); + } + + handle_addon_cli_cmd(storage, &booted_cfs, &opts) + } + } + } }; result.map(|()| CliExitStatus::Success) } From 944a77401be1fdda34c5ca51653aa6b5654fdbb8 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Thu, 10 Sep 2026 15:58:07 +0530 Subject: [PATCH 08/21] 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 3ea7d0c245..4b4dbf7e45 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 d6f8b61db2..6e16b1235e 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 f771a281e1e4c63e7173b0d36af26a82697771b7 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Thu, 10 Sep 2026 16:19:55 +0530 Subject: [PATCH 09/21] 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 | 5 +- 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, 226 insertions(+), 2 deletions(-) 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 f7631c6dff..d92e18af4b 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -1004,7 +1004,7 @@ pub(crate) enum UkiAddonCliOpts { }, /// Remove a UKI Addon Remove { - /// Addon name to be provided without the `.efi.addon` suffix + /// Addon name to be provided without the `.addon.efi` suffix name: String, /// If removing a scoped addon, deployment_id is required. /// If removing a global addon, deployment_id is not required. @@ -1012,7 +1012,7 @@ pub(crate) enum UkiAddonCliOpts { }, /// Add a UKI Addon to the current deployment Add { - /// Addon name to be provided without the `.efi.addon` suffix + /// Addon name to be provided without the `.addon.efi` suffix name: String, addon_type: UkiAddonScope, }, @@ -1150,6 +1150,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 0000000000..17c8a0c0b5 --- /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 `.addon.efi` 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 0000000000..f371ed5461 --- /dev/null +++ b/docs/src/man/bootc-uki-addon-list.8.md @@ -0,0 +1,59 @@ +# NAME + +bootc-uki-addon-list - List all installed UKI Addons + +# SYNOPSIS + +**bootc uki-addon list** \[*OPTIONS...*\] + +# DESCRIPTION + +**This command is experimental and subject to change.** + +List all installed UKI addons on the EFI System Partition, including both +scoped (per-deployment) and global addons. + +By default, output is human-readable with one addon per line. Use `--json` +for machine-readable output suitable for scripting. + +# OPTIONS + + +**--json** + + Output in JSON format + + + +# EXAMPLES + +List all installed addons: + + bootc uki-addon list + +List addons in JSON format: + + bootc uki-addon list --json + +Example JSON output: + +```json +[ + { + "name": "debug", + "addon_type": { "type": "scoped", "depl_id": "a1b2c3..." } + }, + { + "name": "site-config", + "addon_type": { "type": "global" } + } +] +``` + +# SEE ALSO + +**bootc-uki-addon**(8), **bootc-uki-addon-add**(8), **bootc-uki-addon-remove**(8) + +# VERSION + + diff --git a/docs/src/man/bootc-uki-addon-remove.8.md b/docs/src/man/bootc-uki-addon-remove.8.md new file mode 100644 index 0000000000..d467a62182 --- /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 `.addon.efi` 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 0000000000..c8eb11999a --- /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. Addons can carry extra kernel command-line parameters and other resources +that are merged into the boot configuration. + +There are two types of addons: + +- **Scoped** addons are tied to a specific deployment. They are stored next to + the deployment's UKI and are automatically cleaned up by garbage collection + when the deployment is removed. + +- **Global** addons apply to every UKI on the ESP. They persist across + deployments and are 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 c741f8f758..87affcaa07 100644 --- a/docs/src/man/bootc.8.md +++ b/docs/src/man/bootc.8.md @@ -37,6 +37,7 @@ For guides to building, installing, and managing bootable images, see | **bootc install** | Install the running container to a target | | **bootc container** | Operations which can be executed as part of a container build | | **bootc loader-entries** | Operations on Boot Loader Specification (BLS) entries | +| **bootc uki-addon** | Perform operations related to UKI Addons | From ab5f9c3cae9cb40970f5d5629e49c696a1dcad45 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Thu, 10 Sep 2026 17:50:02 +0530 Subject: [PATCH 10/21] 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/cli.rs | 4 ++-- crates/lib/src/install.rs | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index d92e18af4b..4f60451c0c 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -105,7 +105,7 @@ pub(crate) struct DownloadOnlyOpts { /// This unlocks the staged deployment without fetching updates from the container image source. /// The deployment will be applied on the next shutdown or reboot. Use with --apply to /// reboot immediately. - #[clap(long, conflicts_with = "download_only")] + #[clap(long, conflicts_with_all = ["download_only", "scoped", "global"])] pub(crate) from_downloaded: bool, } @@ -119,7 +119,7 @@ pub(crate) struct UpgradeOpts { /// Check if an update is available without applying it. /// /// This only downloads updated metadata, not the full image layers. - #[clap(long, conflicts_with_all = ["apply", "download_only", "from_downloaded"])] + #[clap(long, conflicts_with_all = ["apply", "download_only", "from_downloaded", "scoped", "global"])] pub(crate) check: bool, /// Restart or reboot into the new target image. diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index f6140d36ab..bcafee235f 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -400,15 +400,15 @@ pub(crate) struct UkiAddonOpts { /// Name of the local/scoped UKI addons to install without the ".addon.efi" suffix. /// This option can be provided multiple times if multiple addons are to be installed /// (composefs backend only). - #[clap(long = "uki-addon", requires = "composefs_backend")] - #[serde(default)] + #[clap(long = "uki-addon")] + #[serde(default, rename = "uki_addon")] pub(crate) scoped: Option>, /// Name of the global UKI addons to install without the ".addon.efi" suffix. /// This option can be provided multiple times if multiple addons are to be installed /// (composefs backend only). - #[clap(long = "global-uki-addon", requires = "composefs_backend")] - #[serde(default)] + #[clap(long = "global-uki-addon")] + #[serde(default, rename = "global_uki_addon")] pub(crate) global: Option>, } From 09511d287ad8fccde09a95cc3f4605b741264c93 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Fri, 11 Sep 2026 10:40:24 +0530 Subject: [PATCH 11/21] 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 3bf48cfdfd..47e19e1c0f 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 4b4dbf7e45..9be4aa94cc 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 eb34b2393b9bf6e885a66e86364e8238bce826cf Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Fri, 11 Sep 2026 11:58:39 +0530 Subject: [PATCH 12/21] 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 bf2f7be08e..1227df5c8e 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}; @@ -218,6 +219,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; @@ -1076,7 +1080,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. @@ -1296,113 +1300,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-verity 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 }; @@ -1419,7 +1468,7 @@ fn write_pe_to_esp( ) .context("fsync")?; - Ok(boot_label) + Ok(()) } fn uki_file_name(file_path: &Path) -> Result { @@ -1891,7 +1940,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 { @@ -1949,35 +2006,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 fa4a75b2bb5f9416ec5c26bbcdb047df3b983031 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Fri, 11 Sep 2026 12:07:56 +0530 Subject: [PATCH 13/21] 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 47e19e1c0f..19d131f1c9 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 920a724f13823d1dbf255816c158124ecc82f8be Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Fri, 11 Sep 2026 13:12:24 +0530 Subject: [PATCH 14/21] 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 6e16b1235e..297d78d657 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 4f33b086b5a0f8d788bf370ca42bbd71ddab66f4 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Tue, 15 Sep 2026 15:43:49 +0530 Subject: [PATCH 15/21] 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 +++++++ docs/src/man/bootc-uki-addon.8.md | 9 +- 5 files changed, 235 insertions(+), 6 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 9d3395c704..32fc1ea7cc 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)] @@ -79,6 +86,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![]; @@ -128,3 +198,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 19d131f1c9..bae7ab88b2 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 4f60451c0c..729a506d86 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -1016,6 +1016,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 0000000000..9939325db8 --- /dev/null +++ b/docs/src/man/bootc-uki-addon-list-referenced.8.md @@ -0,0 +1,54 @@ +# NAME + +bootc-uki-addon-list-referenced - List all referenced UKI Addons across all deployments + +# SYNOPSIS + +**bootc uki-addon list-referenced** \[*OPTIONS...*\] + +# DESCRIPTION + +**This command is experimental and subject to change.** + +List all UKI addons referenced by EROFS images across all deployments. This +shows which addons each deployment's container image ships, regardless of +whether those addons are currently installed on the ESP. + +This is primarily useful for debugging and understanding which addons are +available in each deployed image. Garbage collection uses this information +internally to determine which global addons can be safely removed. + +# OPTIONS + + +**--json** + + Output in JSON format + + + +# EXAMPLES + +List all referenced addons: + + bootc uki-addon list-referenced + +Example output: + + Deployment cb82031a...: + fav-cmdline (global) + cmdline-extend (scoped (deployment cb82031a...)) + Deployment 1913e6ed...: + berserk (scoped (deployment 1913e6ed...)) + +List referenced addons in JSON format: + + bootc uki-addon list-referenced --json + +# SEE ALSO + +**bootc-uki-addon**(8), **bootc-uki-addon-list**(8), **bootc-uki-addon-add**(8), **bootc-uki-addon-remove**(8) + +# VERSION + + diff --git a/docs/src/man/bootc-uki-addon.8.md b/docs/src/man/bootc-uki-addon.8.md index c8eb11999a..dfb1d1f86b 100644 --- a/docs/src/man/bootc-uki-addon.8.md +++ b/docs/src/man/bootc-uki-addon.8.md @@ -41,6 +41,9 @@ This command requires the composefs backend with UKI boot. **remove** : Remove a UKI addon from the ESP. See **bootc-uki-addon-remove**(8). +**list-referenced** +: List UKI addons referenced by deployed images. See **bootc-uki-addon-list-referenced**(8). + # EXAMPLES List all installed addons: @@ -55,9 +58,13 @@ Remove a global addon: bootc uki-addon remove site-config +List all referenced addons: + + bootc uki-addon list-referenced + # SEE ALSO -**bootc**(8), **bootc-uki-addon-list**(8), **bootc-uki-addon-add**(8), **bootc-uki-addon-remove**(8) +**bootc**(8), **bootc-uki-addon-list**(8), **bootc-uki-addon-add**(8), **bootc-uki-addon-remove**(8), **bootc-uki-addon-list-referenced**(8) # VERSION From d2b859446f875b2e788eac3fb5c337712c3dec71 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Tue, 15 Sep 2026 17:53:38 +0530 Subject: [PATCH 16/21] 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 +++- docs/src/man/bootc-uki-addon.8.md | 3 +- tmt/tests/booted/test-composefs-gc-uki.nu | 8 ++- 4 files changed, 71 insertions(+), 8 deletions(-) diff --git a/crates/lib/src/bootc_composefs/gc.rs b/crates/lib/src/bootc_composefs/gc.rs index 06fc1f090a..6dda7d017a 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 32fc1ea7cc..6500dee7e9 100644 --- a/crates/lib/src/bootc_composefs/uki_addon.rs +++ b/crates/lib/src/bootc_composefs/uki_addon.rs @@ -162,10 +162,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/docs/src/man/bootc-uki-addon.8.md b/docs/src/man/bootc-uki-addon.8.md index dfb1d1f86b..f6ca4b1eee 100644 --- a/docs/src/man/bootc-uki-addon.8.md +++ b/docs/src/man/bootc-uki-addon.8.md @@ -23,7 +23,8 @@ There are two types of addons: 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. + deployments and are removed by garbage collection once no remaining + deployment references them. This command requires the composefs backend with UKI boot. diff --git a/tmt/tests/booted/test-composefs-gc-uki.nu b/tmt/tests/booted/test-composefs-gc-uki.nu index 9be4aa94cc..31230d1d16 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 58b70d7e79aba819dc80a8cef082d5585f9339dc Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Wed, 30 Sep 2026 12:39:59 +0530 Subject: [PATCH 17/21] 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 bae7ab88b2..08af9edf0f 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 297d78d657..7805a1da9c 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 3a5ea19772116c3b0c30a8c074d12011c5084cd0 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Wed, 30 Sep 2026 15:24:01 +0530 Subject: [PATCH 18/21] 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 1227df5c8e..d46b7767a9 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -1853,7 +1853,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()?; @@ -1888,6 +1894,7 @@ pub(crate) fn setup_composefs_uki_boot( postfetch.detected_bootloader.clone(), allow_missing_fsverity, addons, + vec![], ) } @@ -1896,20 +1903,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(), @@ -1918,26 +1935,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::>(); - ( find_booted_from_esp(&storage.physical_root)?, 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 { @@ -1971,16 +2013,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}"); @@ -1988,10 +2032,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 729a506d86..52e0b81679 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -1783,6 +1783,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) => { @@ -2142,6 +2146,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 bcafee235f..2d6ecc7acd 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -1701,6 +1701,7 @@ async fn prepare_install( target_fs: Option, ) -> Result> { tracing::trace!("Preparing install"); + let allow_missing_verity_explicit = composefs_options.allow_missing_verity; let rootfs = cap_std::fs::Dir::open_ambient_dir("/", cap_std::ambient_authority()) .context("Opening /")?; @@ -1842,6 +1843,13 @@ async fn prepare_install( composefs_options.validate_bound_images(config_opts.bound_images, &rootfs)?; + if !composefs_options.composefs_backend + && (composefs_options.uki_addon_opts.scoped.is_some() + || composefs_options.uki_addon_opts.global.is_some()) + { + anyhow::bail!("UKI Addons are only supported on composefs backends"); + } + // Read the file eagerly so we error out early, and before the mount changes // below hide a file bind mounted under e.g. /tmp. We may re-exec further down // and run this again with those mounts in place, so pass the content to the diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index fe4d0ebfa2..86348f2d36 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -92,6 +92,11 @@ - [install reset](bootc-experimental-install-reset.7.md) - [--progress-fd](bootc-experimental-progress-fd.7.md) - [container export](bootc-experimental-container-export.7.md) +- [`man bootc-uki-addon.8.md`](man/bootc-uki-addon.8.md) +- [`man bootc-uki-addon-list.8.md`](man/bootc-uki-addon-list.8.md) +- [`man bootc-uki-addon-add.8.md`](man/bootc-uki-addon-add.8.md) +- [`man bootc-uki-addon-remove.8.md`](man/bootc-uki-addon-remove.8.md) +- [`man bootc-uki-addon-list-referenced.8.md`](man/bootc-uki-addon-list-referenced.8.md) # More information diff --git a/tmt/tests/booted/test-composefs-uki-addons.nu b/tmt/tests/booted/test-composefs-uki-addons.nu index 7805a1da9c..c0b1d1a60c 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 From 8d197a92a50610b4f2caf5163e86fba884ce348a Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Tue, 13 Oct 2026 13:44:11 +0530 Subject: [PATCH 19/21] gc: Update global UKI Addons check We get referenced global UKI Addons by mounting non-bootable EROFS images and collecting all the addons referenced. This is a pretty expensive operation, so now we only do that if: - The system is booted via UKI, and - Global Addons are actually installed Also, update docs on how scoped and global addons are updated Signed-off-by: Pragyan Poudyal --- crates/lib/src/bootc_composefs/gc.rs | 117 +++++++++++------- crates/lib/src/install.rs | 6 +- docs/src/bootc-composefs.7.md | 8 +- 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 | 4 +- docs/src/man/bootc-upgrade.8.md | 4 +- 8 files changed, 89 insertions(+), 62 deletions(-) diff --git a/crates/lib/src/bootc_composefs/gc.rs b/crates/lib/src/bootc_composefs/gc.rs index 6dda7d017a..f28469081a 100644 --- a/crates/lib/src/bootc_composefs/gc.rs +++ b/crates/lib/src/bootc_composefs/gc.rs @@ -214,6 +214,74 @@ fn unreferenced_boot_binaries<'a>( .collect() } +#[fn_error_context::context("GC Global UKI Addons")] +fn gc_global_uki_addons( + storage: &Storage, + booted_cfs: &BootedComposefs, + gc_opts: &GCOpts, + bootloader_entries: &Vec, +) -> Result<()> { + let currently_installed_addons = list_installed_uki_addons(storage)?; + + if !currently_installed_addons + .iter() + .any(|addon| addon.addon_type == UkiAddonType::Global) + { + tracing::debug!("No bootc managed Global UKI Addon installed"); + return Ok(()); + } + + // Remove unreferenced global UKI Addons + let currently_referenced_addons = list_referenced_uki_addons(booted_cfs, &bootloader_entries)?; + let mut unreferenced_global_addons = vec![]; + + tracing::debug!("currently_referenced_addons: {currently_referenced_addons:#?}"); + tracing::debug!("currently_installed_addons: {currently_installed_addons:#?}"); + + for installed_addon in ¤tly_installed_addons { + // We handle scoped UKI Addons along with the UKI itself + if installed_addon.addon_type != UkiAddonType::Global { + continue; + } + + let is_referenced = currently_referenced_addons.iter().any(|(_, refs)| { + refs.iter().any(|r| { + r.addon_type == installed_addon.addon_type && r.name == installed_addon.name + }) + }); + + if !is_referenced { + unreferenced_global_addons.push(installed_addon.name.clone()); + } + } + + tracing::debug!("Unreferenced Global Addons: {unreferenced_global_addons:?}"); + + if !unreferenced_global_addons.is_empty() { + let global_uki_dir = storage + .require_esp()? + .fd + .open_dir(GLOBAL_UKI_ADDONS_DIR) + .context("Opening global UKI Addons dir")?; + + for addon in &unreferenced_global_addons { + let global_addon_name = get_global_uki_addon_name(&addon); + + tracing::debug!("Deleting Global UKI Addon: {}", addon); + + if gc_opts.dry_run { + continue; + } + + global_uki_dir + .remove_file(&global_addon_name) + .with_context(|| format!("Removing global addon {global_addon_name}"))?; + } + } + + Ok(()) +} + pub(crate) struct GCOpts { pub(crate) dry_run: bool, pub(crate) prune_repo: bool, @@ -317,53 +385,8 @@ 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 booted_cfs_status.boot_type == BootType::Uki { + gc_global_uki_addons(storage, booted_cfs, &gc_opts, &bootloader_entries)?; } if !gc_opts.prune_repo { diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 2d6ecc7acd..594be3fccc 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -399,14 +399,16 @@ pub(crate) struct InstallConfigOpts { pub(crate) struct UkiAddonOpts { /// Name of the local/scoped UKI addons to install without the ".addon.efi" suffix. /// This option can be provided multiple times if multiple addons are to be installed - /// (composefs backend only). + /// (composefs backend only). On upgrade and switch, scoped addons already installed + /// are carried forward; passing this flag adds further addons on top. #[clap(long = "uki-addon")] #[serde(default, rename = "uki_addon")] pub(crate) scoped: Option>, /// Name of the global UKI addons to install without the ".addon.efi" suffix. /// This option can be provided multiple times if multiple addons are to be installed - /// (composefs backend only). + /// (composefs backend only). On upgrade and switch, global addons already installed + /// are carried forward; passing this flag adds further addons on top. #[clap(long = "global-uki-addon")] #[serde(default, rename = "global_uki_addon")] pub(crate) global: Option>, diff --git a/docs/src/bootc-composefs.7.md b/docs/src/bootc-composefs.7.md index 66c556c0c4..78e41d9be6 100644 --- a/docs/src/bootc-composefs.7.md +++ b/docs/src/bootc-composefs.7.md @@ -123,9 +123,11 @@ These remain [experimental](https://github.com/bootc-dev/bootc/blob/main/RELEASE and may change or be removed: - The `grub-cc` bootloader (`--bootloader=grub-cc`). -- UKI addons (`--uki-addon`). Addons are only installed by `bootc install`: - they aren't updated on upgrade, garbage collected, or reverted on - rollback. +- UKI addons (--uki-addon, --global-uki-addon). On upgrade and switch, + addons already installed (scoped or global) are carried forward, and any + passed via the flags are added. Global addons are garbage collected once + no deployment references them, scoped addons are garbage collected along with + their respective deployments. - [Unified storage](bootc-experimental-unified-storage.7.md). ## Limitations diff --git a/docs/src/man/bootc-install-to-disk.8.md b/docs/src/man/bootc-install-to-disk.8.md index f5bd31f684..e834291388 100644 --- a/docs/src/man/bootc-install-to-disk.8.md +++ b/docs/src/man/bootc-install-to-disk.8.md @@ -188,11 +188,11 @@ set `discoverable-partitions = true` in their install configuration **--uki-addon**=*SCOPED* - Name of the local/scoped UKI addons to install without the ".addon.efi" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) + Name of the local/scoped UKI addons to install without the ".addon.efi" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only). On upgrade and switch, scoped addons already installed are carried forward; passing this flag adds further addons on top **--global-uki-addon**=*GLOBAL* - Name of the global UKI addons to install without the ".addon.efi" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) + Name of the global UKI addons to install without the ".addon.efi" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only). On upgrade and switch, global addons already installed are carried forward; passing this flag adds further addons on top **--run-repart** diff --git a/docs/src/man/bootc-install-to-existing-root.8.md b/docs/src/man/bootc-install-to-existing-root.8.md index e20c8d207b..88e7912107 100644 --- a/docs/src/man/bootc-install-to-existing-root.8.md +++ b/docs/src/man/bootc-install-to-existing-root.8.md @@ -229,11 +229,11 @@ of migrating the fstab entries. See the "Injecting kernel arguments" section abo **--uki-addon**=*SCOPED* - Name of the local/scoped UKI addons to install without the ".addon.efi" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) + Name of the local/scoped UKI addons to install without the ".addon.efi" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only). On upgrade and switch, scoped addons already installed are carried forward; passing this flag adds further addons on top **--global-uki-addon**=*GLOBAL* - Name of the global UKI addons to install without the ".addon.efi" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) + Name of the global UKI addons to install without the ".addon.efi" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only). On upgrade and switch, global addons already installed are carried forward; passing this flag adds further addons on top diff --git a/docs/src/man/bootc-install-to-filesystem.8.md b/docs/src/man/bootc-install-to-filesystem.8.md index 2ec425e99d..a380d93e29 100644 --- a/docs/src/man/bootc-install-to-filesystem.8.md +++ b/docs/src/man/bootc-install-to-filesystem.8.md @@ -138,11 +138,11 @@ is currently expected to be empty by default. **--uki-addon**=*SCOPED* - Name of the local/scoped UKI addons to install without the ".addon.efi" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) + Name of the local/scoped UKI addons to install without the ".addon.efi" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only). On upgrade and switch, scoped addons already installed are carried forward; passing this flag adds further addons on top **--global-uki-addon**=*GLOBAL* - Name of the global UKI addons to install without the ".addon.efi" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) + Name of the global UKI addons to install without the ".addon.efi" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only). On upgrade and switch, global addons already installed are carried forward; passing this flag adds further addons on top diff --git a/docs/src/man/bootc-switch.8.md b/docs/src/man/bootc-switch.8.md index 04c2a1dea0..d7e2474bb4 100644 --- a/docs/src/man/bootc-switch.8.md +++ b/docs/src/man/bootc-switch.8.md @@ -78,11 +78,11 @@ For shared `--apply` and `--soft-reboot` behavior, see **--uki-addon**=*SCOPED* - Name of the local/scoped UKI addons to install without the ".addon.efi" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) + Name of the local/scoped UKI addons to install without the ".addon.efi" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only). On upgrade and switch, scoped addons already installed are carried forward; passing this flag adds further addons on top **--global-uki-addon**=*GLOBAL* - Name of the global UKI addons to install without the ".addon.efi" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) + Name of the global UKI addons to install without the ".addon.efi" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only). On upgrade and switch, global addons already installed are carried forward; passing this flag adds further addons on top diff --git a/docs/src/man/bootc-upgrade.8.md b/docs/src/man/bootc-upgrade.8.md index 3c2d7bfa2f..57c741020e 100644 --- a/docs/src/man/bootc-upgrade.8.md +++ b/docs/src/man/bootc-upgrade.8.md @@ -69,11 +69,11 @@ For shared `--apply` and `--soft-reboot` behavior, see **--uki-addon**=*SCOPED* - Name of the local/scoped UKI addons to install without the ".addon.efi" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) + Name of the local/scoped UKI addons to install without the ".addon.efi" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only). On upgrade and switch, scoped addons already installed are carried forward; passing this flag adds further addons on top **--global-uki-addon**=*GLOBAL* - Name of the global UKI addons to install without the ".addon.efi" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only) + Name of the global UKI addons to install without the ".addon.efi" suffix. This option can be provided multiple times if multiple addons are to be installed (composefs backend only). On upgrade and switch, global addons already installed are carried forward; passing this flag adds further addons on top From 402a2b3dcde01642f003321f2f199d9c3e1743ab Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Thu, 15 Oct 2026 10:42:51 +0530 Subject: [PATCH 20/21] uki-addon: Handle staged deployment If we have a staged deployment present, then `uki-addon add/remove` should act on the staged deployment as users usually will expect the addon to take effect after reboot. If we add it to the booted deployment while we have a staged deployment, users won't get the addon after reboot as we'd reboot into the staged deployment Signed-off-by: Pragyan Poudyal --- .../lib/src/bootc_composefs/uki_addons_cli.rs | 50 +++++++++++++++---- crates/lib/src/cli.rs | 16 +++--- docs/src/man/bootc-uki-addon-add.8.md | 2 + docs/src/man/bootc-uki-addon-remove.8.md | 6 ++- tmt/tests/booted/test-composefs-uki-addons.nu | 10 +--- 5 files changed, 57 insertions(+), 27 deletions(-) diff --git a/crates/lib/src/bootc_composefs/uki_addons_cli.rs b/crates/lib/src/bootc_composefs/uki_addons_cli.rs index 08af9edf0f..38dc0a2e5c 100644 --- a/crates/lib/src/bootc_composefs/uki_addons_cli.rs +++ b/crates/lib/src/bootc_composefs/uki_addons_cli.rs @@ -16,6 +16,7 @@ use ostree_ext::{ composefs_oci::linked_erofs_images, }; +use crate::spec::Host; use crate::{ bootc_composefs::{ boot::{ @@ -97,6 +98,7 @@ fn verify_addon_exists( pub(crate) fn handle_addon_cli_cmd( storage: &Storage, booted_cfs: &BootedComposefs, + host: &Host, opts: &UkiAddonCliOpts, ) -> Result<()> { let Ok(esp) = storage.require_esp() else { @@ -123,12 +125,18 @@ pub(crate) fn handle_addon_cli_cmd( } UkiAddonCliOpts::Remove { name: addon_name, - deployment_id, + addon_type, } => { let addons = list_installed_uki_addons(storage)?; - match deployment_id { - Some(depl_id) => { + match addon_type { + UkiAddonScope::Scoped => { + // If we have a staged deployment, we'll operate on that + let depl_id = match &host.status.staged { + Some(staged) => staged.require_composefs()?.verity.as_str(), + None => &*booted_cfs.cmdline.digest, + }; + let found = addons.iter().any(|addon| { matches!( &addon.addon_type, @@ -199,8 +207,7 @@ pub(crate) fn handle_addon_cli_cmd( } } - // Removing a global addon - None => { + UkiAddonScope::Global => { let found = addons.iter().any(|addon| { addon.addon_type == UkiAddonType::Global && addon.name == *addon_name }); @@ -243,9 +250,18 @@ pub(crate) fn handle_addon_cli_cmd( name: addon_name, addon_type, } => { + // If we have a staged deployment, we'll operate on that + let deployment_id = match &host.status.staged { + Some(staged) => staged.require_composefs()?.verity.as_str(), + None => &*booted_cfs.cmdline.digest, + }; + // This should never fail - let booted_digest = Sha512HashValue::from_hex(booted_cfs.cmdline.digest.as_bytes()) - .context("Booted composefs has bad FSVerity")?; + // This is either the staged/booted digest + let deployment_digest = Sha512HashValue::from_hex(deployment_id.as_bytes()) + .with_context(|| { + format!("Composefs deployment has bad FSVerity: {deployment_id}") + })?; let addons = list_installed_uki_addons(storage)?; @@ -256,7 +272,7 @@ pub(crate) fn handle_addon_cli_cmd( UkiAddonScope::Scoped => { matches!( &addon.addon_type, - UkiAddonType::Scoped { depl_id: id } if *id == booted_digest.to_hex() + UkiAddonType::Scoped { depl_id: id } if *id == deployment_digest.to_hex() ) && addon.name == *addon_name } }); @@ -266,7 +282,7 @@ pub(crate) fn handle_addon_cli_cmd( return Ok(()); } - let linked_images = linked_erofs_images(&booted_cfs.repo, &booted_digest) + let linked_images = linked_erofs_images(&booted_cfs.repo, &deployment_digest) .context("Finding linked EROFS for booted deployment")?; let Some(non_bootable_img) = linked_images.iter().find(|img| !img.bootable) else { @@ -338,7 +354,7 @@ pub(crate) fn handle_addon_cli_cmd( } UkiAddonScope::Scoped => { let dir_path = Path::new(BOOTC_UKI_DIR) - .join(get_uki_addon_dir_name(&booted_digest.to_hex())); + .join(get_uki_addon_dir_name(&deployment_digest.to_hex())); esp.fd .create_dir_all(&dir_path) @@ -367,6 +383,20 @@ pub(crate) fn handle_addon_cli_cmd( .context("Reopening as owned fd")?, ) .context("fsync")?; + + match addon_type { + UkiAddonScope::Global => println!("Global UKI Addon {addon_name} added"), + UkiAddonScope::Scoped => { + println!( + "UKI Addon {addon_name} added to {} deployment", + if host.status.staged.is_some() { + "staged" + } else { + "booted" + } + ) + } + } } UkiAddonCliOpts::ListReferenced { json } => { let referenced = diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 52e0b81679..18e1cdd45e 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -1002,18 +1002,22 @@ pub(crate) enum UkiAddonCliOpts { #[clap(long)] json: bool, }, - /// Remove a UKI Addon + /// Remove a UKI Addon from the currently booted deployment. If there is + /// a staged deployment present, the operation is performed on the staged + /// deployment's addons Remove { /// Addon name to be provided without the `.addon.efi` suffix name: String, - /// If removing a scoped addon, deployment_id is required. - /// If removing a global addon, deployment_id is not required. - deployment_id: Option, + /// The type of addon being removed, global or scoped + addon_type: UkiAddonScope, }, - /// Add a UKI Addon to the current deployment + /// Add a UKI Addon to the currently booted deployment. If there is + /// a staged deployment present, the operation is performed on the staged + /// deployment Add { /// Addon name to be provided without the `.addon.efi` suffix name: String, + /// The type of addon being added, global or scoped addon_type: UkiAddonScope, }, /// List all referenced UKI Addons across all deployments @@ -2844,7 +2848,7 @@ async fn run_from_opt(opt: Opt) -> Result { ); } - handle_addon_cli_cmd(storage, &booted_cfs, &opts) + handle_addon_cli_cmd(storage, &booted_cfs, &status, &opts) } } } diff --git a/docs/src/man/bootc-uki-addon-add.8.md b/docs/src/man/bootc-uki-addon-add.8.md index 17c8a0c0b5..68910090f6 100644 --- a/docs/src/man/bootc-uki-addon-add.8.md +++ b/docs/src/man/bootc-uki-addon-add.8.md @@ -26,6 +26,8 @@ already installed, the command is a no-op. **ADDON_TYPE** + The type of addon being added, global or scoped + This argument is required. diff --git a/docs/src/man/bootc-uki-addon-remove.8.md b/docs/src/man/bootc-uki-addon-remove.8.md index d467a62182..a693e2ac4d 100644 --- a/docs/src/man/bootc-uki-addon-remove.8.md +++ b/docs/src/man/bootc-uki-addon-remove.8.md @@ -25,9 +25,11 @@ Use `bootc uki-addon list --json` to find deployment IDs. This argument is required. -**DEPLOYMENT_ID** +**ADDON_TYPE** - If removing a scoped addon, deployment_id is required. If removing a global addon, deployment_id is not required + The type of addon being removed, global or scoped + + This argument is required. diff --git a/tmt/tests/booted/test-composefs-uki-addons.nu b/tmt/tests/booted/test-composefs-uki-addons.nu index c0b1d1a60c..4e89ffa437 100644 --- a/tmt/tests/booted/test-composefs-uki-addons.nu +++ b/tmt/tests/booted/test-composefs-uki-addons.nu @@ -178,7 +178,7 @@ def fourth_boot [] { bootc uki-addon list # Remove the global addon - bootc uki-addon remove global-cmdline + bootc uki-addon remove global-cmdline global let addons_after_remove = bootc uki-addon list --json | from json assert (($addons_after_remove | length) == 2) let globals_after = $addons_after_remove | where addon_type.type == "global" @@ -195,14 +195,6 @@ def fourth_boot [] { # 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) From 6e0605a3148e594b3d09bb32c247c25d7ba9b929 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Thu, 15 Oct 2026 12:29:04 +0530 Subject: [PATCH 21/21] global-uki-addon: Have a staged directory Create a staged directory for global uki addons `loader/addons.stage` in the ESP which we will atomically rename-exchange with the the actual addons directory `loader/addons` `uki-addon add`/`remove` already act on a staged deployment for scoped addons. Extend the same behaviour to global addons. When a deployment is staged, add/remove operate on the staged set at `loader/addons.staged` instead of the live directory. Document the staged global-addon removal behaviour in the man page and the composefs overview, and add a tmt tests. Signed-off-by: Pragyan Poudyal --- crates/lib/src/bootc_composefs/boot.rs | 101 ++++++++++++++++-- crates/lib/src/bootc_composefs/finalize.rs | 24 ++++- crates/lib/src/bootc_composefs/gc.rs | 9 +- crates/lib/src/bootc_composefs/uki_addon.rs | 61 ++++++++--- .../lib/src/bootc_composefs/uki_addons_cli.rs | 69 +++++++++--- docs/src/bootc-composefs.7.md | 8 +- docs/src/man/bootc-uki-addon-remove.8.md | 17 +++ tmt/tests/booted/test-composefs-uki-addons.nu | 47 ++++++++ 8 files changed, 297 insertions(+), 39 deletions(-) diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index d46b7767a9..5163de1758 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -162,6 +162,9 @@ pub(crate) const BOOTC_UKI_DIR: &str = "EFI/Linux/bootc"; /// rollback (a global addon update isn't reverted by rolling back to an older deployment). pub(crate) const GLOBAL_UKI_ADDONS_DIR: &str = "loader/addons"; +/// Where all staged Global UKI Addons are kept +pub(crate) const STAGED_GLOBAL_UKI_ADDONS_DIR: &str = "loader/addons.staged"; + #[derive(thiserror::Error, Debug)] pub(crate) enum UKIDigestMismatch { #[error("The UKI has the wrong composefs= parameter (is '{actual}', should be '{expected}')")] @@ -1276,8 +1279,13 @@ fn pe_output_dir( mounted_efi: &Path, file_path: &Utf8Path, uki_id: &Sha512HashValue, + is_upgrade: bool, ) -> std::path::PathBuf { if matches!(pe_type, PEType::GlobalUkiAddon) { + if is_upgrade { + return mounted_efi.join(STAGED_GLOBAL_UKI_ADDONS_DIR); + } + return mounted_efi.join(GLOBAL_UKI_ADDONS_DIR); } @@ -1419,6 +1427,49 @@ fn parse_uki_cmdline( Ok(()) } +/// Copies Global UKI Addons from loader/addons to loader/addons.staged in case of +/// an upgrade/switch +#[context("Staging Global UKI Addons")] +fn stage_global_uki_addons( + esp_fd: &Dir, + entries_to_write: &Vec>, +) -> Result<()> { + if !entries_to_write + .iter() + .any(|ent| matches!(ent.pe_type, PEType::GlobalUkiAddon)) + { + // No Global UKI Addons to stage + return Ok(()); + } + + let Some(global_addons_dir) = esp_fd.open_dir_optional(GLOBAL_UKI_ADDONS_DIR)? else { + // No current Global UKI Addons to copy + return Ok(()); + }; + + esp_fd + .create_dir_all(STAGED_GLOBAL_UKI_ADDONS_DIR) + .context("Creating directory for staged Global UKI Addons")?; + + let staged_addons_dir = esp_fd + .open_dir(STAGED_GLOBAL_UKI_ADDONS_DIR) + .context("Opening staged Global UKI Addons dir")?; + + // Now copy all addons to the staged directory + for ent in global_addons_dir + .entries_utf8() + .context("Reading entries in Global UKI directory")? + { + let entry = ent?; + let entry_name = entry.file_name().context("Reading file name")?; + global_addons_dir + .copy(&entry_name, &staged_addons_dir, &entry_name) + .with_context(|| format!("Copying addon {entry_name}"))?; + } + + Ok(()) +} + /// Writes a PortableExecutable to ESP along with any PE specific or Global addons #[context("Writing {} to ESP", entry.file_path.display())] fn write_pe_to_esp( @@ -1426,13 +1477,20 @@ fn write_pe_to_esp( entry: &Type2Entry, uki_id: &Sha512HashValue, mounted_efi: impl AsRef, + is_upgrade: bool, ) -> Result<()> { let mut uki_reader = file_from_type2_entry(repo, entry)?; let file_path = Utf8Path::from_path(&entry.file_path) .ok_or_else(|| anyhow::anyhow!("Path is not valid UTf8"))?; - let final_pe_path = pe_output_dir(&entry.pe_type, mounted_efi.as_ref(), file_path, uki_id); + let final_pe_path = pe_output_dir( + &entry.pe_type, + mounted_efi.as_ref(), + file_path, + uki_id, + is_upgrade, + ); create_dir_all(&final_pe_path).with_context(|| format!("Creating {final_pe_path:?}"))?; let pe_dir = Dir::open_ambient_dir(&final_pe_path, ambient_authority()) @@ -1879,6 +1937,7 @@ pub(crate) fn setup_composefs_uki_boot( addon_type: UkiAddonType::Scoped { depl_id: id.to_hex(), }, + staged: false, }); } @@ -1886,6 +1945,7 @@ pub(crate) fn setup_composefs_uki_boot( addons.push(UkiAddonsList { name: addon.into(), addon_type: UkiAddonType::Global, + staged: false, }); } @@ -1905,7 +1965,7 @@ pub(crate) fn setup_composefs_uki_boot( // if we find in the new image // // Only keep addons referenced by the current deployment - let installed_addons = list_installed_uki_addons(storage)? + let installed_addons = list_installed_uki_addons(storage, &host.status.staged)? .into_iter() .filter(|addon| match &addon.addon_type { UkiAddonType::Scoped { depl_id } => *depl_id == *booted_cfs.cmdline.digest, @@ -1922,6 +1982,7 @@ pub(crate) fn setup_composefs_uki_boot( addons_via_cli.push(UkiAddonsList { name: addon.into(), addon_type: UkiAddonType::Global, + staged: false, }); } @@ -1931,6 +1992,7 @@ pub(crate) fn setup_composefs_uki_boot( addon_type: UkiAddonType::Scoped { depl_id: target_depl_id.clone(), }, + staged: false, }); } } @@ -2086,8 +2148,15 @@ pub(crate) fn setup_composefs_uki_boot( .. } = uki_info; + let is_upgrade = matches!(setup_type, BootSetupType::Upgrade(..)); + + // Create a `ESP/loader/addons.staged` if we have Global UKI Addons to update + if is_upgrade { + stage_global_uki_addons(&esp_mount.fd, &entries_to_write)?; + } + for entry in entries_to_write { - write_pe_to_esp(repo, &entry, &deploy_id, esp_mount.dir.path())?; + write_pe_to_esp(repo, &entry, &deploy_id, esp_mount.dir.path(), is_upgrade)?; } match bootloader.kind()? { @@ -2556,7 +2625,13 @@ mod tests { // A UKI itself always lands directly in BOOTC_UKI_DIR. assert_eq!( - pe_output_dir(&PEType::Uki, mounted_efi, Utf8Path::new("foo.efi"), &uki_id), + pe_output_dir( + &PEType::Uki, + mounted_efi, + Utf8Path::new("foo.efi"), + &uki_id, + false + ), mounted_efi.join(BOOTC_UKI_DIR) ); @@ -2567,7 +2642,8 @@ mod tests { &PEType::UkiAddon, mounted_efi, Utf8Path::new("foo.efi.extra.d/bar.addon.efi"), - &uki_id + &uki_id, + false, ), mounted_efi .join(BOOTC_UKI_DIR) @@ -2581,10 +2657,23 @@ mod tests { &PEType::GlobalUkiAddon, mounted_efi, Utf8Path::new("bar.addon.efi"), - &uki_id + &uki_id, + false ), mounted_efi.join(GLOBAL_UKI_ADDONS_DIR) ); + + // On upgrade/switch global addons are written to `loader/addons.staged` + assert_eq!( + pe_output_dir( + &PEType::GlobalUkiAddon, + mounted_efi, + Utf8Path::new("bar.addon.efi"), + &uki_id, + true + ), + mounted_efi.join(STAGED_GLOBAL_UKI_ADDONS_DIR) + ); } #[test] diff --git a/crates/lib/src/bootc_composefs/finalize.rs b/crates/lib/src/bootc_composefs/finalize.rs index 642e8d6d40..c561a0a09d 100644 --- a/crates/lib/src/bootc_composefs/finalize.rs +++ b/crates/lib/src/bootc_composefs/finalize.rs @@ -1,6 +1,6 @@ use std::path::Path; -use crate::bootc_composefs::boot::BootType; +use crate::bootc_composefs::boot::{BootType, GLOBAL_UKI_ADDONS_DIR, STAGED_GLOBAL_UKI_ADDONS_DIR}; use crate::bootc_composefs::gc::{GCOpts, composefs_gc}; use crate::bootc_composefs::rollback::{rename_exchange_bls_entries, rename_exchange_user_cfg}; use crate::bootc_composefs::status::get_composefs_status; @@ -16,7 +16,7 @@ use cap_std_ext::dirext::CapStdExtDirExt; use composefs::generic_tree::{FileSystem, Stat}; use composefs_ctl::composefs; use etc_merge::{Diff, compute_diff, merge, traverse_etc}; -use rustix::fs::{Mode, OFlags, fsync}; +use rustix::fs::{Mode, OFlags, RenameFlags, fsync, renameat_with}; use fn_error_context::context; @@ -138,6 +138,26 @@ pub(crate) async fn composefs_backend_finalize( let boot_dir = storage.require_boot_dir()?; + // Un-stage Global UKI Addons (if present) as the penultimate step + if boot_dir.exists(STAGED_GLOBAL_UKI_ADDONS_DIR) { + tracing::debug!( + "Atomically exchanging {STAGED_GLOBAL_UKI_ADDONS_DIR} and {GLOBAL_UKI_ADDONS_DIR}" + ); + + renameat_with( + &boot_dir, + STAGED_GLOBAL_UKI_ADDONS_DIR, + &boot_dir, + GLOBAL_UKI_ADDONS_DIR, + RenameFlags::EXCHANGE, + ) + .context("Atomically exchanging staged Global UKI Addons")?; + + boot_dir + .remove_dir_all(STAGED_GLOBAL_UKI_ADDONS_DIR) + .context("Removing staged Global UKI Addons dir")?; + } + match booted_composefs.bootloader.kind()? { BootloaderKind::GRUBClassic => match staged_composefs.boot_type { BootType::Bls => { diff --git a/crates/lib/src/bootc_composefs/gc.rs b/crates/lib/src/bootc_composefs/gc.rs index f28469081a..5bcfc0981f 100644 --- a/crates/lib/src/bootc_composefs/gc.rs +++ b/crates/lib/src/bootc_composefs/gc.rs @@ -221,7 +221,9 @@ fn gc_global_uki_addons( gc_opts: &GCOpts, bootloader_entries: &Vec, ) -> Result<()> { - let currently_installed_addons = list_installed_uki_addons(storage)?; + // GC always runs *after* finalizing deployments so there would be no staged deployment + // by definition + let currently_installed_addons = list_installed_uki_addons(storage, &None)?; if !currently_installed_addons .iter() @@ -232,7 +234,10 @@ fn gc_global_uki_addons( } // Remove unreferenced global UKI Addons - let currently_referenced_addons = list_referenced_uki_addons(booted_cfs, &bootloader_entries)?; + // GC always runs *after* finalizing deployments so there would be no staged deployment + // by definition + let currently_referenced_addons = + list_referenced_uki_addons(booted_cfs, &bootloader_entries, &None)?; let mut unreferenced_global_addons = vec![]; tracing::debug!("currently_referenced_addons: {currently_referenced_addons:#?}"); diff --git a/crates/lib/src/bootc_composefs/uki_addon.rs b/crates/lib/src/bootc_composefs/uki_addon.rs index 6500dee7e9..0d5abbc3d3 100644 --- a/crates/lib/src/bootc_composefs/uki_addon.rs +++ b/crates/lib/src/bootc_composefs/uki_addon.rs @@ -14,10 +14,11 @@ use serde::Serialize; use crate::{ bootc_composefs::{ - boot::{BOOTC_UKI_DIR, EFI_LINUX, GLOBAL_UKI_ADDONS_DIR}, + boot::{BOOTC_UKI_DIR, EFI_LINUX, GLOBAL_UKI_ADDONS_DIR, STAGED_GLOBAL_UKI_ADDONS_DIR}, status::BootloaderEntry, }, composefs_consts::UKI_NAME_PREFIX, + spec::BootEntry, store::{BootedComposefs, Storage}, }; @@ -41,6 +42,8 @@ impl fmt::Display for UkiAddonType { pub struct UkiAddonsList { pub name: String, pub addon_type: UkiAddonType, + /// Whether this addon is part of a staged deployment + pub staged: bool, } impl fmt::Display for UkiAddonsList { @@ -53,6 +56,7 @@ fn gather_addons_from_dir( dir: &Dir, addons: &mut Vec, addon_type: UkiAddonType, + staged: bool, ) -> Result<()> { for ent in dir.entries_utf8()? { let ent = ent?; @@ -72,12 +76,14 @@ fn gather_addons_from_dir( addons.push(UkiAddonsList { name: addon_name.to_string(), addon_type: addon_type.clone(), + staged, }); } UkiAddonType::Scoped { .. } => { addons.push(UkiAddonsList { name: addon_name.to_string(), addon_type: addon_type.clone(), + staged, }); } } @@ -91,19 +97,23 @@ fn gather_addons_from_dir( pub fn gather_addons_from_filesystem( boot_dir: &Dir, depl_id: Option<&str>, + staged_deployment: &Option, ) -> 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, - }); + for dir in [GLOBAL_UKI_ADDONS_DIR, STAGED_GLOBAL_UKI_ADDONS_DIR] { + if let Some(global_dir) = boot_dir.open_dir_optional(dir)? { + for entry in global_dir.entries_utf8()? { + let entry = entry?; + let filename = entry.file_name()?; + + if let Some(name) = filename.strip_suffix(EFI_ADDON_FILE_EXT) { + addons_list.push(UkiAddonsList { + name: name.to_string(), + addon_type: UkiAddonType::Global, + staged: dir == STAGED_GLOBAL_UKI_ADDONS_DIR, + }); + } } } } @@ -112,6 +122,11 @@ pub fn gather_addons_from_filesystem( return Ok(addons_list); }; + let staged_deployment_id = match staged_deployment { + Some(entry) => Some(entry.require_composefs()?.verity.as_str()), + None => None, + }; + for entry in efi_linux.entries_utf8()? { let entry = entry?; @@ -141,6 +156,8 @@ pub fn gather_addons_from_filesystem( // We can't always have the deployment id with us depl_id: depl_id.map(|x| x.to_string()).unwrap_or("".into()), }, + staged: staged_deployment_id + .is_some_and(|staged_id| Some(staged_id) == depl_id), }); } } @@ -150,7 +167,10 @@ pub fn gather_addons_from_filesystem( } #[context("Listing UKI Addons")] -pub fn list_installed_uki_addons(storage: &Storage) -> Result> { +pub fn list_installed_uki_addons( + storage: &Storage, + staged_deployment: &Option, +) -> Result> { let mut addons = vec![]; let Ok(esp) = storage.require_esp() else { @@ -158,10 +178,15 @@ 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, false) .context("Gathering global addons")?; }; + if let Some(global_dir) = esp.fd.open_dir_optional(STAGED_GLOBAL_UKI_ADDONS_DIR)? { + gather_addons_from_dir(&global_dir, &mut addons, UkiAddonType::Global, true) + .context("Gathering staged global addons")?; + }; + let Some(bootc_uki_dir) = esp .fd .open_dir_optional(BOOTC_UKI_DIR) @@ -170,6 +195,11 @@ pub fn list_installed_uki_addons(storage: &Storage) -> Result return Ok(addons); }; + let staged_deployment_id = match staged_deployment { + Some(ent) => Some(ent.require_composefs()?.verity.as_str()), + None => None, + }; + for ent in bootc_uki_dir .entries_utf8() .context("Reading UKI dir entries")? @@ -198,6 +228,7 @@ pub fn list_installed_uki_addons(storage: &Storage) -> Result UkiAddonType::Scoped { depl_id: depl_id.to_string(), }, + staged_deployment_id.is_some_and(|verity| verity == depl_id), )?; } @@ -211,6 +242,7 @@ pub fn list_installed_uki_addons(storage: &Storage) -> Result pub fn list_referenced_uki_addons( booted_cfs: &BootedComposefs, bootloader_entries: &Vec, + staged_deployment: &Option, ) -> Result)>> { let mut all_referenced_addons: Vec<(String, Vec)> = vec![]; @@ -261,7 +293,8 @@ pub fn list_referenced_uki_addons( .open_dir("boot") .context("Opening boot directory in composefs image")?; - let addons_list = gather_addons_from_filesystem(&cfs_boot_dir, Some(&entry.fsverity))?; + let addons_list = + gather_addons_from_filesystem(&cfs_boot_dir, Some(&entry.fsverity), staged_deployment)?; // We work with the bootable fsverity everywhere all_referenced_addons.push((entry.fsverity.clone(), addons_list)); diff --git a/crates/lib/src/bootc_composefs/uki_addons_cli.rs b/crates/lib/src/bootc_composefs/uki_addons_cli.rs index 38dc0a2e5c..87ca2052a6 100644 --- a/crates/lib/src/bootc_composefs/uki_addons_cli.rs +++ b/crates/lib/src/bootc_composefs/uki_addons_cli.rs @@ -16,6 +16,7 @@ use ostree_ext::{ composefs_oci::linked_erofs_images, }; +use crate::bootc_composefs::boot::STAGED_GLOBAL_UKI_ADDONS_DIR; use crate::spec::Host; use crate::{ bootc_composefs::{ @@ -107,7 +108,7 @@ pub(crate) fn handle_addon_cli_cmd( match opts { UkiAddonCliOpts::List { json } => { - let addons = list_installed_uki_addons(storage)?; + let addons = list_installed_uki_addons(storage, &host.status.staged)?; if *json { return serde_json::to_writer(std::io::stdout(), &addons) @@ -127,7 +128,9 @@ pub(crate) fn handle_addon_cli_cmd( name: addon_name, addon_type, } => { - let addons = list_installed_uki_addons(storage)?; + let addons = list_installed_uki_addons(storage, &host.status.staged)?; + + let is_staged = host.status.staged.is_some(); match addon_type { UkiAddonScope::Scoped => { @@ -142,6 +145,7 @@ pub(crate) fn handle_addon_cli_cmd( &addon.addon_type, UkiAddonType::Scoped { depl_id: id } if id == depl_id ) && addon.name == *addon_name + && addon.staged == is_staged }); if !found { @@ -209,7 +213,9 @@ pub(crate) fn handle_addon_cli_cmd( UkiAddonScope::Global => { let found = addons.iter().any(|addon| { - addon.addon_type == UkiAddonType::Global && addon.name == *addon_name + addon.addon_type == UkiAddonType::Global + && addon.name == *addon_name + && addon.staged == is_staged }); if !found { @@ -220,27 +226,48 @@ pub(crate) fn handle_addon_cli_cmd( tracing::debug!("Removing Global UKI Addon {full_addon_name}"); + let global_addons_dir = if is_staged { + STAGED_GLOBAL_UKI_ADDONS_DIR + } else { + GLOBAL_UKI_ADDONS_DIR + }; + esp.fd - .remove_file(format!("{GLOBAL_UKI_ADDONS_DIR}/{full_addon_name}")) + .remove_file(format!("{global_addons_dir}/{full_addon_name}")) .with_context(|| format!("Removing global addon {full_addon_name}"))?; println!("Removed Global Addon {addon_name}"); let num_ents = esp .fd - .open_dir(GLOBAL_UKI_ADDONS_DIR) + .open_dir(global_addons_dir) .context("Opening global addons dir")? .entries() .context("Getting global addons dir entries")? .count(); - // Remove directory if empty + // Remove the directory if it's now empty. if num_ents == 0 { esp.fd - .remove_dir(GLOBAL_UKI_ADDONS_DIR) + .remove_dir(global_addons_dir) .context("Removing global addons dir")?; - println!("Removed empty directory {GLOBAL_UKI_ADDONS_DIR}"); + println!("Removed empty directory {global_addons_dir}"); + + // The staged global addon set is a transient overlay that + // finalize exchanges with the live set on the next boot. An + // empty overlay is collapsed rather than applied, so removing + // the last staged global addon leaves the currently active + // global addons in effect after reboot. + if is_staged { + println!( + "Note: '{addon_name}' was the last staged global addon. \ + An empty staged global addon set is not applied on boot, so \ + the currently active global addons remain in effect after \ + reboot. To drop all global addons, remove them while no \ + deployment is staged." + ); + } } } } @@ -256,6 +283,8 @@ pub(crate) fn handle_addon_cli_cmd( None => &*booted_cfs.cmdline.digest, }; + let is_staged = host.status.staged.is_some(); + // This should never fail // This is either the staged/booted digest let deployment_digest = Sha512HashValue::from_hex(deployment_id.as_bytes()) @@ -263,17 +292,20 @@ pub(crate) fn handle_addon_cli_cmd( format!("Composefs deployment has bad FSVerity: {deployment_id}") })?; - let addons = list_installed_uki_addons(storage)?; + let addons = list_installed_uki_addons(storage, &host.status.staged)?; let already_present = addons.iter().any(|addon| match addon_type { UkiAddonScope::Global => { - addon.addon_type == UkiAddonType::Global && addon.name == *addon_name + addon.addon_type == UkiAddonType::Global + && addon.name == *addon_name + && addon.staged == is_staged } UkiAddonScope::Scoped => { matches!( &addon.addon_type, UkiAddonType::Scoped { depl_id: id } if *id == deployment_digest.to_hex() ) && addon.name == *addon_name + && addon.staged == is_staged } }); @@ -341,13 +373,19 @@ pub(crate) fn handle_addon_cli_cmd( let (dest_dir, dest_name) = match addon_type { UkiAddonScope::Global => { + let global_addons_dir = if is_staged { + STAGED_GLOBAL_UKI_ADDONS_DIR + } else { + GLOBAL_UKI_ADDONS_DIR + }; + esp.fd - .create_dir_all(GLOBAL_UKI_ADDONS_DIR) + .create_dir_all(global_addons_dir) .context("Creating global addons directory")?; let global_addons_dir = esp .fd - .open_dir(GLOBAL_UKI_ADDONS_DIR) + .open_dir(global_addons_dir) .context("Opening global addons dir")?; (global_addons_dir, get_global_uki_addon_name(addon_name)) @@ -399,8 +437,11 @@ pub(crate) fn handle_addon_cli_cmd( } } UkiAddonCliOpts::ListReferenced { json } => { - let referenced = - list_referenced_uki_addons(booted_cfs, &list_bootloader_entries(storage)?)?; + let referenced = list_referenced_uki_addons( + booted_cfs, + &list_bootloader_entries(storage)?, + &host.status.staged, + )?; if *json { return serde_json::to_writer(std::io::stdout(), &referenced) diff --git a/docs/src/bootc-composefs.7.md b/docs/src/bootc-composefs.7.md index 78e41d9be6..6b86e3f855 100644 --- a/docs/src/bootc-composefs.7.md +++ b/docs/src/bootc-composefs.7.md @@ -127,7 +127,13 @@ and may change or be removed: addons already installed (scoped or global) are carried forward, and any passed via the flags are added. Global addons are garbage collected once no deployment references them, scoped addons are garbage collected along with - their respective deployments. + their respective deployments. While a deployment is staged, `bootc uki-addon + add`/`remove` for global addons operate on a staged copy at + `loader/addons.staged`, which is applied to the live `loader/addons` on the + next boot. Removing the last global addon empties and deletes + `loader/addons.staged`, so nothing is applied on the next boot and the live + global addons are kept; remove the last global addon when no deployment is + staged. - [Unified storage](bootc-experimental-unified-storage.7.md). ## Limitations diff --git a/docs/src/man/bootc-uki-addon-remove.8.md b/docs/src/man/bootc-uki-addon-remove.8.md index a693e2ac4d..126752b144 100644 --- a/docs/src/man/bootc-uki-addon-remove.8.md +++ b/docs/src/man/bootc-uki-addon-remove.8.md @@ -16,6 +16,15 @@ For global addons, only the addon name is needed. For scoped addons, the deployment ID is required to identify which deployment's addon to remove. Use `bootc uki-addon list --json` to find deployment IDs. +When a deployment is staged (after `bootc upgrade` or `bootc switch`), removing a +global addon operates on the staged copy at `loader/addons.staged`, not the live +`loader/addons`. The staged copy is applied to `loader/addons` on the next boot. + +Removing the last global addon empties `loader/addons.staged`, so that directory +is deleted. With no `loader/addons.staged` left, nothing is applied on the next +boot and the live `loader/addons` is kept as-is, so the removed global addon +comes back. To remove the last global addon, do it when no deployment is staged. + # OPTIONS @@ -43,6 +52,14 @@ Remove a scoped addon (get the deployment ID from `list --json`): bootc uki-addon remove debug a1b2c3d4e5f6... +Removing the last global addon while a deployment is staged. This command +reports success, but after reboot `site-config` is still installed because +`loader/addons.staged` was deleted and nothing replaced the live `loader/addons`: + + bootc uki-addon remove site-config global + +Reboot first so no deployment is staged, then run the same command to remove it. + # SEE ALSO **bootc-uki-addon**(8), **bootc-uki-addon-list**(8), **bootc-uki-addon-add**(8) diff --git a/tmt/tests/booted/test-composefs-uki-addons.nu b/tmt/tests/booted/test-composefs-uki-addons.nu index 4e89ffa437..213d7a3578 100644 --- a/tmt/tests/booted/test-composefs-uki-addons.nu +++ b/tmt/tests/booted/test-composefs-uki-addons.nu @@ -82,6 +82,16 @@ def second_boot [] { # should succeed bootc switch --transport containers-storage --uki-addon monster-cmdline --global-uki-addon global-cmdline localhost/bootc-uki-addons-2 + # A switch/upgrade must *stage* the global addon under loader/addons.staged + # and leave the live loader/addons dir untouched until the next boot. + # Remount the ESP so our view is coherent with the mount bootc wrote through. + umount /var/tmp/efi + mount /dev/disk/by-partlabel/EFI-SYSTEM /var/tmp/efi + + assert ("/var/tmp/efi/loader/addons.staged/global-cmdline.addon.efi" | path exists) "Global addon should be staged under loader/addons.staged" + # This is the first global addon, so the live dir must not hold it yet + assert (not ("/var/tmp/efi/loader/addons/global-cmdline.addon.efi" | path exists)) "Live loader/addons must not be updated before reboot" + tmt-reboot } @@ -93,6 +103,11 @@ def third_boot [] { # We should have two addons assert ((^find /var/tmp/efi -type f -name '*addon.efi' | ^wc -l | str trim | into int) == 2) + # finalize should have atomically promoted the staged global addon into the + # live loader/addons dir and removed the staged dir. + assert (not ("/var/tmp/efi/loader/addons.staged" | path exists)) "Staged loader/addons.staged must be gone after reboot" + assert ("/var/tmp/efi/loader/addons/global-cmdline.addon.efi" | path exists) "Global addon must be promoted to live loader/addons after reboot" + # We should have those in the cmdline assert (open /proc/cmdline | str contains "johan=liebert") assert (open /proc/cmdline | str contains "kenzo=tenma") @@ -138,6 +153,38 @@ def third_boot [] { # Also this shouldn't include the local cmdline addon so we're good and this should pass bootc switch --transport containers-storage --uki-addon berserk-cmdline localhost/bootc-uki-addons-3 + # --- Test: removing the last global addon while a deployment is staged --- + # The switch above staged a deployment and carried the global addon forward + # into loader/addons.staged, while the live loader/addons is left intact. + # Remount the ESP for a view coherent with bootc's own mount. + umount /var/tmp/efi + mount /dev/disk/by-partlabel/EFI-SYSTEM /var/tmp/efi + + assert ("/var/tmp/efi/loader/addons.staged/global-cmdline.addon.efi" | path exists) "Global addon should be staged after switch" + assert ("/var/tmp/efi/loader/addons/global-cmdline.addon.efi" | path exists) "Live global addon must remain while staged" + + # Removing the last global addon while staged operates on loader/addons.staged: + # it empties and deletes that dir and warns that the removal won't take effect, + # leaving the live loader/addons untouched. + let remove_result = (do { bootc uki-addon remove global-cmdline global } | complete) + assert ($remove_result.exit_code == 0) "Removing the staged global addon should succeed" + assert ($remove_result.stdout | str contains "last staged global addon") "Should warn about removing the last staged global addon" + + umount /var/tmp/efi + mount /dev/disk/by-partlabel/EFI-SYSTEM /var/tmp/efi + + assert (not ("/var/tmp/efi/loader/addons.staged" | path exists)) "loader/addons.staged must be deleted after removing the last staged global addon" + assert ("/var/tmp/efi/loader/addons/global-cmdline.addon.efi" | path exists) "Live global addon must be kept" + + # Re-stage the global addon so fourth_boot sees the same state as before this + # check. With a deployment staged, `add` writes back into loader/addons.staged. + bootc uki-addon add global-cmdline global + + umount /var/tmp/efi + mount /dev/disk/by-partlabel/EFI-SYSTEM /var/tmp/efi + + assert ("/var/tmp/efi/loader/addons.staged/global-cmdline.addon.efi" | path exists) "Global addon should be re-staged" + tmt-reboot }