Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
72f7399
uki-addon: Update addons on update/switch
Johan-Liebert1 Sep 9, 2026
89a7e14
uki/addon: Support global addons
Johan-Liebert1 Sep 9, 2026
aa6cb4a
cfs/upgrade/switch: Handle UKI Addons
Johan-Liebert1 Sep 9, 2026
ae49ce8
tmt: Add tests for UKI Addons
Johan-Liebert1 Sep 10, 2026
9d77327
uki-addon: Add docs for UKI Addons
Johan-Liebert1 Sep 10, 2026
5b2f98c
global-uki-addons: Add prefix to name
Johan-Liebert1 Sep 10, 2026
a794dbd
uki-addon: Add CLI for managing UKI Addons
Johan-Liebert1 Sep 10, 2026
c5d6e48
tmt: Update UKI Addon tests
Johan-Liebert1 Sep 10, 2026
35622a3
manpage: Update manpages for uki-addons
Johan-Liebert1 Sep 10, 2026
3d6b91c
cli: Remove composefs_backend requirement from UkiAddonOpts
Johan-Liebert1 Sep 10, 2026
d1b737c
uki-addon: Remove addon dir if empty
Johan-Liebert1 Sep 11, 2026
451cd36
uki: Parse composefs cmdline from UKI addons as well
Johan-Liebert1 Sep 11, 2026
6a61180
uki-addon: Refuse to remove addons containing `composefs=` cmdline
Johan-Liebert1 Sep 11, 2026
4002d2a
tmt: Add composefs= cmdline validation tests for UKI addons
Johan-Liebert1 Sep 11, 2026
ffd1a0e
uki-addons: Add `ListReferenced` cmd option
Johan-Liebert1 Sep 15, 2026
6aaec11
uki-addons: GC Global UKI Addons
Johan-Liebert1 Sep 15, 2026
02cc7dd
uki-addon: Validate and atomically write on `add`
Johan-Liebert1 Sep 30, 2026
3f74c4b
install/uki-addons: Bail if ostree / not found
Johan-Liebert1 Sep 30, 2026
3fec90a
gc: Update global UKI Addons check
Johan-Liebert1 Oct 13, 2026
caf5380
uki-addon: Handle staged deployment
Johan-Liebert1 Oct 15, 2026
fb01f99
global-uki-addon: Have a staged directory
Johan-Liebert1 Oct 15, 2026
d3053e8
uki-addon: Warn before a global addon is GC'd on upgrade/switch
Johan-Liebert1 Oct 19, 2026
4524504
global-uki-addons: Staging fixes
Johan-Liebert1 Oct 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions contrib/packaging/finalize-uki
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
527 changes: 411 additions & 116 deletions crates/lib/src/bootc_composefs/boot.rs

Large diffs are not rendered by default.

