From a4c880199b22aefd4beb16053a7b2c0c472f4997 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Wed, 9 Sep 2026 12:54:07 +0530 Subject: [PATCH 01/14] 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 | 21 +++-- crates/lib/src/bootc_composefs/mod.rs | 1 + crates/lib/src/bootc_composefs/uki_addon.rs | 96 +++++++++++++++++++++ 3 files changed, 113 insertions(+), 5 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 72ff812a8f..7735d339d7 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -98,6 +98,7 @@ use serde::{Deserialize, Serialize}; use crate::bootc_composefs::state::{get_booted_bls, write_composefs_state}; use crate::bootc_composefs::status::ComposefsCmdline; +use crate::bootc_composefs::uki_addon::list_installed_uki_addons; use crate::bootc_kargs::compute_new_kargs; use crate::composefs_consts::{TYPE1_BOOT_DIR_PREFIX, TYPE1_ENT_PATH, TYPE1_ENT_PATH_STAGED}; use crate::parsers::bls_config::{BLSConfig, BLSConfigType, EFIKey}; @@ -1276,6 +1277,8 @@ pub(crate) fn setup_composefs_uki_boot( id: &Sha512HashValue, entries: Vec>, ) -> Result { + let addons_to_update; + let (root_path, esp_device, bootloader, missing_fsverity_allowed, uki_addons) = match setup_type { BootSetupType::Setup((root_setup, state, postfetch)) => { @@ -1301,16 +1304,24 @@ pub(crate) fn setup_composefs_uki_boot( let root_dev = bootc_blockdev::list_dev_by_dir(&storage.physical_root)?; let esp_dev = root_dev.find_first_colocated_esp()?; + let installed_addons = list_installed_uki_addons(storage, booted_cfs)?; + + // If we find addons (that are currently installed) in the new image as well, + // we will update them + // + // TODO: This has a weird edge case where a local addon and global addon can have + // the same name. We can add a container lint for this + addons_to_update = installed_addons + .into_iter() + .map(|a| a.name) + .collect::>(); + ( sysroot, esp_dev.path(), bootloader, booted_cfs.cmdline.allow_missing_fsverity, - // TODO: We never (re)install UKI addons on upgrade, only on initial - // `install`. This is especially relevant for global addons (see the - // TODO on `GLOBAL_UKI_ADDONS_DIR`): if a newer image changes or drops - // one, the ESP copy is never reconciled. - None, + Some(&addons_to_update), ) } }; diff --git a/crates/lib/src/bootc_composefs/mod.rs b/crates/lib/src/bootc_composefs/mod.rs index 42d521150a..4b520d3643 100644 --- a/crates/lib/src/bootc_composefs/mod.rs +++ b/crates/lib/src/bootc_composefs/mod.rs @@ -14,5 +14,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 5e7d0078b625c0845c4acefae747fb2d997a3bd4 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Wed, 9 Sep 2026 14:04:32 +0530 Subject: [PATCH 02/14] 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 | 8 +- 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, 76 insertions(+), 31 deletions(-) diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 7735d339d7..640122f8a1 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -98,7 +98,7 @@ use serde::{Deserialize, Serialize}; use crate::bootc_composefs::state::{get_booted_bls, write_composefs_state}; use crate::bootc_composefs::status::ComposefsCmdline; -use crate::bootc_composefs::uki_addon::list_installed_uki_addons; +use crate::bootc_composefs::uki_addon::{UkiAddonType, UkiAddonsList, list_installed_uki_addons}; use crate::bootc_kargs::compute_new_kargs; use crate::composefs_consts::{TYPE1_BOOT_DIR_PREFIX, TYPE1_ENT_PATH, TYPE1_ENT_PATH_STAGED}; use crate::parsers::bls_config::{BLSConfig, BLSConfigType, EFIKey}; @@ -1277,8 +1277,6 @@ pub(crate) fn setup_composefs_uki_boot( id: &Sha512HashValue, entries: Vec>, ) -> Result { - let addons_to_update; - let (root_path, esp_device, bootloader, missing_fsverity_allowed, uki_addons) = match setup_type { BootSetupType::Setup((root_setup, state, postfetch)) => { @@ -1287,12 +1285,34 @@ pub(crate) fn setup_composefs_uki_boot( // Locate ESP partition device by walking up to the root disk(s) let esp_part = root_setup.device_info.find_first_colocated_esp()?; + let mut addons: Vec = vec![]; + + if let Some(local_addons) = &state.composefs_options.uki_addon { + for addon in local_addons { + addons.push(UkiAddonsList { + name: addon.into(), + addon_type: UkiAddonType::Scoped { + depl_id: id.to_hex(), + }, + }); + } + }; + + if let Some(global_addons) = &state.composefs_options.global_uki_addon { + for addon in global_addons { + addons.push(UkiAddonsList { + name: addon.into(), + addon_type: UkiAddonType::Global, + }); + } + }; + ( root_setup.physical_root_path.clone(), esp_part.path(), postfetch.detected_bootloader.clone(), state.composefs_options.allow_missing_verity, - state.composefs_options.uki_addon.as_ref(), + addons, ) } @@ -1304,24 +1324,14 @@ pub(crate) fn setup_composefs_uki_boot( let root_dev = bootc_blockdev::list_dev_by_dir(&storage.physical_root)?; let esp_dev = root_dev.find_first_colocated_esp()?; - let installed_addons = list_installed_uki_addons(storage, booted_cfs)?; - - // If we find addons (that are currently installed) in the new image as well, - // we will update them - // - // TODO: This has a weird edge case where a local addon and global addon can have - // the same name. We can add a container lint for this - addons_to_update = installed_addons - .into_iter() - .map(|a| a.name) - .collect::>(); + let installed_addons = list_installed_uki_addons(storage)?; ( sysroot, esp_dev.path(), bootloader, booted_cfs.cmdline.allow_missing_fsverity, - Some(&addons_to_update), + installed_addons, ) } }; @@ -1341,10 +1351,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() @@ -1358,8 +1364,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 c82d857f7e..3e2e540d5f 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -403,11 +403,17 @@ 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. #[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. + #[clap(long, requires = "composefs_backend")] + #[serde(default)] + pub(crate) global_uki_addon: Option>, } #[cfg(feature = "install-to-disk")] diff --git a/docs/src/man/bootc-install-to-disk.8.md b/docs/src/man/bootc-install-to-disk.8.md index 9c11739480..0a191575cd 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 + Name of the local/scoped UKI addons to install without the ".efi.addon" suffix + +**--global-uki-addon**=*GLOBAL_UKI_ADDON* + + Name of the global UKI addons to install without the ".efi.addon" suffix 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 8c156c791e..31300d233a 100644 --- a/docs/src/man/bootc-install-to-existing-root.8.md +++ b/docs/src/man/bootc-install-to-existing-root.8.md @@ -238,7 +238,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 + Name of the local/scoped UKI addons to install without the ".efi.addon" suffix + +**--global-uki-addon**=*GLOBAL_UKI_ADDON* + + Name of the global UKI addons to install without the ".efi.addon" suffix diff --git a/docs/src/man/bootc-install-to-filesystem.8.md b/docs/src/man/bootc-install-to-filesystem.8.md index 2d2b8b6f62..1929c5edae 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 + Name of the local/scoped UKI addons to install without the ".efi.addon" suffix + +**--global-uki-addon**=*GLOBAL_UKI_ADDON* + + Name of the global UKI addons to install without the ".efi.addon" suffix From 13285e11448045e87c42d65a126f94e1bef3693b Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Wed, 9 Sep 2026 15:20:13 +0530 Subject: [PATCH 03/14] 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 | 73 +++++++++++++------ 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 | 31 +++++--- docs/src/man/bootc-install-to-disk.8.md | 4 +- .../man/bootc-install-to-existing-root.8.md | 4 +- docs/src/man/bootc-install-to-filesystem.8.md | 4 +- docs/src/man/bootc-switch.8.md | 8 ++ docs/src/man/bootc-upgrade.8.md | 8 ++ 10 files changed, 108 insertions(+), 42 deletions(-) diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 640122f8a1..c90694434c 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -117,7 +117,7 @@ use crate::{ }; use crate::{parsers::grub_menuconfig::MenuEntry, store::BootedComposefs}; -use crate::install::{RootSetup, State}; +use crate::install::{RootSetup, State, UkiAddonOpts}; /// Contains the EFP's filesystem UUID. Used by grub pub(crate) const EFI_UUID_FILE: &str = "efiuuid.cfg"; @@ -262,7 +262,14 @@ pub(crate) enum BootSetupType<'a> { /// For initial setup, i.e. install to-disk Setup((&'a RootSetup, &'a State, &'a PostFetchState)), /// For `bootc upgrade` - Upgrade((&'a Storage, &'a BootedComposefs, &'a Host)), + Upgrade( + ( + &'a Storage, + &'a BootedComposefs, + &'a Host, + Option<&'a UkiAddonOpts>, + ), + ), } #[derive( @@ -716,7 +723,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()?; @@ -1287,25 +1294,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, + }); + } ( root_setup.physical_root_path.clone(), @@ -1316,7 +1321,7 @@ pub(crate) fn setup_composefs_uki_boot( ) } - BootSetupType::Upgrade((storage, booted_cfs, host)) => { + BootSetupType::Upgrade((storage, booted_cfs, host, uki_addon_opts)) => { let sysroot = Utf8PathBuf::from("/sysroot"); // Still needed for root_path let bootloader = host.require_composefs_booted()?.bootloader.clone(); @@ -1324,7 +1329,29 @@ pub(crate) fn setup_composefs_uki_boot( let root_dev = bootc_blockdev::list_dev_by_dir(&storage.physical_root)?; let esp_dev = root_dev.find_first_colocated_esp()?; - let installed_addons = list_installed_uki_addons(storage)?; + // 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)?; + + 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, + }); + } + + let depl_id = id.to_hex(); + + for addon in addons.scoped.iter().flatten() { + installed_addons.push(UkiAddonsList { + name: addon.into(), + addon_type: UkiAddonType::Scoped { + depl_id: depl_id.clone(), + }, + }); + } + } ( sysroot, 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 c2b43c8b86..67774b5326 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -14,6 +14,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::{ @@ -220,6 +221,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( @@ -317,7 +320,7 @@ pub(crate) async fn do_upgrade( let boot_digest = match boot_type { BootType::Bls => setup_composefs_bls_boot( - BootSetupType::Upgrade((storage, booted_cfs, &host)), + BootSetupType::Upgrade((storage, booted_cfs, &host, None)), &repo, &id, entry, @@ -326,7 +329,7 @@ pub(crate) async fn do_upgrade( BootType::Uki => { let uki_setup_result = setup_composefs_uki_boot( - BootSetupType::Upgrade((storage, booted_cfs, &host)), + BootSetupType::Upgrade((storage, booted_cfs, &host, Some(&opts.uki_addon_opts))), &repo, &id, entries, @@ -490,6 +493,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 97b9a9e778..df2651e677 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -50,6 +50,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; @@ -140,6 +141,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 @@ -209,6 +214,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, } /// Options controlling rollback diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 3e2e540d5f..0dcd006c2c 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -391,6 +391,23 @@ pub(crate) struct InstallConfigOpts { pub(crate) bootloader: Option, } +#[derive(Debug, Default, Clone, clap::Parser, Serialize, Deserialize, PartialEq, Eq)] +pub(crate) struct UkiAddonOpts { + /// Name of the local/scoped UKI addons to install without the ".efi.addon" suffix. + /// + /// This option can be provided multiple times if multiple addons are to be installed. + #[clap(long = "uki-addon", requires = "composefs_backend")] + #[serde(default)] + pub(crate) scoped: Option>, + + /// Name of the global UKI addons to install without the ".efi.addon" suffix. + /// + /// This option can be provided multiple times if multiple addons are to be installed. + #[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 { /// If true, composefs backend is used, else ostree backend is used @@ -403,17 +420,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. - #[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. - #[clap(long, requires = "composefs_backend")] - #[serde(default)] - pub(crate) global_uki_addon: Option>, + #[clap(flatten)] + #[serde(flatten)] + pub(crate) uki_addon_opts: UkiAddonOpts, } #[cfg(feature = "install-to-disk")] diff --git a/docs/src/man/bootc-install-to-disk.8.md b/docs/src/man/bootc-install-to-disk.8.md index 0a191575cd..2cfb269c53 100644 --- a/docs/src/man/bootc-install-to-disk.8.md +++ b/docs/src/man/bootc-install-to-disk.8.md @@ -186,11 +186,11 @@ set `discoverable-partitions = true` in their install configuration Default: false -**--uki-addon**=*UKI_ADDON* +**--uki-addon**=*SCOPED* Name of the local/scoped UKI addons to install without the ".efi.addon" suffix -**--global-uki-addon**=*GLOBAL_UKI_ADDON* +**--global-uki-addon**=*GLOBAL* Name of the global UKI addons to install without the ".efi.addon" suffix 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 31300d233a..85f2e7b374 100644 --- a/docs/src/man/bootc-install-to-existing-root.8.md +++ b/docs/src/man/bootc-install-to-existing-root.8.md @@ -236,11 +236,11 @@ of migrating the fstab entries. See the "Injecting kernel arguments" section abo Default: false -**--uki-addon**=*UKI_ADDON* +**--uki-addon**=*SCOPED* Name of the local/scoped UKI addons to install without the ".efi.addon" suffix -**--global-uki-addon**=*GLOBAL_UKI_ADDON* +**--global-uki-addon**=*GLOBAL* Name of the global UKI addons to install without the ".efi.addon" suffix diff --git a/docs/src/man/bootc-install-to-filesystem.8.md b/docs/src/man/bootc-install-to-filesystem.8.md index 1929c5edae..65bed562f7 100644 --- a/docs/src/man/bootc-install-to-filesystem.8.md +++ b/docs/src/man/bootc-install-to-filesystem.8.md @@ -136,11 +136,11 @@ is currently expected to be empty by default. Default: false -**--uki-addon**=*UKI_ADDON* +**--uki-addon**=*SCOPED* Name of the local/scoped UKI addons to install without the ".efi.addon" suffix -**--global-uki-addon**=*GLOBAL_UKI_ADDON* +**--global-uki-addon**=*GLOBAL* Name of the global UKI addons to install without the ".efi.addon" suffix diff --git a/docs/src/man/bootc-switch.8.md b/docs/src/man/bootc-switch.8.md index 407aa0bc09..23e7b6965b 100644 --- a/docs/src/man/bootc-switch.8.md +++ b/docs/src/man/bootc-switch.8.md @@ -79,6 +79,14 @@ Soft reboot allows faster system restart by avoiding full hardware reboot when p Retain reference to currently booted image +**--uki-addon**=*SCOPED* + + Name of the local/scoped UKI addons to install without the ".efi.addon" suffix + +**--global-uki-addon**=*GLOBAL* + + Name of the global UKI addons to install without the ".efi.addon" suffix + # EXAMPLES diff --git a/docs/src/man/bootc-upgrade.8.md b/docs/src/man/bootc-upgrade.8.md index b1b3f3b694..a65d0e39d9 100644 --- a/docs/src/man/bootc-upgrade.8.md +++ b/docs/src/man/bootc-upgrade.8.md @@ -73,6 +73,14 @@ Soft reboot allows faster system restart by avoiding full hardware reboot when p Upgrade to a different tag of the currently booted image +**--uki-addon**=*SCOPED* + + Name of the local/scoped UKI addons to install without the ".efi.addon" suffix + +**--global-uki-addon**=*GLOBAL* + + Name of the global UKI addons to install without the ".efi.addon" suffix + # EXAMPLES From a6ed2edf36a197ca5989e99651036aca4e22e709 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Thu, 10 Sep 2026 09:56:33 +0530 Subject: [PATCH 04/14] 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 125ad46975..1eb6e5db3c 100644 --- a/tmt/plans/integration.fmf +++ b/tmt/plans/integration.fmf @@ -314,4 +314,12 @@ execute: test: - /tmt/tests/tests/test-48-composefs-uki-dumpfile extra-skip_if_ostree: true + +/plan-49-composefs-uki-addons: + summary: Test composefs UKI Addons + discover: + how: fmf + test: + - /tmt/tests/tests/test-49-composefs-uki-addons + extra-skip_if_ostree: true # END GENERATED PLANS diff --git a/tmt/tests/booted/tap.nu b/tmt/tests/booted/tap.nu index b4f0dd23d3..749d371a1e 100644 --- a/tmt/tests/booted/tap.nu +++ b/tmt/tests/booted/tap.nu @@ -75,7 +75,7 @@ rm -vrf /usr/lib/bootc/bound-images.d " } -export def make_uki_containerfile [containerfile: string] { +export def make_uki_containerfile [containerfile: string, --addon-cmds: string = ""] { let is_cfs = (is_composefs) if not $is_cfs { @@ -122,6 +122,8 @@ export def make_uki_containerfile [containerfile: string] { --kernel-dir /run/kernel/boot/${kver} \\ --write-dumpfile-to /out/${kver}.dump \\ --seal-state ($seal_state) + + ($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..efc0a92e73 --- /dev/null +++ b/tmt/tests/booted/test-composefs-uki-addons.nu @@ -0,0 +1,138 @@ +# number: 49 +# 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 93364f28d1..3a13325e40 100644 --- a/tmt/tests/tests.fmf +++ b/tmt/tests/tests.fmf @@ -193,3 +193,8 @@ check: summary: Test composefs UKI dumpfile diff print duration: 30m test: nu booted/test-composefs-uki-dumpfile.nu + +/test-49-composefs-uki-addons: + summary: Test composefs UKI Addons + duration: 30m + test: nu booted/test-composefs-uki-addons.nu From 25bd5456054eab45ffd19f784fb51b799e55d977 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Thu, 10 Sep 2026 13:47:41 +0530 Subject: [PATCH 05/14] uki-addon: Add docs for UKI Addons Assisted-by: AI Signed-off-by: Pragyan Poudyal --- crates/lib/src/bootc_composefs/boot.rs | 15 ++--- docs/src/experimental-composefs.md | 89 ++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 9 deletions(-) diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index c90694434c..4fa811a388 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -147,15 +147,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)] diff --git a/docs/src/experimental-composefs.md b/docs/src/experimental-composefs.md index c4f04eca74..e0aebad987 100644 --- a/docs/src/experimental-composefs.md +++ b/docs/src/experimental-composefs.md @@ -187,6 +187,95 @@ For production environments with dedicated signing infrastructure: This workflow is planned for streamlining in future releases (see [#1498](https://github.com/bootc-dev/bootc/issues/1498)). +## UKI Addons + +UKI addons are signed PE binaries that [systemd-stub](https://www.freedesktop.org/software/systemd/man/latest/systemd-stub.html) loads alongside the main UKI at boot. Each addon carries extra kernel command-line parameters (or other PE sections) that get merged into the boot. This lets you ship optional configuration — debug flags, hardware quirks, site-specific parameters — separately from the base UKI, without rebuilding or re-signing it. + +bootc supports two types of UKI addons: + +- **Scoped addons** are tied to a specific UKI (and therefore a specific deployment). They live in a `.efi.extra.d/` directory next to the UKI on the ESP, and are cleaned up by garbage collection when the deployment is removed. +- **Global addons** apply to *every* UKI on the ESP. They live in `loader/addons/` and persist across deployments — they are not removed by GC. + +### Building Images with Addons + +Addons are built with `ukify` in the same Containerfile stage that produces the sealed UKI. Each addon is a separate `ukify build` invocation: + +```dockerfile +# Inside the sealed-uki build stage, after building the main UKI: + +# Scoped addon: lives in .efi.extra.d/, loaded only by this UKI +ukify build --cmdline 'debug loglevel=7' \ + --output /out/${kver}.efi.extra.d/debug.addon.efi + +# Global addon: lives in loader/addons/, loaded by every UKI ergo every deployment +mkdir -p /out/loader/addons +ukify build --cmdline 'custom_param=value' \ + --output /out/loader/addons/site-config.addon.efi +``` + +The `finalize-uki` script (run in the final Containerfile stage) copies these directories into `/boot` alongside the UKI if they exist. + +Addons can also be signed for Secure Boot by passing `--signtool` and key/cert options to `ukify build`, the same as for the main UKI. + +### Selecting Addons at Install Time + +An image can ship multiple addons, but none are installed unless explicitly requested. Use `--uki-addon` for scoped addons and `--global-uki-addon` for global ones. The name is the addon filename without the `.addon.efi` suffix: + +```bash +# Install with one scoped and one global addon +bootc install to-disk \ + --uki-addon debug \ + --global-uki-addon site-config \ + /dev/sda + +# Multiple addons of the same type +bootc install to-disk \ + --uki-addon debug \ + --uki-addon extra-kargs \ + --global-uki-addon site-config \ + /dev/sda +``` + +Without `--uki-addon` or `--global-uki-addon`, no addons are installed even if the image contains them. + +### Addons on Upgrade and Switch + +The same `--uki-addon` and `--global-uki-addon` options are available on `bootc upgrade` and `bootc switch`: + +```bash +# Add a new addon during switch +bootc switch --uki-addon debug --global-uki-addon site-config \ + quay.io/myorg/myimage:v2 + +# Add an addon during upgrade +bootc upgrade --uki-addon debug +``` + +Once an addon is installed, it **persists across subsequent upgrades**. If the new image contains an addon with the same filename as one already installed, bootc automatically updates it with the version from the new image. You do not need to pass `--uki-addon` again on every upgrade — only when adding a new addon that wasn't previously installed. + +If an addon name is passed but no matching file exists in the image, it is silently skipped. + +### ESP Layout + +On the EFI System Partition, bootc places addon files as follows: + +``` +ESP/ +├── EFI/Linux/bootc/ +│ ├── bootc-.efi # The UKI +│ └── bootc-.efi.extra.d/ # Scoped addons for this UKI +│ ├── debug.addon.efi +│ └── extra-kargs.addon.efi +└── loader/addons/ # Global addons + └── site-config.addon.efi +``` + +Scoped addon directories are namespaced by the deployment's composefs digest, so different deployments can have different sets of scoped addons without colliding. + +### Current Limitations + +- **Global addons are not rollback-aware**: Rolling back to a previous deployment does not revert changes to global addons, since they are shared across all UKIs. + ## Developing and Testing bootc with composefs See [CONTRIBUTING.md](https://github.com/bootc-dev/bootc/blob/main/CONTRIBUTING.md) for information on building and testing bootc itself with composefs support. From 5436966df21de216964d97f9d9d352aa2421a51a Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Thu, 10 Sep 2026 13:59:27 +0530 Subject: [PATCH 06/14] 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 4fa811a388..772af90f4a 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -465,10 +465,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 @@ -1102,6 +1110,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 8617f1005b..b5be2c507d 100644 --- a/crates/lib/src/composefs_consts.rs +++ b/crates/lib/src/composefs_consts.rs @@ -46,6 +46,12 @@ pub(crate) const BOOTC_FINALIZE_STAGED_SERVICE: &str = "bootc-finalize-staged.se pub(crate) const TYPE1_BOOT_DIR_PREFIX: &str = "bootc_composefs-"; /// The prefix for names of UKI and UKI Addons +/// +/// The actual name of a scoped UKI Addon is NOT prefixed, +/// only its directory name is prefixed +/// +/// The actual name of a global UKI Addon IS prefixed, since +/// they all live in ESP/loader/addons pub(crate) const UKI_NAME_PREFIX: &str = TYPE1_BOOT_DIR_PREFIX; /// Prefix for OCI tags owned by bootc in the composefs repository. From f85dc7bb9294c0acd2e77c13b7ab187a0faee550 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Thu, 10 Sep 2026 14:03:50 +0530 Subject: [PATCH 07/14] 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/mod.rs | 1 + crates/lib/src/bootc_composefs/uki_addon.rs | 50 +++- .../lib/src/bootc_composefs/uki_addons_cli.rs | 270 ++++++++++++++++++ crates/lib/src/cli.rs | 44 +++ 4 files changed, 358 insertions(+), 7 deletions(-) create mode 100644 crates/lib/src/bootc_composefs/uki_addons_cli.rs diff --git a/crates/lib/src/bootc_composefs/mod.rs b/crates/lib/src/bootc_composefs/mod.rs index 4b520d3643..6d86b632ef 100644 --- a/crates/lib/src/bootc_composefs/mod.rs +++ b/crates/lib/src/bootc_composefs/mod.rs @@ -15,5 +15,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..d8c50f598d 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, @@ -33,10 +52,26 @@ fn gather_addons_from_dir( 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(), - }); + match addon_name.strip_prefix(UKI_NAME_PREFIX) { + Some(addon_name) => { + addons.push(UkiAddonsList { + name: addon_name.to_string(), + addon_type: addon_type.clone(), + }); + } + None => match addon_type { + UkiAddonType::Scoped { .. } => { + addons.push(UkiAddonsList { + name: addon_name.to_string(), + addon_type: addon_type.clone(), + }); + } + // We only prefix global UKI Addons for identification + UkiAddonType::Global => { + tracing::info!("Global UKI Addon not managed by bootc found: {addon_name}") + } + }, + } }; } @@ -52,7 +87,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 df2651e677..1cf45e40db 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -41,6 +41,7 @@ use serde::{Deserialize, Serialize}; 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::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}, @@ -905,6 +906,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 @@ -1034,6 +1065,8 @@ pub(crate) enum Opt { DeleteDeployment { depl_id: String, }, + #[clap(subcommand)] + UkiAddon(UkiAddonCliOpts), } /// Ensure we've entered a mount namespace, so that we can remount @@ -2618,6 +2651,17 @@ async fn run_from_opt(opt: Opt) -> Result { } } } + Opt::UkiAddon(opts) => { + let storage = &get_storage().await?; + match storage.kind()? { + BootedStorageKind::Ostree(_) => { + anyhow::bail!("UKI Addons are only supported for Composefs Backend") + } + BootedStorageKind::Composefs(booted_cfs) => { + handle_addon_cli_cmd(storage, &booted_cfs, &opts) + } + } + } }; result.map(|()| CliExitStatus::Success) } From 6c21a0bfdd90da6c6b7b20d0f236d235650590d0 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Thu, 10 Sep 2026 15:58:07 +0530 Subject: [PATCH 08/14] 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 efc0a92e73..da47b35228 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 df073cfa73309f6911f8a896f7aa4b9ab551094a Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Thu, 10 Sep 2026 16:19:55 +0530 Subject: [PATCH 09/14] manpage: Update manpages for uki-addons Assisted-by: Claude-Code (Opus) Signed-off-by: Pragyan Poudyal --- docs/src/man/bootc-uki-addon-add.8.md | 47 ++++++++++++++++++ docs/src/man/bootc-uki-addon-list.8.md | 57 ++++++++++++++++++++++ docs/src/man/bootc-uki-addon-remove.8.md | 48 ++++++++++++++++++ docs/src/man/bootc-uki-addon.8.md | 62 ++++++++++++++++++++++++ docs/src/man/bootc.8.md | 1 + 5 files changed, 215 insertions(+) create mode 100644 docs/src/man/bootc-uki-addon-add.8.md create mode 100644 docs/src/man/bootc-uki-addon-list.8.md create mode 100644 docs/src/man/bootc-uki-addon-remove.8.md create mode 100644 docs/src/man/bootc-uki-addon.8.md diff --git a/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..4595a79574 --- /dev/null +++ b/docs/src/man/bootc-uki-addon-add.8.md @@ -0,0 +1,47 @@ +# NAME + +bootc-uki-addon-add - Add a UKI Addon to the current deployment + +# SYNOPSIS + +**bootc uki-addon add** <*NAME*> <*ADDON_TYPE*> + +# DESCRIPTION + +Add a UKI addon from the currently booted image to the EFI System Partition. + +The addon must exist in the booted image under `/boot`. If the addon is +already installed, the command is a no-op. + +# OPTIONS + + +**NAME** + + Addon name to be provided without the `.efi.addon` suffix + + This argument is required. + +**ADDON_TYPE** + + This argument is required. + + + +# EXAMPLES + +Add a scoped addon (tied to the current deployment): + + bootc uki-addon add debug scoped + +Add a global addon (applies to all deployments): + + bootc uki-addon add site-config global + +# SEE ALSO + +**bootc-uki-addon**(8), **bootc-uki-addon-list**(8), **bootc-uki-addon-remove**(8) + +# VERSION + + diff --git a/docs/src/man/bootc-uki-addon-list.8.md b/docs/src/man/bootc-uki-addon-list.8.md new file mode 100644 index 0000000000..94d5bdb0a4 --- /dev/null +++ b/docs/src/man/bootc-uki-addon-list.8.md @@ -0,0 +1,57 @@ +# NAME + +bootc-uki-addon-list - List all installed UKI Addons + +# SYNOPSIS + +**bootc uki-addon list** \[*OPTIONS...*\] + +# DESCRIPTION + +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..777fb5a5f9 --- /dev/null +++ b/docs/src/man/bootc-uki-addon-remove.8.md @@ -0,0 +1,48 @@ +# NAME + +bootc-uki-addon-remove - Remove a UKI Addon + +# SYNOPSIS + +**bootc uki-addon remove** <*NAME*> \[*DEPLOYMENT_ID*\] + +# DESCRIPTION + +Remove a UKI addon from the EFI System Partition. + +For global addons, only the addon name is needed. For scoped addons, the +deployment ID is required to identify which deployment's addon to remove. +Use `bootc uki-addon list --json` to find deployment IDs. + +# OPTIONS + + +**NAME** + + Addon name to be provided without the `.efi.addon` suffix + + This argument is required. + +**DEPLOYMENT_ID** + + If removing a scoped addon, deployment_id is required. If removing a global addon, deployment_id is not required + + + +# EXAMPLES + +Remove a global addon: + + bootc uki-addon remove site-config + +Remove a scoped addon (get the deployment ID from `list --json`): + + bootc uki-addon remove debug a1b2c3d4e5f6... + +# SEE ALSO + +**bootc-uki-addon**(8), **bootc-uki-addon-list**(8), **bootc-uki-addon-add**(8) + +# VERSION + + diff --git a/docs/src/man/bootc-uki-addon.8.md b/docs/src/man/bootc-uki-addon.8.md new file mode 100644 index 0000000000..03167db5ba --- /dev/null +++ b/docs/src/man/bootc-uki-addon.8.md @@ -0,0 +1,62 @@ +# NAME + +bootc-uki-addon - Manage UKI addons on the EFI System Partition + +# SYNOPSIS + +**bootc uki-addon** <*COMMAND*> + +# DESCRIPTION + +Manage UKI (Unified Kernel Image) addons on the EFI System Partition. + +UKI addons are PE binaries that systemd-stub loads alongside the main UKI at +boot. Each addon carries extra kernel command-line parameters that get merged +into the boot configuration. + +There are two types of addons: + +- **Scoped** addons are tied to a specific deployment. They are stored next to + the deployment's UKI and are automatically cleaned up by garbage collection + when the deployment is removed. + +- **Global** addons apply to every UKI on the ESP. They persist across + deployments and are not removed by garbage collection. + +This command requires the composefs backend with UKI boot. + + + + +# COMMANDS + +**list** +: List all installed UKI addons. See **bootc-uki-addon-list**(8). + +**add** +: Add a UKI addon from the booted image. See **bootc-uki-addon-add**(8). + +**remove** +: Remove a UKI addon from the ESP. See **bootc-uki-addon-remove**(8). + +# EXAMPLES + +List all installed addons: + + bootc uki-addon list + +Add a scoped addon from the booted image: + + bootc uki-addon add debug scoped + +Remove a global addon: + + bootc uki-addon remove site-config + +# SEE ALSO + +**bootc**(8), **bootc-uki-addon-list**(8), **bootc-uki-addon-add**(8), **bootc-uki-addon-remove**(8) + +# VERSION + + diff --git a/docs/src/man/bootc.8.md b/docs/src/man/bootc.8.md index d543d04994..449ddbca2e 100644 --- a/docs/src/man/bootc.8.md +++ b/docs/src/man/bootc.8.md @@ -35,6 +35,7 @@ pulled and `bootc upgrade`. | **bootc container** | Operations which can be executed as part of a container build | | **bootc loader-entries** | Operations on Boot Loader Specification (BLS) entries | | **bootc composefs-finalize-staged** | | +| **bootc uki-addon** | | From 1adb01cf7aeaaf36851c562ac83bf06876dead31 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Thu, 10 Sep 2026 17:50:02 +0530 Subject: [PATCH 10/14] cli: Remove composefs_backend requirement from UkiAddonOpts UkiAddonOpts is now flattened into both upgrade and switch commands, which don't have a --composefs-backend flag. The `requires = "composefs_backend"` constraint causes a panic at clap validation time because the referenced argument doesn't exist in those command contexts. This is generally safe as the options are ignored for ostree installs anyway Signed-off-by: Pragyan Poudyal --- crates/lib/src/install.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 0dcd006c2c..1e10c0c843 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -396,14 +396,14 @@ pub(crate) struct UkiAddonOpts { /// Name of the local/scoped UKI addons to install without the ".efi.addon" suffix. /// /// This option can be provided multiple times if multiple addons are to be installed. - #[clap(long = "uki-addon", requires = "composefs_backend")] + #[clap(long = "uki-addon")] #[serde(default)] pub(crate) scoped: Option>, /// Name of the global UKI addons to install without the ".efi.addon" suffix. /// /// This option can be provided multiple times if multiple addons are to be installed. - #[clap(long = "global-uki-addon", requires = "composefs_backend")] + #[clap(long = "global-uki-addon")] #[serde(default)] pub(crate) global: Option>, } From 89f25a42e64d86a92aa36f7ebea204a740190a0c Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Fri, 11 Sep 2026 10:40:24 +0530 Subject: [PATCH 11/14] 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 ac8030329ffd0fe30037a59dde89a1e7a6358c56 Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Fri, 11 Sep 2026 11:58:39 +0530 Subject: [PATCH 12/14] 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 | 208 ++++++++++++++++--------- 1 file changed, 138 insertions(+), 70 deletions(-) diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 772af90f4a..b467171bb1 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -160,7 +160,7 @@ pub(crate) const GLOBAL_UKI_ADDONS_DIR: &str = "loader/addons"; pub(crate) struct UKIDigestMismatch { pub actual: String, pub expected: String, - pub uki_name: Option, + pub uki_name: String, } pub(crate) fn print_uki_dumpfile_diff( @@ -170,8 +170,8 @@ pub(crate) fn print_uki_dumpfile_diff( ) { let dumpfile_name = mismatch .uki_name - .as_ref() - .and_then(|x| x.strip_suffix(EFI_EXT).map(|x| format!("{x}.dump"))); + .strip_suffix(EFI_EXT) + .map(|x| format!("{x}.dump")); let Some(dumpfile_name) = &dumpfile_name else { return; @@ -976,6 +976,7 @@ struct UKIInfo { version: Option, os_id: Option, boot_digest: String, + cmdline: Option>, } /// Determines the directory (under `mounted_efi`) that a PE binary should be written to. @@ -1013,6 +1014,109 @@ fn pe_output_dir( } } +#[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, + uki_reader: &mut R, + uki_id: &Sha512HashValue, + uki_name: &str, + uki_info: &mut UKIInfo, +) -> Result<()> { + // We expect this in the UKI itself and not in addons + if matches!(pe_type, PEType::Uki) { + let osrel = uki::get_text_section_buffered(uki_reader, ".osrel")?; + + let parsed_osrel = OsReleaseInfo::parse(&osrel); + + uki_reader.seek(SeekFrom::Start(0))?; + let boot_digest = compute_boot_digest_uki(uki_reader)?; + + uki_reader.seek(SeekFrom::Start(0))?; + + uki_info.boot_label = + uki::get_boot_label_buffered(uki_reader).context("Getting UKI boot label")?; + uki_info.version = parsed_osrel.get_version(); + uki_info.os_id = parsed_osrel.get_value(&["ID"]); + uki_info.boot_digest = boot_digest; + + uki_reader.seek(SeekFrom::Start(0))?; + } + + // UKI Addon might not even have a cmdline + let cmdline = uki::get_cmdline_buffered(uki_reader); + + let cmdline_str = match cmdline { + Ok(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"); + } + }; + + tracing::debug!("cmdline found in {pe_type:?}: {cmdline_str}"); + + let cfs_cmdline_info = ComposefsBootCmdline::::from_cmdline(&cmdline_str) + .context("Parsing composefs=")?; + + // Make sure there's no composefs= in a global UKI Addon + if matches!(pe_type, PEType::GlobalUkiAddon) { + if let Some(cmdline) = cfs_cmdline_info { + anyhow::bail!("Composefs cmdline {cmdline:?} found in a Global UKI Addon"); + }; + } + + // We could have a cmdline in an Addon, so don't early error out if we don't find it + // immediately + let Some(cfs_cmdline_info) = cfs_cmdline_info else { + return Ok(()); + }; + + // 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.cmdline { + anyhow::bail!("Already had cmdline {found_cmdline:?}, found another {cfs_cmdline_info:?}"); + }; + + let composefs_cmdline = cfs_cmdline_info.digest(); + let missing_verity_allowed_cmdline = cfs_cmdline_info.is_insecure(); + + // If the UKI cmdline does not match what the user has passed as cmdline option + // NOTE: This will only be checked for new installs and now upgrades/switches + match missing_fsverity_allowed { + true if !missing_verity_allowed_cmdline => { + tracing::warn!( + "--allow-missing-fsverity passed as option but UKI cmdline does not support it" + ); + } + + false if missing_verity_allowed_cmdline => { + tracing::warn!("UKI cmdline has composefs set as insecure"); + } + + _ => { /* no-op */ } + } + + if *composefs_cmdline != *uki_id { + return Err(UKIDigestMismatch { + actual: composefs_cmdline.to_hex(), + expected: uki_id.to_hex(), + uki_name: uki_name.into(), + } + .into()); + } + + uki_info.cmdline = Some(cfs_cmdline_info); + + Ok(()) +} + /// Writes a PortableExecutable to ESP along with any PE specific or Global addons #[context("Writing {file_path} to ESP")] fn write_pe_to_esp( @@ -1023,7 +1127,8 @@ fn write_pe_to_esp( uki_id: &Sha512HashValue, missing_fsverity_allowed: bool, mounted_efi: impl AsRef, -) -> Result> { + uki_info: &mut UKIInfo, +) -> Result<()> { let mut uki_reader = match file { RegularFile::Inline(..) => { // UKI/Addons would always be large enough to be an external object @@ -1037,63 +1142,18 @@ fn write_pe_to_esp( } }; - let mut boot_label: Option = None; - - // UKI Extension might not even have a cmdline - // TODO: UKI Addon might also have a composefs= cmdline? - if matches!(pe_type, PEType::Uki) { - let cmdline = uki::get_cmdline_buffered(&mut uki_reader).context("Getting UKI cmdline")?; - - let composefs_info = ComposefsBootCmdline::::from_cmdline(&cmdline) - .context("Parsing composefs=")? - .ok_or_else(|| anyhow::anyhow!("No composefs image in UKI cmdline"))?; - let composefs_cmdline = composefs_info.digest(); - let missing_verity_allowed_cmdline = composefs_info.is_insecure(); - - // If the UKI cmdline does not match what the user has passed as cmdline option - // NOTE: This will only be checked for new installs and now upgrades/switches - match missing_fsverity_allowed { - true if !missing_verity_allowed_cmdline => { - tracing::warn!( - "--allow-missing-fsverity passed as option but UKI cmdline does not support it" - ); - } - - false if missing_verity_allowed_cmdline => { - tracing::warn!("UKI cmdline has composefs set as insecure"); - } - - _ => { /* no-op */ } - } - - let file_name = file_path.file_name(); - - if *composefs_cmdline != *uki_id { - return Err(UKIDigestMismatch { - actual: composefs_cmdline.to_hex(), - expected: uki_id.to_hex(), - uki_name: file_name.map(|x| x.to_string()), - } - .into()); - } - - uki_reader.seek(SeekFrom::Start(0))?; - let osrel = uki::get_text_section_buffered(&mut uki_reader, ".osrel")?; - - let parsed_osrel = OsReleaseInfo::parse(&osrel); - - uki_reader.seek(SeekFrom::Start(0))?; - let boot_digest = compute_boot_digest_uki(&mut uki_reader)?; - - 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, - }); - } + let file_name = file_path + .file_name() + .context("Filename not found for PE binary")?; + + parse_uki_cmdline( + &pe_type, + missing_fsverity_allowed, + &mut uki_reader, + uki_id, + file_name, + uki_info, + )?; let final_pe_path = pe_output_dir(&pe_type, mounted_efi.as_ref(), file_path, uki_id); create_dir_all(&final_pe_path).with_context(|| format!("Creating {final_pe_path:?}"))?; @@ -1131,7 +1191,7 @@ fn write_pe_to_esp( ) .context("fsync")?; - Ok(boot_label) + Ok(()) } #[context("Writing Grub menuentry")] @@ -1379,7 +1439,13 @@ 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(), + cmdline: None, + }; for entry in entries { match entry { @@ -1437,7 +1503,7 @@ 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( + write_pe_to_esp( &repo, &entry.file, utf8_file_path, @@ -1445,17 +1511,19 @@ pub(crate) fn setup_composefs_uki_boot( &id, missing_fsverity_allowed, esp_mount.dir.path(), + &mut uki_info, )?; - - if let Some(label) = ret { - uki_info = Some(label); - } } }; } - let uki_info = - uki_info.ok_or_else(|| anyhow::anyhow!("Failed to get version and boot label from UKI"))?; + let Some(..) = uki_info.cmdline 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 boot_digest = uki_info.boot_digest.clone(); From bb2c76654f9869af78640dff065c03b7efeccd9d Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Fri, 11 Sep 2026 12:07:56 +0530 Subject: [PATCH 13/14] 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 e31ce45f3a0c221f627c85a4713d5bdb1893e21b Mon Sep 17 00:00:00 2001 From: Pragyan Poudyal Date: Fri, 11 Sep 2026 13:12:24 +0530 Subject: [PATCH 14/14] 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 da47b35228..06ec6347d9 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