Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
433 changes: 319 additions & 114 deletions crates/lib/src/bootc_composefs/boot.rs

Large diffs are not rendered by default.

59 changes: 55 additions & 4 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 @@ -306,7 +308,7 @@ pub(crate) async fn composefs_gc(
)
}

for (ty, verity) in unreferenced_boot_binaries {
for (ty, verity) in &unreferenced_boot_binaries {
match ty {
BootType::Bls => {
delete_kernel_initrd(storage, &get_type1_dir_name(verity), gc_opts.dry_run)?
Expand All @@ -315,6 +317,55 @@ pub(crate) async fn composefs_gc(
}
}

// Remove unreferenced global UKI Addons
let currently_referenced_addons = list_referenced_uki_addons(booted_cfs, &bootloader_entries)?;
let currently_installed_addons = list_installed_uki_addons(storage)?;
let mut unreferenced_global_addons = vec![];

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

for installed_addon in &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}"))?;
}
}

if !gc_opts.prune_repo {
return Ok(GcResult::default());
}
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 @@ -15,5 +15,7 @@ pub(crate) mod soft_reboot;
pub(crate) mod state;
pub(crate) mod status;
pub(crate) mod switch;
pub(crate) mod uki_addon;
pub(crate) mod uki_addons_cli;
pub(crate) mod update;
pub(crate) mod utils;
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
272 changes: 272 additions & 0 deletions crates/lib/src/bootc_composefs/uki_addon.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
use std::fmt;

use anyhow::{Context, Result};
use bootc_mount::tempmount::TempMount;
use cap_std_ext::cap_std::fs::Dir;
use cap_std_ext::dirext::CapStdExtDirExt;
use fn_error_context::context;
use ostree_ext::{
composefs::fsverity::{FsVerityHashValue, Sha512HashValue},
composefs_boot::bootloader::{EFI_ADDON_DIR_EXT, EFI_ADDON_FILE_EXT},
composefs_oci::linked_erofs_images,
};
use serde::Serialize;

use crate::{
bootc_composefs::{
boot::{BOOTC_UKI_DIR, EFI_LINUX, GLOBAL_UKI_ADDONS_DIR},
status::BootloaderEntry,
},
composefs_consts::UKI_NAME_PREFIX,
store::{BootedComposefs, Storage},
};

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum UkiAddonType {
Scoped { depl_id: String },
Global,
}

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<UkiAddonsList>,
addon_type: UkiAddonType,
) -> Result<()> {
for ent in dir.entries_utf8()? {
let ent = ent?;
let filename = ent.file_name()?;

let Some(addon_name) = filename.strip_suffix(EFI_ADDON_FILE_EXT) else {
continue;
};

match addon_name.strip_prefix(UKI_NAME_PREFIX) {
Some(addon_name) => {
addons.push(UkiAddonsList {
name: addon_name.to_string(),
addon_type: addon_type.clone(),
});
}
None => match addon_type {
UkiAddonType::Scoped { .. } => {
addons.push(UkiAddonsList {
name: addon_name.to_string(),
addon_type: addon_type.clone(),
});
}
// We only prefix global UKI Addons for identification
UkiAddonType::Global => {
tracing::info!("Global UKI Addon not managed by bootc found: {addon_name}")
}
},
}
}

Ok(())
}

/// Gathers UKI Addons (Global + Scoped) from the ESP
#[context("Gathering addons from filesystem")]
pub fn gather_addons_from_filesystem(
boot_dir: &Dir,
depl_id: Option<&str>,
) -> Result<Vec<UkiAddonsList>> {
let mut addons_list: Vec<UkiAddonsList> = vec![];

if let Some(global_dir) = boot_dir.open_dir_optional(GLOBAL_UKI_ADDONS_DIR)? {
for entry in global_dir.entries_utf8()? {
let entry = entry?;
let filename = entry.file_name()?;

if let Some(name) = filename.strip_suffix(EFI_ADDON_FILE_EXT) {
addons_list.push(UkiAddonsList {
name: name.to_string(),
addon_type: UkiAddonType::Global,
});
}
}
}

let Some(efi_linux) = boot_dir.open_dir_optional(EFI_LINUX)? else {
return Ok(addons_list);
};

for entry in efi_linux.entries_utf8()? {
let entry = entry?;

if !entry.file_type()?.is_dir() {
continue;
}

let dirname = entry.file_name()?;

// This will usually be the kernel version
let Some(..) = dirname.strip_suffix(EFI_ADDON_DIR_EXT) else {
continue;
};

let dir = efi_linux
.open_dir(&dirname)
.with_context(|| format!("Opening {dirname}"))?;

for addon_ent in dir.entries_utf8()? {
let addon_ent = addon_ent?;
let filename = addon_ent.file_name()?;

if let Some(name) = filename.strip_suffix(EFI_ADDON_FILE_EXT) {
addons_list.push(UkiAddonsList {
name: name.to_string(),
addon_type: UkiAddonType::Scoped {
// We can't always have the deployment id with us
depl_id: depl_id.map(|x| x.to_string()).unwrap_or("".into()),
},
});
}
}
}

Ok(addons_list)
}