10 changes: 9 additions & 1 deletion crates/lib/src/bootc_composefs/delete.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt};
use crate::{
bootc_composefs::{
aboot,
boot::{BootType, get_efi_uuid_source},
boot::{BootType, STAGED_GLOBAL_UKI_ADDONS_DIR, get_efi_uuid_source},
gc::{GCOpts, composefs_gc},
rollback::{composefs_rollback, rename_exchange_user_cfg},
state::remove_staged_deployment,
Expand Down Expand Up @@ -270,6 +270,14 @@ pub(crate) async fn delete_composefs_deployment(
}
} else {
delete_depl_boot_entries(&depl_to_del, &storage, deleting_staged)?;

if deleting_staged {
let esp = storage.require_esp()?;

esp.fd
.remove_all_optional(STAGED_GLOBAL_UKI_ADDONS_DIR)
.context("Removing staged Global UKI Addons")?;
}
}

composefs_gc(
Expand Down
37 changes: 36 additions & 1 deletion crates/lib/src/bootc_composefs/finalize.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use std::{path::Path, process::Command};

use crate::bootc_composefs::boot::{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;
Expand All @@ -16,7 +17,7 @@ use cap_std_ext::dirext::CapStdExtDirExt;
use composefs::generic_tree::{FileSystem, Stat};
use composefs_ctl::composefs;
use etc_merge::{Diff, compute_diff, merge, traverse_etc};
use rustix::fs::{Mode, OFlags, fsync};
use rustix::fs::{Mode, OFlags, RenameFlags, fsync, renameat, renameat_with};

use fn_error_context::context;

Expand Down Expand Up @@ -156,6 +157,40 @@ pub(crate) async fn composefs_backend_finalize(

let boot_dir = storage.require_boot_dir()?;

// Un-stage Global UKI Addons (if present) as the penultimate step
if boot_dir.exists(STAGED_GLOBAL_UKI_ADDONS_DIR) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On classic GRUB, boot_dir is /boot, not the ESP, but staging and uki-addon add write loader/addons.staged on the ESP. So on GRUB + UKI a staged global addon is never applied.

Reproduced with an image built with BOOTC_bootloader=grub BOOTC_boot_type=uki: switch --global-uki-addon foo, reboot. foo isn't on the cmdline, addons.staged is still on the ESP, and uki-addon list --verbose keeps showing it as staged. Using storage.require_esp()?.fd here should fix it. The GRUB branch in rollback.rs also needs the addons.staged cleanup that the BLS branch got.

if boot_dir.exists(GLOBAL_UKI_ADDONS_DIR) {
tracing::debug!(
"Atomically exchanging {STAGED_GLOBAL_UKI_ADDONS_DIR} and {GLOBAL_UKI_ADDONS_DIR}"
);

renameat_with(
&boot_dir,
STAGED_GLOBAL_UKI_ADDONS_DIR,
&boot_dir,
GLOBAL_UKI_ADDONS_DIR,
RenameFlags::EXCHANGE,
)
.context("Atomically exchanging staged Global UKI Addons")?;

boot_dir
.remove_dir_all(STAGED_GLOBAL_UKI_ADDONS_DIR)
.context("Removing staged Global UKI Addons dir")?;
} else {
tracing::debug!(
"No global addons in the current deployment. Finalizing global UKI Addons"
);

renameat(
&boot_dir,
STAGED_GLOBAL_UKI_ADDONS_DIR,
&boot_dir,
GLOBAL_UKI_ADDONS_DIR,
)
.context("Finalizing global UKI Addons")?;
}
}

match booted_composefs.bootloader.kind()? {
BootloaderKind::GRUBClassic => match staged_composefs.boot_type {
BootType::Bls => {
Expand Down
103 changes: 96 additions & 7 deletions crates/lib/src/bootc_composefs/gc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -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()? {
Expand Down Expand Up @@ -212,6 +214,82 @@ 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<BootloaderEntry>,
) -> Result<()> {
// GC always runs *after* finalizing deployments so there would be no staged deployment
// by definition but still only consider live addons for GC
let currently_installed_addons = list_installed_uki_addons(storage, &None)?
.into_iter()
.filter(|addon| !addon.staged)
.collect::<Vec<_>>();

if !currently_installed_addons
.iter()
.any(|addon| addon.addon_type == UkiAddonType::Global)
{
tracing::debug!("No bootc managed Global UKI Addon installed");
return Ok(());
}

// Remove unreferenced global UKI Addons
// GC always runs *after* finalizing deployments so there would be no staged deployment
// by definition
let currently_referenced_addons =
list_referenced_uki_addons(booted_cfs, &bootloader_entries, &None)?;
let mut unreferenced_global_addons = vec![];

tracing::debug!("currently_referenced_addons: {currently_referenced_addons:#?}");
tracing::debug!("currently_installed_addons: {currently_installed_addons:#?}");

for installed_addon in &currently_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,
Expand Down Expand Up @@ -333,7 +411,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)?
Expand All @@ -345,6 +423,10 @@ pub(crate) async fn composefs_gc(
}
}

if booted_cfs_status.boot_type == BootType::Uki {
gc_global_uki_addons(storage, booted_cfs, &gc_opts, &bootloader_entries)?;
}

if !gc_opts.prune_repo {
return Ok(GcResult::default());
}
Expand Down Expand Up @@ -624,14 +706,21 @@ pub(crate) async fn composefs_gc(
}

// Get file size before removing
if let Ok(stat) = statat(&objects_dir, path.as_ref(), AtFlags::empty()) {
objects_bytes += stat.st_size as u64;
match statat(&objects_dir, path.as_ref(), AtFlags::empty()) {
Ok(stat) => {
objects_bytes += stat.st_size as u64;
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This loop unlinks every object in an orphan's /boot without checking whether a retained deployment uses the same object, and addons are usually byte-identical from one image to the next. The loop predates this PR, but uki-addon add now reads these objects.

Reproduced: A, B and C ship identical foo (global) and sc (scoped) addons. With C booted, B the rollback and A no longer in the boot entries, bootc internals composefs-gc --prune-repo (which upgrade and delete-deployment also run) removes 15 objects. After that, uki-addon remove sc scoped + add sc scoped fails with Opening UKI Addon at boot/EFI/Linux/<kver>.efi.extra.d/sc.addon.efi: No such file or directory, the same happens for foo global, and bootc internals fsck still reports ok. Leaving out the objects that any live deployment's /boot still references (booted, staged, rollback, pinned) would fix it. The ENOENT skip covers duplicates within one image, but not this case.

tracing::debug!("Object {} not found... skipping", path.display());
continue;
}
Err(e) => Err(e).with_context(|| format!("statat {} failed", path.display()))?,
}

objects_removed += 1;

unlinkat(&objects_dir, path.as_ref(), AtFlags::empty())
.with_context(|| format!("Unlinking object {path:?}"))?;
.with_context(|| format!("Unlinking (from bootc) object {path:?}"))?;
}
}

Expand Down
2 changes: 2 additions & 0 deletions crates/lib/src/bootc_composefs/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,7 @@ pub(crate) mod soft_reboot;
pub(crate) mod state;
pub(crate) mod status;
pub(crate) mod switch;
pub(crate) mod uki_addon;
pub(crate) mod uki_addons_cli;
pub(crate) mod update;
pub(crate) mod utils;
14 changes: 12 additions & 2 deletions crates/lib/src/bootc_composefs/rollback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ use rustix::fs::{AtFlags, RenameFlags, fsync, renameat_with};

use crate::bootc_composefs::aboot;
use crate::bootc_composefs::boot::{
BootType, FILENAME_PRIORITY_PRIMARY, FILENAME_PRIORITY_SECONDARY, primary_sort_key,
secondary_sort_key, type1_entry_conf_file_name,
BootType, FILENAME_PRIORITY_PRIMARY, FILENAME_PRIORITY_SECONDARY, STAGED_GLOBAL_UKI_ADDONS_DIR,
primary_sort_key, secondary_sort_key, type1_entry_conf_file_name,
};
use crate::bootc_composefs::status::{get_composefs_status, get_sorted_type1_boot_entries};
use crate::composefs_consts::{
Expand Down Expand Up @@ -285,6 +285,16 @@ pub(crate) async fn composefs_rollback(
},

BootloaderKind::BLSCompatible => {
// Clean up any staged global addons which is consistent with what we do
// for staged BLS Config files
//
// The fsync for ESP is done when we rollback entries
let esp = storage.require_esp()?;

esp.fd
.remove_all_optional(STAGED_GLOBAL_UKI_ADDONS_DIR)
.context("Removing staged Global UKI Addons")?;

// We use BLS entries for systemd UKI as well
rollback_composefs_entries(&host, boot_dir, rollback_entry.bootloader.clone())?;
}
Expand Down
1 change: 1 addition & 0 deletions crates/lib/src/bootc_composefs/switch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading