diff --git a/Cargo.lock b/Cargo.lock index b99032d57..8f84aea80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -739,7 +739,7 @@ checksum = "55b672471b4e9f9e95499ea597ff64941a309b2cdbffcc46f2cc5e2d971fd335" [[package]] name = "composefs" version = "0.7.0" -source = "git+https://github.com/composefs/composefs-rs?rev=0fbc853325ff92dbb842e715611bed7ddf9fe169#0fbc853325ff92dbb842e715611bed7ddf9fe169" +source = "git+https://github.com/composefs/composefs-rs?rev=76923702bc50a0d625b4adf64fb2ee40dd1304b9#76923702bc50a0d625b4adf64fb2ee40dd1304b9" dependencies = [ "anyhow", "composefs-ioctls", @@ -747,12 +747,12 @@ dependencies = [ "hex", "log", "once_cell", + "openssl", "rand 0.10.2", "rustix", "serde", "serde_json", "serde_repr", - "sha2 0.11.0", "tempfile", "thiserror 2.0.19", "tokio", @@ -766,7 +766,7 @@ dependencies = [ [[package]] name = "composefs-boot" version = "0.7.0" -source = "git+https://github.com/composefs/composefs-rs?rev=0fbc853325ff92dbb842e715611bed7ddf9fe169#0fbc853325ff92dbb842e715611bed7ddf9fe169" +source = "git+https://github.com/composefs/composefs-rs?rev=76923702bc50a0d625b4adf64fb2ee40dd1304b9#76923702bc50a0d625b4adf64fb2ee40dd1304b9" dependencies = [ "anyhow", "composefs", @@ -782,7 +782,7 @@ dependencies = [ [[package]] name = "composefs-ctl" version = "0.7.0" -source = "git+https://github.com/composefs/composefs-rs?rev=0fbc853325ff92dbb842e715611bed7ddf9fe169#0fbc853325ff92dbb842e715611bed7ddf9fe169" +source = "git+https://github.com/composefs/composefs-rs?rev=76923702bc50a0d625b4adf64fb2ee40dd1304b9#76923702bc50a0d625b4adf64fb2ee40dd1304b9" dependencies = [ "anyhow", "cap-std-ext", @@ -811,7 +811,7 @@ dependencies = [ [[package]] name = "composefs-fuse" version = "0.7.0" -source = "git+https://github.com/composefs/composefs-rs?rev=0fbc853325ff92dbb842e715611bed7ddf9fe169#0fbc853325ff92dbb842e715611bed7ddf9fe169" +source = "git+https://github.com/composefs/composefs-rs?rev=76923702bc50a0d625b4adf64fb2ee40dd1304b9#76923702bc50a0d625b4adf64fb2ee40dd1304b9" dependencies = [ "anyhow", "composefs", @@ -826,7 +826,7 @@ dependencies = [ [[package]] name = "composefs-ioctls" version = "0.7.0" -source = "git+https://github.com/composefs/composefs-rs?rev=0fbc853325ff92dbb842e715611bed7ddf9fe169#0fbc853325ff92dbb842e715611bed7ddf9fe169" +source = "git+https://github.com/composefs/composefs-rs?rev=76923702bc50a0d625b4adf64fb2ee40dd1304b9#76923702bc50a0d625b4adf64fb2ee40dd1304b9" dependencies = [ "rustix", "thiserror 2.0.19", @@ -835,7 +835,7 @@ dependencies = [ [[package]] name = "composefs-oci" version = "0.7.0" -source = "git+https://github.com/composefs/composefs-rs?rev=0fbc853325ff92dbb842e715611bed7ddf9fe169#0fbc853325ff92dbb842e715611bed7ddf9fe169" +source = "git+https://github.com/composefs/composefs-rs?rev=76923702bc50a0d625b4adf64fb2ee40dd1304b9#76923702bc50a0d625b4adf64fb2ee40dd1304b9" dependencies = [ "anyhow", "async-compression", @@ -855,7 +855,6 @@ dependencies = [ "rustix", "serde", "serde_json", - "sha2 0.11.0", "tar-core", "thiserror 2.0.19", "tokio", @@ -869,12 +868,12 @@ dependencies = [ [[package]] name = "composefs-splitdirfdstream" version = "0.7.0" -source = "git+https://github.com/composefs/composefs-rs?rev=0fbc853325ff92dbb842e715611bed7ddf9fe169#0fbc853325ff92dbb842e715611bed7ddf9fe169" +source = "git+https://github.com/composefs/composefs-rs?rev=76923702bc50a0d625b4adf64fb2ee40dd1304b9#76923702bc50a0d625b4adf64fb2ee40dd1304b9" dependencies = [ + "openssl", "rand 0.10.2", "rand_pcg", "rustix", - "sha2 0.11.0", "thiserror 2.0.19", "tokio", ] @@ -882,7 +881,7 @@ dependencies = [ [[package]] name = "composefs-storage" version = "0.7.0" -source = "git+https://github.com/composefs/composefs-rs?rev=0fbc853325ff92dbb842e715611bed7ddf9fe169#0fbc853325ff92dbb842e715611bed7ddf9fe169" +source = "git+https://github.com/composefs/composefs-rs?rev=76923702bc50a0d625b4adf64fb2ee40dd1304b9#76923702bc50a0d625b4adf64fb2ee40dd1304b9" dependencies = [ "anyhow", "base64 0.23.0", diff --git a/Cargo.toml b/Cargo.toml index 8038bc7c8..6c80f8999 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,7 +48,7 @@ clap_mangen = { version = "0.3.0" } # pulls), which drags in rustls-webpki/untrusted/webpki-root-certs whose # licenses aren't in our cargo-deny allow list. Re-enable once that's sorted. # See: https://github.com/bootc-dev/bootc/pull/2295 -composefs-ctl = { git = "https://github.com/composefs/composefs-rs", rev = "0fbc853325ff92dbb842e715611bed7ddf9fe169", default-features = false, features = [ +composefs-ctl = { git = "https://github.com/composefs/composefs-rs", rev = "76923702bc50a0d625b4adf64fb2ee40dd1304b9", default-features = false, features = [ "pre-6.15", "pre-6.16", "oci", diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index 9909aa0c0..748a801e8 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -62,6 +62,7 @@ //! 2. **Secondary**: Currently booted deployment (rollback option) use std::cell::Cell; +use std::collections::HashMap; use std::fs::create_dir_all; use std::io::{Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; @@ -91,6 +92,10 @@ use composefs_ctl::composefs_oci; use fn_error_context::context; use linux_kernel_cmdline::utf8::{Cmdline, Parameter}; use ostree_ext::composefs::dumpfile; +use ostree_ext::composefs::erofs::format::FormatVersion; +use ostree_ext::composefs::erofs::writer::{ValidatedFileSystem, mkfs_erofs_versioned}; +use ostree_ext::composefs::generic_tree::{Directory, FileSystem, Inode, Leaf, LeafId}; +use ostree_ext::composefs::repository::Repository; use rustix::{mount::MountFlags, path::Arg}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -353,6 +358,11 @@ 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 the saved /boot directory EROFS +pub(crate) fn get_boot_erofs_name(depl_verity: &str) -> String { + format!("{depl_verity}.boot") +} + /// Compute SHA256Sum of VMlinuz + Initrd /// /// # Arguments @@ -966,7 +976,7 @@ fn write_pe_to_esp( let pe_name = match pe_type { PEType::Uki => &get_uki_name(&uki_id.to_hex()), - PEType::UkiAddon => file_path + PEType::UkiAddon | PEType::GlobalUkiAddon => file_path .components() .last() .ok_or_else(|| anyhow::anyhow!("Failed to get UKI Addon file name"))? @@ -1450,6 +1460,66 @@ fn get_secureboot_keys(fs: &Dir, p: &str) -> Result> { })); } +fn copy_inode( + inode: &Inode>, + src_leaves: &[Leaf>], + dst: &mut FileSystem>, + id_map: &mut HashMap, +) -> Inode> { + match inode { + Inode::Directory(dir) => { + let mut new_dir = Directory::new(dir.stat.clone()); + for (name, child) in dir.entries() { + let new_child = copy_inode(child, src_leaves, dst, id_map); + new_dir.insert(name, new_child); + } + Inode::Directory(Box::new(new_dir)) + } + Inode::Leaf(id, _) => { + let new_id = *id_map.entry(*id).or_insert_with(|| { + let leaf = &src_leaves[id.0]; + dst.push_leaf(leaf.stat.clone(), leaf.content.clone()) + }); + Inode::leaf(new_id) + } + } +} + +/// Accepts a filesystem, find the `/boot` directory, clones the `/boot` directory +/// into a new filesystem, validates the filesystem then returns the raw bytes for +/// the EROFS image corresponding to the newly created filesystem +#[context("Saving EROFS for /boot")] +pub(crate) fn save_boot_dir_erofs( + repo: &Repository, + fs: &FileSystem>, + depl_id: &Sha512HashValue, +) -> Result<()> { + for (name, inode) in fs.root.entries() { + if name.as_str() != Ok("boot") { + continue; + } + + let Inode::Directory(..) = inode else { + anyhow::bail!("/boot is a file"); + }; + + let mut new_fs: FileSystem> = + FileSystem::new(fs.root.stat.clone()); + let mut id_map = HashMap::new(); + + let new_inode = copy_inode(inode, &fs.leaves, &mut new_fs, &mut id_map); + new_fs.root.insert(name, new_inode); + + let validated_boot = ValidatedFileSystem::new(new_fs)?; + let erofs_bytes = mkfs_erofs_versioned(&validated_boot, FormatVersion::V1); + // NOTE: .boot is actually saved in refs + // The actual EROFS is still in composefs/images + repo.write_image(Some(&get_boot_erofs_name(&depl_id.to_hex())), &erofs_bytes)?; + } + + Ok(()) +} + #[context("Setting up composefs boot")] pub(crate) async fn setup_composefs_boot( root_setup: &RootSetup, @@ -1475,21 +1545,28 @@ pub(crate) async fn setup_composefs_boot( let repo = Arc::new(repo); // Generate the bootable EROFS image (idempotent). - let id = composefs_oci::generate_boot_image( + let (id, mut fs) = composefs_oci::generate_boot_image_get_fs( &repo, &pull_result.manifest_digest, &Default::default(), ) .context("Generating bootable EROFS image")?; - // Reconstruct the OCI filesystem to discover boot entries (kernel, initramfs, etc.). - let fs = composefs_oci::image::create_filesystem( - &*repo, - &pull_result.config_digest, - None, - &Default::default(), - ) - .context("Creating composefs filesystem for boot entry discovery")?; + if fs.is_none() { + // Reconstruct the OCI filesystem to discover boot entries (kernel, initramfs, etc.). + fs = Some( + composefs_oci::image::create_filesystem( + &*repo, + &pull_result.config_digest, + Some(&pull_result.config_verity), + &Default::default(), + ) + .context("Creating composefs filesystem for boot entry discovery")?, + ); + } + + let fs = fs.ok_or_else(|| anyhow::anyhow!("Failed to get composefs filesystem"))?; + let entries = get_boot_resources(&fs, &*repo).context("Extracting boot entries from OCI image")?; @@ -1598,6 +1675,11 @@ pub(crate) async fn setup_composefs_boot( ) })?; + // Save /boot for UKI images + if boot_type == BootType::Uki { + save_boot_dir_erofs(&repo, &fs, &id)?; + } + let boot_digest = match boot_type { BootType::Bls => setup_composefs_bls_boot( BootSetupType::Setup((&root_setup, &state, &postfetch)), diff --git a/crates/lib/src/bootc_composefs/gc.rs b/crates/lib/src/bootc_composefs/gc.rs index 5856035e6..eae491a91 100644 --- a/crates/lib/src/bootc_composefs/gc.rs +++ b/crates/lib/src/bootc_composefs/gc.rs @@ -12,7 +12,12 @@ use composefs_boot::bootloader::EFI_EXT; use composefs_ctl::composefs; use composefs_ctl::composefs_boot; use composefs_ctl::composefs_oci; +use ostree_ext::composefs::ImageNotFound; +use rustix::fs::AtFlags; +use rustix::fs::readlinkat; +use rustix::fs::{statat, unlinkat}; +use crate::bootc_composefs::boot::get_boot_erofs_name; use crate::{ bootc_composefs::{ boot::{BOOTC_UKI_DIR, BootType, get_type1_dir_name, get_uki_addon_dir_name, get_uki_name}, @@ -346,7 +351,7 @@ pub(crate) async fn composefs_gc( // Collect the set of manifest digests referenced by live deployments, // and track EROFS image verities as fallback additional_roots for - // deployments that predate the manifest→image link. + // deployments that predate the manifest -> image link. let mut live_manifest_digests: Vec = Vec::new(); let mut additional_roots = Vec::new(); // Container image names for containers-storage pruning. @@ -366,7 +371,7 @@ pub(crate) async fn composefs_gc( } // Keep the EROFS image as an additional root until all deployments - // have manifest→image refs. Once a deployment is pulled with the + // have manifest -> image refs. Once a deployment is pulled with the // new code, its EROFS image is reachable from the manifest and // this entry becomes redundant (but harmless). additional_roots.push(verity.clone()); @@ -492,12 +497,91 @@ pub(crate) async fn composefs_gc( // first action. Callers must ensure no other `Repository` handle on that // same underlying file is still alive when this runs, or it will deadlock // (see update.rs's do_upgrade() for an example of getting this wrong). - let gc_result = if gc_opts.dry_run { + let mut gc_result = if gc_opts.dry_run { booted_cfs.repo.gc_dry_run(&additional_roots)? } else { booted_cfs.repo.gc(&additional_roots)? }; + // Now GC the UKI/UKI Addons from `.boot` EROFS if we have them + // These won't be GC'd by the above `repo.gc` as the EROFS have + // `/boot` masked + for verity in all_orphans { + let boot_img_name = get_boot_erofs_name(verity); + + let objects = match booted_cfs.repo.objects_for_image(&boot_img_name) { + Ok(objects) => objects, + Err(e) => match e.downcast::() { + Ok(_) => continue, + Err(e) => Err(e)?, + }, + }; + + let objects_dir = booted_cfs + .repo + .objects_dir() + .context("Opening objects dir")?; + + for object_sha in objects { + let path = object_sha.to_object_pathname(); + + tracing::debug!( + "{}: objects/{path}", + if gc_opts.dry_run { + "would remove" + } else { + "removing" + }, + ); + + if gc_opts.dry_run { + continue; + } + + // Get file size before removing + if let Ok(stat) = statat(&objects_dir, &path, AtFlags::empty()) { + gc_result.objects_bytes += stat.st_size as u64; + } + + gc_result.objects_removed += 1; + + unlinkat(&objects_dir, &path, AtFlags::empty()) + .with_context(|| format!("Unlinking object {path}"))?; + } + + let boot_img_path = format!("images/refs/{boot_img_name}"); + + // Delete the image now + let repo_fd = booted_cfs.repo.repo_fd(); + let linked_img = + readlinkat(repo_fd, &boot_img_path, &[]).context("Reading boot image link")?; + let linked_img = linked_img.to_str().context("Converting link to str")?; + + tracing::debug!("Boot image: {boot_img_name} -> {linked_img:?}"); + + // The link is realtive, we need the image name itself + let Some(linked_img) = linked_img.rsplit("/").next() else { + anyhow::bail!("{linked_img} is not a proper relative symlink to an image"); + }; + + if gc_opts.dry_run { + continue; + } + + let img_path = format!("images/{linked_img}"); + + if let Ok(stat) = statat(booted_cfs.repo.repo_fd(), &img_path, AtFlags::empty()) { + gc_result.objects_bytes += stat.st_size as u64; + } + + unlinkat(booted_cfs.repo.repo_fd(), &img_path, AtFlags::empty()) + .context("Removing boot image")?; + gc_result.images_pruned += 1; + + unlinkat(booted_cfs.repo.repo_fd(), &boot_img_path, AtFlags::empty()) + .context("Removing boot image symlink")?; + } + Ok(gc_result) } diff --git a/crates/lib/src/bootc_composefs/repo.rs b/crates/lib/src/bootc_composefs/repo.rs index 4d9fe068d..656d4fb35 100644 --- a/crates/lib/src/bootc_composefs/repo.rs +++ b/crates/lib/src/bootc_composefs/repo.rs @@ -36,6 +36,7 @@ //! backend. use fn_error_context::context; +use ostree_ext::composefs::tree::FileSystem; use std::sync::Arc; use anyhow::{Context, Result}; @@ -188,6 +189,8 @@ pub(crate) struct PullRepoResult { pub(crate) id: Sha512HashValue, /// The OCI manifest content digest (e.g. "sha256:abc...") pub(crate) manifest_digest: String, + /// The untransformed OCI filesystem (still has /boot, /sysroot, etc.) + pub(crate) fs: FileSystem, } /// Pull an image directly into the composefs repository via skopeo. @@ -393,21 +396,28 @@ pub(crate) async fn pull_composefs_repo( ); // Generate the bootable EROFS image (idempotent). - let id = composefs_oci::generate_boot_image( + let (id, mut fs) = composefs_oci::generate_boot_image_get_fs( &repo, &pull_result.manifest_digest, &Default::default(), ) .context("Generating bootable EROFS image")?; - // Get boot entries from the OCI filesystem (untransformed). - let fs = create_composefs_filesystem( - &*repo, - &pull_result.config_digest, - None, - &Default::default(), - ) - .context("Creating composefs filesystem for boot entry discovery")?; + if fs.is_none() { + // Get boot entries from the OCI filesystem (untransformed). + fs = Some( + create_composefs_filesystem( + &*repo, + &pull_result.config_digest, + Some(&pull_result.config_verity), + &Default::default(), + ) + .context("Creating composefs filesystem for boot entry discovery")?, + ); + } + + let fs = fs.ok_or_else(|| anyhow::anyhow!("Failed to get composefs filesystem"))?; + let entries = get_boot_resources(&fs, &*repo).context("Extracting boot entries from OCI image")?; @@ -424,6 +434,7 @@ pub(crate) async fn pull_composefs_repo( entries, id, manifest_digest: pull_result.manifest_digest.to_string(), + fs, }) } diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index 05cb989b5..6ee5b7984 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -12,6 +12,7 @@ use fn_error_context::context; use ocidir::cap_std::ambient_authority; use ostree_ext::container::ManifestDiff; +use crate::bootc_composefs::boot::save_boot_dir_erofs; use crate::bootc_composefs::finalize::get_etc_diff; use crate::bootc_composefs::gc::GCOpts; use crate::spec::BootloaderKind; @@ -263,6 +264,7 @@ pub(crate) async fn do_upgrade( entries, id, manifest_digest, + fs, } = pull_composefs_repo( imgref, booted_cfs.cmdline.allow_missing_fsverity, @@ -311,6 +313,11 @@ pub(crate) async fn do_upgrade( let boot_type = BootType::from(entry); + // Save /boot for UKI images + if boot_type == BootType::Uki { + save_boot_dir_erofs(&repo, &fs, &id)?; + } + let boot_digest = match boot_type { BootType::Bls => setup_composefs_bls_boot( BootSetupType::Upgrade((storage, booted_cfs, &host)), diff --git a/tmt/tests/booted/test-composefs-gc-uki.nu b/tmt/tests/booted/test-composefs-gc-uki.nu index e2efd561c..440639038 100644 --- a/tmt/tests/booted/test-composefs-gc-uki.nu +++ b/tmt/tests/booted/test-composefs-gc-uki.nu @@ -38,12 +38,30 @@ def first_boot [] { bootc switch --transport containers-storage localhost/bootc-first + # Make sure we have the .boot EROFS + let st = bootc status --json | from json + let staged_verity = $st.status.staged.composefs.verity + let booted_verity = $st.status.booted.composefs.verity + + assert ($"/sysroot/composefs/images/refs/($staged_verity).boot" | path exists) + assert ($"/sysroot/composefs/images/refs/($booted_verity).boot" | path exists) + # Find the large file's verity and save it let new_st = bootc status --json | from json let path = bootc internals cfs dump-files $new_st.status.staged.composefs.verity /usr/share/large-test-file --backing-path-only | awk '{print $2}' echo $"/sysroot/composefs/objects/($path)" | save /var/large-file-marker-objpath + mkdir /var/tmp/efi + mount /dev/disk/by-partlabel/EFI-SYSTEM /var/tmp/efi + + # 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}' + echo $st.status.booted.composefs.verity | save /var/boot0-verity + echo $uki_in_objs | save /var/boot0-uki-in-objs tmt-reboot } @@ -100,6 +118,11 @@ def third_boot [] { bootc switch --transport containers-storage localhost/bootc-third + # Make sure the .boot image is removed + assert (not ($"/sysroot/composefs/images/refs/(cat /var/boot0-verity).boot" | path exists)) + # Now also make sure the UKI doesn't exist in the objects directory either + assert (not (cat /var/boot0-uki-in-objs | path exists)) + tmt-reboot }