#[context("Listing UKI Addons")]
pub fn list_installed_uki_addons(storage: &Storage) -> Result<Vec<UkiAddonsList>> {
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)
.context("Gathering global addons")?;
};

let Some(bootc_uki_dir) = esp
.fd
.open_dir_optional(BOOTC_UKI_DIR)
.context("Opening UKI dir")?
else {
return Ok(addons);
};

for ent in bootc_uki_dir
.entries_utf8()
.context("Reading UKI dir entries")?
{

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nonblocking but I think the inner listing object here could be a helper function that we can easily unit test in a tempdir

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)
}

/// Go through all the EROFS images and get all the referenced UKI Addons
///
/// Returns a list of tuple of (EROFS verity, List of referenced addons)
#[context("Getting all referenced UKI Addons")]
pub fn list_referenced_uki_addons(
booted_cfs: &BootedComposefs,
bootloader_entries: &Vec<BootloaderEntry>,
) -> Result<Vec<(String, Vec<UkiAddonsList>)>> {
let mut all_referenced_addons: Vec<(String, Vec<UkiAddonsList>)> = vec![];

for entry in bootloader_entries {
let verity = Sha512HashValue::from_hex(&entry.fsverity);

let verity = match verity {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

These continues (bad verity, no EROFS, no non-bootable EROFS) make that deployment's global addons look unreferenced, so GC deletes them. Can we fail closed here and skip global-addon GC with a warning when any live entry can't be resolved?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is partly to handle corrupted state test, though I do think we need better testing for that.

These continues (bad verity, no EROFS, no non-bootable EROFS) make that deployment's global addons look unreferenced, so GC deletes them.

If we do not have a non-bootable EROFS, we can't find the referenced Addons in the first place. Also, we always generate both (bootable and non-bootable) EROFS-es for each deployment

Ok(v) => v,
Err(e) => {
tracing::warn!(
"Invalid fsverity found in bootloader entry {}: {e:?}",
entry.fsverity
);
continue;
}
};

let linked_erofs = match linked_erofs_images(&booted_cfs.repo, &verity) {
Ok(v) => v,
// Skip entries with no linked EROFS image, but propagate any other error
// TODO: Update with proper error downcasted match once we have
// https://github.com/composefs/composefs-rs/pull/400 released
Err(e)
if e.chain()
.any(|c| c.to_string().contains("No EROFS image found")) =>
Comment thread
Johan-Liebert1 marked this conversation as resolved.
{
tracing::debug!("No EROFS image found for {}", entry.fsverity);
continue;
}
Err(e) => return Err(e),
};

let Some(non_bootable) = linked_erofs.iter().find(|e| !e.bootable) else {
tracing::debug!("No non-bootable EROFS found for {}", entry.fsverity);
continue;
};

let composefs_mnt_fd = booted_cfs
.repo
.mount(&non_bootable.id.to_hex())
.context("Failed to mount composefs image")?;

let composefs = TempMount::mount_fd(composefs_mnt_fd)
.context("Attaching composefs image to temporary directory")?;

let cfs_boot_dir = composefs
.fd
.open_dir("boot")
.context("Opening boot directory in composefs image")?;

let addons_list = gather_addons_from_filesystem(&cfs_boot_dir, Some(&entry.fsverity))?;

// We work with the bootable fsverity everywhere
all_referenced_addons.push((entry.fsverity.clone(), addons_list));
}

Ok(all_referenced_addons)
}
Loading
Loading