From 410f54364ae84a513f3b27895b2318a32aa7341e Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Sat, 14 Mar 2026 19:51:18 +0000 Subject: [PATCH 1/3] composefs: Prep for new composefs-rs OCI APIs Extract read_origin() helper from status.rs into state.rs for reuse in GC. TEMP: bump to my fork Assisted-by: OpenCode (Claude claude-opus-4-6) Signed-off-by: Colin Walters --- Cargo.lock | 23 +++++++++++++++++------ Cargo.toml | 3 +++ crates/lib/src/bootc_composefs/export.rs | 5 +++-- crates/lib/src/bootc_composefs/state.rs | 21 +++++++++++++++++++++ crates/lib/src/bootc_composefs/status.rs | 18 +++--------------- 5 files changed, 47 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 958396b17..75e761adb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -621,7 +621,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "cfsctl" version = "0.3.0" -source = "git+https://github.com/composefs/composefs-rs?rev=2203e8f#2203e8f331cc41afafa0f81e9a2df7d681e9f631" +source = "git+https://github.com/cgwalters/composefs-rs?rev=d62471bc89d4852f2aab91c7948466e3a3942021#d62471bc89d4852f2aab91c7948466e3a3942021" dependencies = [ "anyhow", "clap", @@ -775,7 +775,7 @@ checksum = "55b672471b4e9f9e95499ea597ff64941a309b2cdbffcc46f2cc5e2d971fd335" [[package]] name = "composefs" version = "0.3.0" -source = "git+https://github.com/composefs/composefs-rs?rev=2203e8f#2203e8f331cc41afafa0f81e9a2df7d681e9f631" +source = "git+https://github.com/cgwalters/composefs-rs?rev=d62471bc89d4852f2aab91c7948466e3a3942021#d62471bc89d4852f2aab91c7948466e3a3942021" dependencies = [ "anyhow", "composefs-ioctls", @@ -797,7 +797,7 @@ dependencies = [ [[package]] name = "composefs-boot" version = "0.3.0" -source = "git+https://github.com/composefs/composefs-rs?rev=2203e8f#2203e8f331cc41afafa0f81e9a2df7d681e9f631" +source = "git+https://github.com/cgwalters/composefs-rs?rev=d62471bc89d4852f2aab91c7948466e3a3942021#d62471bc89d4852f2aab91c7948466e3a3942021" dependencies = [ "anyhow", "composefs", @@ -811,7 +811,7 @@ dependencies = [ [[package]] name = "composefs-ioctls" version = "0.3.0" -source = "git+https://github.com/composefs/composefs-rs?rev=2203e8f#2203e8f331cc41afafa0f81e9a2df7d681e9f631" +source = "git+https://github.com/cgwalters/composefs-rs?rev=d62471bc89d4852f2aab91c7948466e3a3942021#d62471bc89d4852f2aab91c7948466e3a3942021" dependencies = [ "rustix", "thiserror 2.0.17", @@ -820,12 +820,13 @@ dependencies = [ [[package]] name = "composefs-oci" version = "0.3.0" -source = "git+https://github.com/composefs/composefs-rs?rev=2203e8f#2203e8f331cc41afafa0f81e9a2df7d681e9f631" +source = "git+https://github.com/cgwalters/composefs-rs?rev=d62471bc89d4852f2aab91c7948466e3a3942021#d62471bc89d4852f2aab91c7948466e3a3942021" dependencies = [ "anyhow", "async-compression", "bytes", "composefs", + "composefs-boot", "containers-image-proxy", "fn-error-context", "hex", @@ -835,7 +836,7 @@ dependencies = [ "serde", "serde_json", "sha2", - "tar", + "tar-core", "tokio", "tokio-util", ] @@ -2965,6 +2966,16 @@ dependencies = [ "xattr", ] +[[package]] +name = "tar-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a870b96d9b5bd13e81517d63a18da0f0c33de072eeb5a293a2e40f3830befa0e" +dependencies = [ + "thiserror 2.0.17", + "zerocopy", +] + [[package]] name = "target-lexicon" version = "0.13.3" diff --git a/Cargo.toml b/Cargo.toml index 839c079bf..f414844d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -114,3 +114,6 @@ todo = "deny" needless_borrow = "allow" needless_borrows_for_generic_args = "allow" +[patch."https://github.com/composefs/composefs-rs"] +cfsctl = { git = "https://github.com/cgwalters/composefs-rs", rev = "d62471bc89d4852f2aab91c7948466e3a3942021" } + diff --git a/crates/lib/src/bootc_composefs/export.rs b/crates/lib/src/bootc_composefs/export.rs index 797d19954..9d882231b 100644 --- a/crates/lib/src/bootc_composefs/export.rs +++ b/crates/lib/src/bootc_composefs/export.rs @@ -55,8 +55,9 @@ pub async fn export_repo_to_image( let oci_dir = OciDir::ensure(tmpdir.try_clone()?).context("Opening OCI")?; // Use composefs_oci::open_config to get the config and layer map - let (config, layer_map) = - open_config(&*booted_cfs.repo, &config_digest, None).context("Opening config")?; + let open = open_config(&*booted_cfs.repo, &config_digest, None).context("Opening config")?; + let config = open.config; + let layer_map = open.layer_refs; // We can't guarantee that we'll get the same tar stream as the container image // So we create new config and manifest diff --git a/crates/lib/src/bootc_composefs/state.rs b/crates/lib/src/bootc_composefs/state.rs index 3be32090b..ebdd69c1f 100644 --- a/crates/lib/src/bootc_composefs/state.rs +++ b/crates/lib/src/bootc_composefs/state.rs @@ -44,6 +44,27 @@ use crate::{ utils::path_relative_to, }; +/// Read and parse the `.origin` INI file for a deployment. +/// +/// Returns `None` if the state directory or origin file doesn't exist +/// (e.g. the deployment was partially deleted). +#[context("Reading origin for deployment {deployment_id}")] +pub(crate) fn read_origin(sysroot: &Dir, deployment_id: &str) -> Result> { + let depl_state_path = std::path::PathBuf::from(STATE_DIR_RELATIVE).join(deployment_id); + + let Some(state_dir) = sysroot.open_dir_optional(&depl_state_path)? else { + return Ok(None); + }; + + let origin_filename = format!("{deployment_id}.origin"); + let Some(origin_contents) = state_dir.read_to_string_optional(&origin_filename)? else { + return Ok(None); + }; + + let ini = tini::Ini::from_string(&origin_contents).context("Failed to parse origin file")?; + Ok(Some(ini)) +} + pub(crate) fn get_booted_bls(boot_dir: &Dir) -> Result { let cmdline = Cmdline::from_proc()?; let booted = cmdline diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index 4a1f142f8..24cd4cc9a 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -11,7 +11,7 @@ use crate::{ boot::BootType, repo::get_imgref, selinux::are_selinux_policies_compatible, - state::get_composefs_usr_overlay_status, + state::{get_composefs_usr_overlay_status, read_origin}, utils::{compute_store_boot_digest_for_uki, get_uki_cmdline}, }, composefs_consts::{ @@ -699,11 +699,6 @@ async fn composefs_deployment_status_from( // This is our source of truth let bootloader_entry_verity = list_bootloader_entries(storage)?; - let state_dir = storage - .physical_root - .open_dir(STATE_DIR_RELATIVE) - .with_context(|| format!("Opening {STATE_DIR_RELATIVE}"))?; - let host_spec = HostSpec { image: None, boot_order: BootOrder::Default, @@ -732,15 +727,8 @@ async fn composefs_deployment_status_from( let mut extra_deployment_boot_entries: Vec = Vec::new(); for verity_digest in bootloader_entry_verity { - // read the origin file - let config = state_dir - .open_dir(&verity_digest) - .with_context(|| format!("Failed to open {verity_digest}"))? - .read_to_string(format!("{verity_digest}.origin")) - .with_context(|| format!("Reading file {verity_digest}.origin"))?; - - let ini = tini::Ini::from_string(&config) - .with_context(|| format!("Failed to parse file {verity_digest}.origin as ini"))?; + let ini = read_origin(&storage.physical_root, &verity_digest)? + .ok_or_else(|| anyhow::anyhow!("No origin file for deployment {verity_digest}"))?; let mut boot_entry = boot_entry_from_composefs_deployment(storage, ini, &verity_digest).await?; From 859287bfa4f11c4ba1e324a0cb669f46ecc7b1a2 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Sat, 14 Mar 2026 19:51:28 +0000 Subject: [PATCH 2/3] composefs: Switch to composefs_oci::pull_image() API The new API gives us manifest-level information (digest, verity) which we need for tag-based GC later. Assisted-by: OpenCode (Claude claude-opus-4-6) Signed-off-by: Colin Walters --- crates/lib/src/bootc_composefs/repo.rs | 62 ++++++++++++++++++------ crates/lib/src/bootc_composefs/update.rs | 8 ++- crates/lib/src/install.rs | 9 +--- 3 files changed, 55 insertions(+), 24 deletions(-) diff --git a/crates/lib/src/bootc_composefs/repo.rs b/crates/lib/src/bootc_composefs/repo.rs index a7cb1835f..359d6a067 100644 --- a/crates/lib/src/bootc_composefs/repo.rs +++ b/crates/lib/src/bootc_composefs/repo.rs @@ -9,7 +9,8 @@ use cfsctl::composefs_oci; use composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; use composefs_boot::{BootOps, bootloader::BootEntry as ComposefsBootEntry}; use composefs_oci::{ - PullResult, image::create_filesystem as create_composefs_filesystem, pull as composefs_oci_pull, + image::create_filesystem as create_composefs_filesystem, + pull_image as composefs_oci_pull_image, skopeo::PullResult, }; use ostree_ext::container::ImageReference as OstreeExtImgRef; @@ -56,13 +57,25 @@ pub(crate) async fn initialize_composefs_repository( } = &state.source.imageref; // transport's display is already of type ":" - composefs_oci_pull( + let (pull_result, _stats) = composefs_oci_pull_image( &Arc::new(repo), &format!("{transport}{image_name}"), None, None, ) - .await + .await?; + + tracing::info!( + message_id = COMPOSEFS_REPO_INIT_JOURNAL_ID, + bootc.operation = "repository_init", + bootc.manifest_digest = pull_result.manifest_digest, + bootc.manifest_verity = pull_result.manifest_verity.to_hex(), + bootc.config_digest = pull_result.config_digest, + bootc.config_verity = pull_result.config_verity.to_hex(), + "Pulled image into composefs repository", + ); + + Ok(pull_result) } /// skopeo (in composefs-rs) doesn't understand "registry:" @@ -85,6 +98,18 @@ pub(crate) fn get_imgref(transport: &str, image: &str) -> String { } } +/// Result of pulling a composefs repository, including the OCI manifest digest +/// needed to reconstruct image metadata from the local composefs repo. +#[allow(dead_code)] +pub(crate) struct PullRepoResult { + pub(crate) repo: crate::store::ComposefsRepository, + pub(crate) entries: Vec>, + pub(crate) id: Sha512HashValue, + pub(crate) fs: crate::store::ComposefsFilesystem, + /// The OCI manifest content digest (e.g. "sha256:abc...") + pub(crate) manifest_digest: String, +} + /// Pulls the `image` from `transport` into a composefs repository at /sysroot /// Checks for boot entries in the image and returns them #[context("Pulling composefs repository")] @@ -92,12 +117,7 @@ pub(crate) async fn pull_composefs_repo( transport: &String, image: &String, allow_missing_fsverity: bool, -) -> Result<( - crate::store::ComposefsRepository, - Vec>, - Sha512HashValue, - crate::store::ComposefsFilesystem, -)> { +) -> Result { const COMPOSEFS_PULL_JOURNAL_ID: &str = "4c3b2a1f0e9d8c7b6a5f4e3d2c1b0a9f8"; tracing::info!( @@ -120,15 +140,19 @@ pub(crate) async fn pull_composefs_repo( tracing::debug!("Image to pull {final_imgref}"); - let pull_result = composefs_oci_pull(&Arc::new(repo), &final_imgref, None, None) - .await - .context("Pulling composefs repo")?; + let (pull_result, _stats) = + composefs_oci_pull_image(&Arc::new(repo), &final_imgref, None, None) + .await + .context("Pulling composefs repo")?; tracing::info!( message_id = COMPOSEFS_PULL_JOURNAL_ID, - id = pull_result.config_digest, - verity = pull_result.config_verity.to_hex(), - "Pulled image into repository" + bootc.operation = "pull", + bootc.manifest_digest = pull_result.manifest_digest, + bootc.manifest_verity = pull_result.manifest_verity.to_hex(), + bootc.config_digest = pull_result.config_digest, + bootc.config_verity = pull_result.config_verity.to_hex(), + "Pulled image into composefs repository", ); let mut repo = open_composefs_repo(&rootfs_dir)?; @@ -141,7 +165,13 @@ pub(crate) async fn pull_composefs_repo( let entries = fs.transform_for_boot(&repo)?; let id = fs.commit_image(&repo, None)?; - Ok((repo, entries, id, fs)) + Ok(PullRepoResult { + repo, + entries, + id, + fs, + manifest_digest: pull_result.manifest_digest, + }) } #[cfg(test)] diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index 78a3d4717..f092d489d 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -255,7 +255,13 @@ pub(crate) async fn do_upgrade( ) -> Result<()> { start_finalize_stated_svc()?; - let (repo, entries, id, fs) = pull_composefs_repo( + let crate::bootc_composefs::repo::PullRepoResult { + repo, + entries, + id, + fs, + manifest_digest: _, + } = pull_composefs_repo( &imgref.transport, &imgref.image, booted_cfs.cmdline.allow_missing_fsverity, diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 5b412f990..fab8577ae 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -201,8 +201,7 @@ use crate::task::Task; use crate::utils::sigpolicy_from_opt; use bootc_kernel_cmdline::{INITRD_ARG_PREFIX, ROOTFLAGS, bytes, utf8}; use bootc_mount::Filesystem; -use cfsctl::composefs; -use composefs::fsverity::FsVerityHashValue; + /// The toplevel boot directory pub(crate) const BOOT: &str = "boot"; @@ -1959,11 +1958,7 @@ async fn install_to_filesystem_impl( state.composefs_options.allow_missing_verity, ) .await?; - tracing::info!( - "id: {}, verity: {}", - pull_result.config_digest, - pull_result.config_verity.to_hex() - ); + setup_composefs_boot( rootfs, From 215b824406db45de4db0c602370449bba96cf91a Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Sat, 14 Mar 2026 19:53:03 +0000 Subject: [PATCH 3/3] composefs: Use composefs-rs OCI APIs for image metadata and GC Read manifest+config via OciImage::open() instead of .imginfo sidecar files, with fallback for legacy deployments. Create bootc-owned OCI tags as GC roots so the manifest->config->layer chain stays alive through standard composefs-rs reachability. Replace the manual transform_for_boot sequence with generate_boot_image(). The GC flow is now: prune unreferenced bootc tags, then repo.gc(). Assisted-by: OpenCode (Claude claude-opus-4-6) Signed-off-by: Colin Walters --- crates/lib/src/bootc_composefs/boot.rs | 131 ++++++------------ crates/lib/src/bootc_composefs/export.rs | 2 +- crates/lib/src/bootc_composefs/gc.rs | 85 ++++++++++-- crates/lib/src/bootc_composefs/repo.rs | 78 +++++++---- crates/lib/src/bootc_composefs/state.rs | 31 ++--- crates/lib/src/bootc_composefs/status.rs | 98 +++++++------ crates/lib/src/bootc_composefs/switch.rs | 21 +-- crates/lib/src/bootc_composefs/update.rs | 45 ++---- crates/lib/src/composefs_consts.rs | 12 ++ crates/lib/src/install.rs | 4 +- crates/lib/src/store/mod.rs | 2 - .../booted/readonly/030-test-composefs.nu | 10 ++ 12 files changed, 282 insertions(+), 237 deletions(-) diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index adc1b0532..7fb17ee73 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -61,10 +61,10 @@ //! 1. **Primary**: New/upgraded deployment (default boot target) //! 2. **Secondary**: Currently booted deployment (rollback option) -use std::ffi::OsStr; use std::fs::create_dir_all; use std::io::Write; use std::path::Path; +use std::sync::Arc; use anyhow::{Context, Result, anyhow, bail}; use bootc_kernel_cmdline::utf8::{Cmdline, Parameter, ParameterKey}; @@ -81,43 +81,27 @@ use clap::ValueEnum; use composefs::fs::read_file; use composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; use composefs::tree::RegularFile; -use composefs_boot::BootOps; use composefs_boot::bootloader::{ BootEntry as ComposefsBootEntry, EFI_ADDON_DIR_EXT, EFI_ADDON_FILE_EXT, EFI_EXT, PEType, - UsrLibModulesVmlinuz, + UsrLibModulesVmlinuz, get_boot_resources, }; use composefs_boot::{cmdline::get_cmdline_composefs, os_release::OsReleaseInfo, uki}; -use composefs_oci::image::create_filesystem as create_composefs_filesystem; use fn_error_context::context; use rustix::{mount::MountFlags, path::Arg}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use crate::bootc_composefs::state::{get_booted_bls, write_composefs_state}; +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}; use crate::task::Task; -use crate::{ - bootc_composefs::repo::get_imgref, - composefs_consts::{TYPE1_ENT_PATH, TYPE1_ENT_PATH_STAGED}, -}; -use crate::{ - bootc_composefs::repo::open_composefs_repo, - store::{ComposefsFilesystem, Storage}, -}; -use crate::{ - bootc_composefs::state::{get_booted_bls, write_composefs_state}, - composefs_consts::TYPE1_BOOT_DIR_PREFIX, -}; -use crate::{ - bootc_composefs::status::get_container_manifest_and_config, bootc_kargs::compute_new_kargs, -}; +use crate::{bootc_composefs::repo::open_composefs_repo, store::Storage}; use crate::{bootc_composefs::status::get_sorted_grub_uki_boot_entries, install::PostFetchState}; -use crate::{ - composefs_consts::UKI_NAME_PREFIX, - parsers::bls_config::{BLSConfig, BLSConfigType}, -}; use crate::{ composefs_consts::{ BOOT_LOADER_ENTRIES, COMPOSEFS_CMDLINE, ORIGIN_KEY_BOOT, ORIGIN_KEY_BOOT_DIGEST, - STAGED_BOOT_LOADER_ENTRIES, STATE_DIR_ABS, USER_CFG, USER_CFG_STAGED, + STAGED_BOOT_LOADER_ENTRIES, STATE_DIR_ABS, UKI_NAME_PREFIX, USER_CFG, USER_CFG_STAGED, }, spec::{Bootloader, Host}, }; @@ -149,23 +133,9 @@ pub(crate) const BOOTC_UKI_DIR: &str = "EFI/Linux/bootc"; pub(crate) enum BootSetupType<'a> { /// For initial setup, i.e. install to-disk - Setup( - ( - &'a RootSetup, - &'a State, - &'a PostFetchState, - &'a ComposefsFilesystem, - ), - ), + Setup((&'a RootSetup, &'a State, &'a PostFetchState)), /// For `bootc upgrade` - Upgrade( - ( - &'a Storage, - &'a BootedComposefs, - &'a ComposefsFilesystem, - &'a Host, - ), - ), + Upgrade((&'a Storage, &'a BootedComposefs, &'a Host)), } #[derive( @@ -448,41 +418,20 @@ fn write_bls_boot_entries_to_disk( } /// Parses /usr/lib/os-release and returns (id, title, version) -fn parse_os_release( - fs: &crate::store::ComposefsFilesystem, - repo: &crate::store::ComposefsRepository, -) -> Result, Option)>> { +fn parse_os_release(mounted_fs: &Dir) -> Result, Option)>> { // Every update should have its own /usr/lib/os-release - let (dir, fname) = fs - .root - .split(OsStr::new("/usr/lib/os-release")) - .context("Getting /usr/lib/os-release")?; - - let os_release = dir - .get_file_opt(fname) - .context("Getting /usr/lib/os-release")?; - - let Some(os_rel_file) = os_release else { - return Ok(None); - }; - - let file_contents = match read_file(os_rel_file, repo) { + let file_contents = match mounted_fs.read_to_string("usr/lib/os-release") { Ok(c) => c, - Err(e) => { - tracing::warn!("Could not read /usr/lib/os-release: {e:?}"); + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { return Ok(None); } - }; - - let file_contents = match std::str::from_utf8(&file_contents) { - Ok(c) => c, Err(e) => { - tracing::warn!("/usr/lib/os-release did not have valid UTF-8: {e}"); + tracing::warn!("Could not read /usr/lib/os-release: {e:?}"); return Ok(None); } }; - let parsed = OsReleaseInfo::parse(file_contents); + let parsed = OsReleaseInfo::parse(&file_contents); let os_id = parsed .get_value(&["ID"]) @@ -518,8 +467,8 @@ pub(crate) fn setup_composefs_bls_boot( ) -> Result { let id_hex = id.to_hex(); - let (root_path, esp_device, mut cmdline_refs, fs, bootloader) = match setup_type { - BootSetupType::Setup((root_setup, state, postfetch, fs)) => { + let (root_path, esp_device, mut cmdline_refs, bootloader) = match setup_type { + BootSetupType::Setup((root_setup, state, postfetch)) => { // root_setup.kargs has [root=UUID=, "rw"] let mut cmdline_options = Cmdline::new(); @@ -541,12 +490,11 @@ pub(crate) fn setup_composefs_bls_boot( root_setup.physical_root_path.clone(), esp_part.path(), cmdline_options, - fs, postfetch.detected_bootloader.clone(), ) } - BootSetupType::Upgrade((storage, booted_cfs, fs, host)) => { + BootSetupType::Upgrade((storage, booted_cfs, host)) => { let bootloader = host.require_composefs_booted()?.bootloader.clone(); let boot_dir = storage.require_boot_dir()?; @@ -583,7 +531,6 @@ pub(crate) fn setup_composefs_bls_boot( Utf8PathBuf::from("/sysroot"), esp_dev.path(), cmdline, - fs, bootloader, ) } @@ -657,7 +604,7 @@ pub(crate) fn setup_composefs_bls_boot( let boot_digest = compute_boot_digest(usr_lib_modules_vmlinuz, &repo) .context("Computing boot digest")?; - let osrel = parse_os_release(fs, &repo)?; + let osrel = parse_os_release(mounted_erofs)?; let (os_id, title, version, sort_key) = match osrel { Some((id_str, title_opt, version_opt)) => ( @@ -1102,7 +1049,7 @@ pub(crate) fn setup_composefs_uki_boot( ) -> Result { let (root_path, esp_device, bootloader, missing_fsverity_allowed, uki_addons) = match setup_type { - BootSetupType::Setup((root_setup, state, postfetch, ..)) => { + BootSetupType::Setup((root_setup, state, postfetch)) => { state.require_no_kargs_for_uki()?; let esp_part = root_setup.device_info.find_partition_of_esp()?; @@ -1116,7 +1063,7 @@ pub(crate) fn setup_composefs_uki_boot( ) } - BootSetupType::Upgrade((storage, booted_cfs, _, host)) => { + BootSetupType::Upgrade((storage, booted_cfs, host)) => { let sysroot = Utf8PathBuf::from("/sysroot"); // Still needed for root_path let bootloader = host.require_composefs_booted()?.bootloader.clone(); @@ -1259,7 +1206,7 @@ fn get_secureboot_keys(fs: &Dir, p: &str) -> Result> { pub(crate) async fn setup_composefs_boot( root_setup: &RootSetup, state: &State, - image_id: &str, + pull_result: &composefs_oci::skopeo::PullResult, allow_missing_fsverity: bool, ) -> Result<()> { const COMPOSEFS_BOOT_SETUP_JOURNAL_ID: &str = "1f0e9d8c7b6a5f4e3d2c1b0a9f8e7d6c5"; @@ -1267,7 +1214,7 @@ pub(crate) async fn setup_composefs_boot( tracing::info!( message_id = COMPOSEFS_BOOT_SETUP_JOURNAL_ID, bootc.operation = "boot_setup", - bootc.image_id = image_id, + bootc.config_digest = pull_result.config_digest, bootc.allow_missing_fsverity = allow_missing_fsverity, "Setting up composefs boot", ); @@ -1275,9 +1222,18 @@ pub(crate) async fn setup_composefs_boot( let mut repo = open_composefs_repo(&root_setup.physical_root)?; repo.set_insecure(allow_missing_fsverity); - let mut fs = create_composefs_filesystem(&repo, image_id, None)?; - let entries = fs.transform_for_boot(&repo)?; - let id = fs.commit_image(&repo, None)?; + let repo = Arc::new(repo); + + // Generate the bootable EROFS image (idempotent). + let id = composefs_oci::generate_boot_image(&repo, &pull_result.manifest_digest) + .context("Generating bootable EROFS image")?; + + // Get boot entries from the OCI filesystem (untransformed). + let fs = composefs_oci::image::create_filesystem(&*repo, &pull_result.config_digest, None) + .context("Creating composefs filesystem for boot entry discovery")?; + let entries = + get_boot_resources(&fs, &*repo).context("Extracting boot entries from OCI image")?; + let mounted_fs = Dir::reopen_dir( &repo .mount(&id.to_hex()) @@ -1320,16 +1276,23 @@ pub(crate) async fn setup_composefs_boot( let boot_type = BootType::from(entry); + // Unwrap Arc to pass owned repo to boot setup functions. + let repo = Arc::try_unwrap(repo).map_err(|_| { + anyhow::anyhow!( + "BUG: Arc still has other references after boot image generation" + ) + })?; + let boot_digest = match boot_type { BootType::Bls => setup_composefs_bls_boot( - BootSetupType::Setup((&root_setup, &state, &postfetch, &fs)), + BootSetupType::Setup((&root_setup, &state, &postfetch)), repo, &id, entry, &mounted_fs, )?, BootType::Uki => setup_composefs_uki_boot( - BootSetupType::Setup((&root_setup, &state, &postfetch, &fs)), + BootSetupType::Setup((&root_setup, &state, &postfetch)), repo, &id, entries, @@ -1343,11 +1306,7 @@ pub(crate) async fn setup_composefs_boot( None, boot_type, boot_digest, - &get_container_manifest_and_config(&get_imgref( - &state.source.imageref.transport.to_string(), - &state.source.imageref.name, - )) - .await?, + &pull_result.manifest_digest, allow_missing_fsverity, ) .await?; diff --git a/crates/lib/src/bootc_composefs/export.rs b/crates/lib/src/bootc_composefs/export.rs index 9d882231b..d767c03f1 100644 --- a/crates/lib/src/bootc_composefs/export.rs +++ b/crates/lib/src/bootc_composefs/export.rs @@ -43,7 +43,7 @@ pub async fn export_repo_to_image( let depl_verity = depl_verity.ok_or_else(|| anyhow::anyhow!("Image {source} not found"))?; - let imginfo = get_imginfo(storage, &depl_verity, None).await?; + let imginfo = get_imginfo(storage, &depl_verity)?; // We want the digest in the form of "sha256:abc123" let config_digest = format!("{}", imginfo.manifest.config().digest()); diff --git a/crates/lib/src/bootc_composefs/gc.rs b/crates/lib/src/bootc_composefs/gc.rs index f67d4becc..780c41c35 100644 --- a/crates/lib/src/bootc_composefs/gc.rs +++ b/crates/lib/src/bootc_composefs/gc.rs @@ -8,6 +8,7 @@ use anyhow::{Context, Result}; use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt}; use cfsctl::composefs; use cfsctl::composefs_boot; +use cfsctl::composefs_oci; use composefs::repository::GcResult; use composefs_boot::bootloader::EFI_EXT; @@ -15,9 +16,14 @@ use crate::{ bootc_composefs::{ boot::{BOOTC_UKI_DIR, BootType, get_type1_dir_name, get_uki_addon_dir_name, get_uki_name}, delete::{delete_image, delete_staged, delete_state_dir}, - status::{get_composefs_status, get_imginfo, list_bootloader_entries}, + repo::bootc_tag_for_manifest, + state::read_origin, + status::{get_composefs_status, list_bootloader_entries}, + }, + composefs_consts::{ + BOOTC_TAG_PREFIX, ORIGIN_KEY_IMAGE, ORIGIN_KEY_MANIFEST_DIGEST, STATE_DIR_RELATIVE, + TYPE1_BOOT_DIR_PREFIX, UKI_NAME_PREFIX, }, - composefs_consts::{STATE_DIR_RELATIVE, TYPE1_BOOT_DIR_PREFIX, UKI_NAME_PREFIX}, store::{BootedComposefs, Storage}, }; @@ -302,22 +308,77 @@ pub(crate) async fn composefs_gc( delete_state_dir(&sysroot, verity, dry_run)?; } - // Now we GC the unrefenced objects in composefs repo - let mut additional_roots = vec![]; + // 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. + let mut live_manifest_digests = Vec::new(); + let mut additional_roots = Vec::new(); for deployment in host.list_deployments() { let verity = &deployment.require_composefs()?.verity; - // These need to be GC'd + // Skip deployments that are already being GC'd. if img_bootloader_diff.contains(&verity) || state_img_diff.contains(&verity) { continue; } - let image = get_imginfo(storage, verity, None).await?; - let stream = format!("oci-config-{}", image.manifest.config().digest()); - + // Keep the EROFS image as an additional root until all deployments + // 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()); - additional_roots.push(stream); + + if let Some(ini) = read_origin(sysroot, verity)? { + if let Some(manifest_digest) = + ini.get::(ORIGIN_KEY_IMAGE, ORIGIN_KEY_MANIFEST_DIGEST) + { + live_manifest_digests.push(manifest_digest); + } else { + tracing::warn!( + "Deployment {verity} has no manifest_digest in origin; \ + OCI manifest/config metadata is unprotected from GC" + ); + } + } + } + + // Migration: ensure every live deployment has a bootc-owned tag. + // Deployments from before the tag-based GC won't have tags yet; + // create them now so their OCI metadata survives this GC cycle. + let existing_tags = composefs_oci::list_refs(&*booted_cfs.repo) + .context("Listing OCI tags in composefs repo")?; + + for manifest_digest in &live_manifest_digests { + let expected_tag = bootc_tag_for_manifest(manifest_digest); + let has_tag = existing_tags + .iter() + .any(|(tag_name, _)| tag_name == &expected_tag); + if !has_tag { + tracing::info!("Creating missing bootc tag for live deployment: {expected_tag}"); + if !dry_run { + composefs_oci::tag_image(&*booted_cfs.repo, manifest_digest, &expected_tag) + .with_context(|| format!("Creating migration tag {expected_tag}"))?; + } + } + } + + // Re-read tags after potential migration. + let all_tags = composefs_oci::list_refs(&*booted_cfs.repo) + .context("Listing OCI tags in composefs repo")?; + + for (tag_name, manifest_digest) in &all_tags { + if !tag_name.starts_with(BOOTC_TAG_PREFIX) { + // Not a bootc-owned tag; leave it alone (could be an app image). + continue; + } + + if !live_manifest_digests.iter().any(|d| d == manifest_digest) { + tracing::debug!("Removing unreferenced bootc tag: {tag_name}"); + if !dry_run { + composefs_oci::untag_image(&*booted_cfs.repo, tag_name) + .with_context(|| format!("Removing tag {tag_name}"))?; + } + } } let additional_roots = additional_roots @@ -325,7 +386,11 @@ pub(crate) async fn composefs_gc( .map(|x| x.as_str()) .collect::>(); - // Run garbage collection on objects after deleting images + // Run garbage collection. Tags root the OCI metadata chain + // (manifest → config → layers). The additional_roots protect EROFS + // images for deployments that predate the manifest→image link; + // once all deployments have been pulled with the new code, these + // become redundant. let gc_result = if dry_run { booted_cfs.repo.gc_dry_run(&additional_roots)? } else { diff --git a/crates/lib/src/bootc_composefs/repo.rs b/crates/lib/src/bootc_composefs/repo.rs index 359d6a067..f6df2d203 100644 --- a/crates/lib/src/bootc_composefs/repo.rs +++ b/crates/lib/src/bootc_composefs/repo.rs @@ -7,18 +7,28 @@ use cfsctl::composefs; use cfsctl::composefs_boot; use cfsctl::composefs_oci; use composefs::fsverity::{FsVerityHashValue, Sha512HashValue}; -use composefs_boot::{BootOps, bootloader::BootEntry as ComposefsBootEntry}; +use composefs_boot::bootloader::{BootEntry as ComposefsBootEntry, get_boot_resources}; use composefs_oci::{ image::create_filesystem as create_composefs_filesystem, - pull_image as composefs_oci_pull_image, skopeo::PullResult, + pull_image as composefs_oci_pull_image, skopeo::PullResult, tag_image, }; use ostree_ext::container::ImageReference as OstreeExtImgRef; use cap_std_ext::cap_std::{ambient_authority, fs::Dir}; +use crate::composefs_consts::BOOTC_TAG_PREFIX; use crate::install::{RootSetup, State}; +/// Create a composefs OCI tag name for the given manifest digest. +/// +/// Returns a tag like `localhost/bootc-sha256:abc...` which acts as a GC root +/// in the composefs repository, keeping the manifest, config, and all layer +/// splitstreams alive. +pub(crate) fn bootc_tag_for_manifest(manifest_digest: &str) -> String { + format!("{BOOTC_TAG_PREFIX}{manifest_digest}") +} + pub(crate) fn open_composefs_repo(rootfs_dir: &Dir) -> Result { crate::store::ComposefsRepository::open_path(rootfs_dir, "composefs") .context("Failed to open composefs repository") @@ -56,14 +66,16 @@ pub(crate) async fn initialize_composefs_repository( transport, } = &state.source.imageref; - // transport's display is already of type ":" - let (pull_result, _stats) = composefs_oci_pull_image( - &Arc::new(repo), - &format!("{transport}{image_name}"), - None, - None, - ) - .await?; + // Pull without a reference tag; we tag explicitly afterward so we + // control the tag name format. + let repo = Arc::new(repo); + let (pull_result, _stats) = + composefs_oci_pull_image(&repo, &format!("{transport}{image_name}"), None, None).await?; + + // Tag the manifest as a bootc-owned GC root. + let tag = bootc_tag_for_manifest(&pull_result.manifest_digest); + tag_image(&*repo, &pull_result.manifest_digest, &tag) + .context("Tagging pulled image as bootc GC root")?; tracing::info!( message_id = COMPOSEFS_REPO_INIT_JOURNAL_ID, @@ -72,6 +84,7 @@ pub(crate) async fn initialize_composefs_repository( bootc.manifest_verity = pull_result.manifest_verity.to_hex(), bootc.config_digest = pull_result.config_digest, bootc.config_verity = pull_result.config_verity.to_hex(), + bootc.tag = tag, "Pulled image into composefs repository", ); @@ -100,12 +113,10 @@ pub(crate) fn get_imgref(transport: &str, image: &str) -> String { /// Result of pulling a composefs repository, including the OCI manifest digest /// needed to reconstruct image metadata from the local composefs repo. -#[allow(dead_code)] pub(crate) struct PullRepoResult { pub(crate) repo: crate::store::ComposefsRepository, pub(crate) entries: Vec>, pub(crate) id: Sha512HashValue, - pub(crate) fs: crate::store::ComposefsFilesystem, /// The OCI manifest content digest (e.g. "sha256:abc...") pub(crate) manifest_digest: String, } @@ -140,10 +151,15 @@ pub(crate) async fn pull_composefs_repo( tracing::debug!("Image to pull {final_imgref}"); - let (pull_result, _stats) = - composefs_oci_pull_image(&Arc::new(repo), &final_imgref, None, None) - .await - .context("Pulling composefs repo")?; + let repo = Arc::new(repo); + let (pull_result, _stats) = composefs_oci_pull_image(&repo, &final_imgref, None, None) + .await + .context("Pulling composefs repo")?; + + // Tag the manifest as a bootc-owned GC root. + let tag = bootc_tag_for_manifest(&pull_result.manifest_digest); + tag_image(&*repo, &pull_result.manifest_digest, &tag) + .context("Tagging pulled image as bootc GC root")?; tracing::info!( message_id = COMPOSEFS_PULL_JOURNAL_ID, @@ -152,24 +168,30 @@ pub(crate) async fn pull_composefs_repo( bootc.manifest_verity = pull_result.manifest_verity.to_hex(), bootc.config_digest = pull_result.config_digest, bootc.config_verity = pull_result.config_verity.to_hex(), + bootc.tag = tag, "Pulled image into composefs repository", ); - let mut repo = open_composefs_repo(&rootfs_dir)?; - repo.set_insecure(allow_missing_fsverity); + // Generate the bootable EROFS image (idempotent). + let id = composefs_oci::generate_boot_image(&repo, &pull_result.manifest_digest) + .context("Generating bootable EROFS image")?; - let mut fs: crate::store::ComposefsFilesystem = - create_composefs_filesystem(&repo, &pull_result.config_digest, None) - .context("Failed to create composefs filesystem")?; + // Get boot entries from the OCI filesystem (untransformed). + let fs = create_composefs_filesystem(&*repo, &pull_result.config_digest, None) + .context("Creating composefs filesystem for boot entry discovery")?; + let entries = + get_boot_resources(&fs, &*repo).context("Extracting boot entries from OCI image")?; - let entries = fs.transform_for_boot(&repo)?; - let id = fs.commit_image(&repo, None)?; + // Unwrap the Arc to get the owned repo back. + let mut repo = Arc::try_unwrap(repo).map_err(|_| { + anyhow::anyhow!("BUG: Arc still has other references after pull completed") + })?; + repo.set_insecure(allow_missing_fsverity); Ok(PullRepoResult { repo, entries, id, - fs, manifest_digest: pull_result.manifest_digest, }) } @@ -216,4 +238,12 @@ mod tests { format!("docker-daemon:{IMAGE_NAME}") ); } + + #[test] + fn test_bootc_tag_for_manifest() { + let digest = "sha256:abc123def456"; + let tag = bootc_tag_for_manifest(digest); + assert_eq!(tag, "localhost/bootc-sha256:abc123def456"); + assert!(tag.starts_with(BOOTC_TAG_PREFIX)); + } } diff --git a/crates/lib/src/bootc_composefs/state.rs b/crates/lib/src/bootc_composefs/state.rs index ebdd69c1f..18f653f43 100644 --- a/crates/lib/src/bootc_composefs/state.rs +++ b/crates/lib/src/bootc_composefs/state.rs @@ -27,16 +27,14 @@ use rustix::{ use crate::bootc_composefs::boot::BootType; use crate::bootc_composefs::repo::get_imgref; -use crate::bootc_composefs::status::{ - ImgConfigManifest, StagedDeployment, get_sorted_type1_boot_entries, -}; +use crate::bootc_composefs::status::{StagedDeployment, get_sorted_type1_boot_entries}; use crate::parsers::bls_config::BLSConfigType; use crate::store::{BootedComposefs, Storage}; use crate::{ composefs_consts::{ COMPOSEFS_CMDLINE, COMPOSEFS_STAGED_DEPLOYMENT_FNAME, COMPOSEFS_TRANSIENT_STATE_DIR, - ORIGIN_KEY_BOOT, ORIGIN_KEY_BOOT_DIGEST, ORIGIN_KEY_BOOT_TYPE, SHARED_VAR_PATH, - STATE_DIR_RELATIVE, + ORIGIN_KEY_BOOT, ORIGIN_KEY_BOOT_DIGEST, ORIGIN_KEY_BOOT_TYPE, ORIGIN_KEY_IMAGE, + ORIGIN_KEY_MANIFEST_DIGEST, SHARED_VAR_PATH, STATE_DIR_RELATIVE, }, parsers::bls_config::BLSConfig, spec::ImageReference, @@ -244,15 +242,15 @@ pub(crate) fn update_boot_digest_in_origin( /// * `staged` - Whether this is a staged deployment (writes to transient state dir) /// * `boot_type` - Boot loader type (`Bls` or `Uki`) /// * `boot_digest` - Optional boot digest for verification -/// * `container_details` - Container manifest and config used to create this deployment +/// * `manifest_digest` - OCI manifest content digest, stored in the origin file so the +/// manifest+config can be retrieved from the composefs repo later /// /// # State Directory Structure /// /// Creates the following structure under `/sysroot/state/deploy/{deployment_id}/`: /// * `etc/` - Copy of system configuration files /// * `var` - Symlink to shared `/var` directory -/// * `{deployment_id}.origin` - OSTree-style origin configuration -/// * `{deployment_id}.imginfo` - Container image manifest and config as JSON +/// * `{deployment_id}.origin` - Origin configuration with image ref, boot, and image metadata /// /// For staged deployments, also writes to `/run/composefs/staged-deployment`. #[context("Writing composefs state")] @@ -263,7 +261,7 @@ pub(crate) async fn write_composefs_state( staged: Option, boot_type: BootType, boot_digest: String, - container_details: &ImgConfigManifest, + manifest_digest: &str, allow_missing_fsverity: bool, ) -> Result<()> { let state_path = root_path @@ -312,18 +310,15 @@ pub(crate) async fn write_composefs_state( .section(ORIGIN_KEY_BOOT) .item(ORIGIN_KEY_BOOT_DIGEST, boot_digest); + // Store the OCI manifest digest so we can retrieve the manifest+config + // from the composefs repository later (composefs-rs stores them as splitstreams). + config = config + .section(ORIGIN_KEY_IMAGE) + .item(ORIGIN_KEY_MANIFEST_DIGEST, manifest_digest); + let state_dir = Dir::open_ambient_dir(&state_path, ambient_authority()).context("Opening state dir")?; - // NOTE: This is only supposed to be temporary until we decide on where to store - // the container manifest/config - state_dir - .atomic_write( - format!("{}.imginfo", deployment_id.to_hex()), - serde_json::to_vec(&container_details)?, - ) - .context("Failed to write to .imginfo file")?; - state_dir .atomic_write( format!("{}.origin", deployment_id.to_hex()), diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index 24cd4cc9a..a88d86171 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -3,20 +3,21 @@ use std::{collections::HashSet, io::Read, sync::OnceLock}; use anyhow::{Context, Result}; use bootc_kernel_cmdline::utf8::Cmdline; use bootc_mount::inspect_filesystem; +use cfsctl::composefs::fsverity::Sha512HashValue; +use cfsctl::composefs_oci::OciImage; use fn_error_context::context; use serde::{Deserialize, Serialize}; use crate::{ bootc_composefs::{ boot::BootType, - repo::get_imgref, selinux::are_selinux_policies_compatible, state::{get_composefs_usr_overlay_status, read_origin}, utils::{compute_store_boot_digest_for_uki, get_uki_cmdline}, }, composefs_consts::{ - COMPOSEFS_CMDLINE, ORIGIN_KEY_BOOT_DIGEST, TYPE1_ENT_PATH, TYPE1_ENT_PATH_STAGED, USER_CFG, - USER_CFG_STAGED, + COMPOSEFS_CMDLINE, ORIGIN_KEY_BOOT_DIGEST, ORIGIN_KEY_IMAGE, ORIGIN_KEY_MANIFEST_DIGEST, + TYPE1_ENT_PATH, TYPE1_ENT_PATH_STAGED, USER_CFG, USER_CFG_STAGED, }, install::EFI_LOADER_INFO, parsers::{ @@ -358,57 +359,63 @@ pub(crate) fn get_bootloader() -> Result { } } -/// Reads the .imginfo file for the provided deployment -#[context("Reading imginfo")] -pub(crate) async fn get_imginfo( - storage: &Storage, - deployment_id: &str, - imgref: Option<&ImageReference>, -) -> Result { - let imginfo_fname = format!("{deployment_id}.imginfo"); +/// Retrieves the OCI manifest and config for a deployment from the composefs repository. +/// +/// The manifest digest is read from the deployment's `.origin` file, +/// then `OciImage::open()` retrieves manifest+config from the composefs repo +/// where composefs-rs stores them as splitstreams during pull. +/// +/// Falls back to reading legacy `.imginfo` files for backwards compatibility +/// with deployments created before the manifest digest was stored in `.origin`. +#[context("Reading image info for deployment {deployment_id}")] +pub(crate) fn get_imginfo(storage: &Storage, deployment_id: &str) -> Result { + let ini = read_origin(&storage.physical_root, deployment_id)? + .ok_or_else(|| anyhow::anyhow!("No origin file for deployment {deployment_id}"))?; + + // Try to read the manifest digest from the origin file (new path) + if let Some(manifest_digest) = ini.get::(ORIGIN_KEY_IMAGE, ORIGIN_KEY_MANIFEST_DIGEST) { + let repo = storage.get_ensure_composefs()?; + let oci_image = OciImage::::open(&repo, &manifest_digest, None) + .with_context(|| format!("Opening OCI image for manifest {manifest_digest}"))?; + + let manifest = oci_image.manifest().clone(); + let config = oci_image + .config() + .cloned() + .ok_or_else(|| anyhow::anyhow!("OCI image has no config (artifact?)"))?; + + return Ok(ImgConfigManifest { config, manifest }); + } + // Fallback: read legacy .imginfo file for deployments created before + // the manifest digest was stored in .origin let depl_state_path = std::path::PathBuf::from(STATE_DIR_RELATIVE).join(deployment_id); - let path = depl_state_path.join(imginfo_fname); + let imginfo_fname = format!("{deployment_id}.imginfo"); + let path = depl_state_path.join(&imginfo_fname); let mut img_conf = storage .physical_root .open_optional(&path) - .context("Failed to open file")?; + .with_context(|| format!("Opening legacy {imginfo_fname}"))?; let Some(img_conf) = &mut img_conf else { - let imgref = imgref.ok_or_else(|| anyhow::anyhow!("No imgref or imginfo file found"))?; - - let container_details = - get_container_manifest_and_config(&get_imgref(&imgref.transport, &imgref.image)) - .await?; - - let state_dir = storage.physical_root.open_dir(depl_state_path)?; - - state_dir - .atomic_write( - format!("{}.imginfo", deployment_id), - serde_json::to_vec(&container_details)?, - ) - .context("Failed to write to .imginfo file")?; - - let state_dir = state_dir.reopen_as_ownedfd()?; - - rustix::fs::fsync(state_dir).context("fsync")?; - - return Ok(container_details); + anyhow::bail!( + "No manifest_digest in origin and no legacy .imginfo file \ + for deployment {deployment_id}" + ); }; let mut buffer = String::new(); img_conf.read_to_string(&mut buffer)?; let img_conf = serde_json::from_str::(&buffer) - .context("Failed to parse file as JSON")?; + .context("Failed to parse .imginfo file as JSON")?; Ok(img_conf) } #[context("Getting composefs deployment metadata")] -async fn boot_entry_from_composefs_deployment( +fn boot_entry_from_composefs_deployment( storage: &Storage, origin: tini::Ini, verity: &str, @@ -418,7 +425,7 @@ async fn boot_entry_from_composefs_deployment( let ostree_img_ref = OstreeImageReference::from_str(&img_name_from_config)?; let img_ref = ImageReference::from(ostree_img_ref); - let img_conf = get_imginfo(storage, &verity, Some(&img_ref)).await?; + let img_conf = get_imginfo(storage, &verity)?; let image_digest = img_conf.manifest.config().digest().to_string(); let architecture = img_conf.config.architecture().to_string(); @@ -699,6 +706,11 @@ async fn composefs_deployment_status_from( // This is our source of truth let bootloader_entry_verity = list_bootloader_entries(storage)?; + let state_dir = storage + .physical_root + .open_dir(STATE_DIR_RELATIVE) + .with_context(|| format!("Opening {STATE_DIR_RELATIVE}"))?; + let host_spec = HostSpec { image: None, boot_order: BootOrder::Default, @@ -727,11 +739,17 @@ async fn composefs_deployment_status_from( let mut extra_deployment_boot_entries: Vec = Vec::new(); for verity_digest in bootloader_entry_verity { - let ini = read_origin(&storage.physical_root, &verity_digest)? - .ok_or_else(|| anyhow::anyhow!("No origin file for deployment {verity_digest}"))?; + // read the origin file + let config = state_dir + .open_dir(&verity_digest) + .with_context(|| format!("Failed to open {verity_digest}"))? + .read_to_string(format!("{verity_digest}.origin")) + .with_context(|| format!("Reading file {verity_digest}.origin"))?; + + let ini = tini::Ini::from_string(&config) + .with_context(|| format!("Failed to parse file {verity_digest}.origin as ini"))?; - let mut boot_entry = - boot_entry_from_composefs_deployment(storage, ini, &verity_digest).await?; + let mut boot_entry = boot_entry_from_composefs_deployment(storage, ini, &verity_digest)?; // SAFETY: boot_entry.composefs will always be present let boot_type_from_origin = boot_entry.composefs.as_ref().unwrap().boot_type; diff --git a/crates/lib/src/bootc_composefs/switch.rs b/crates/lib/src/bootc_composefs/switch.rs index e44bc478d..b6ce55ad5 100644 --- a/crates/lib/src/bootc_composefs/switch.rs +++ b/crates/lib/src/bootc_composefs/switch.rs @@ -75,15 +75,8 @@ pub(crate) async fn switch_composefs( } UpdateAction::Proceed => { - return do_upgrade( - storage, - booted_cfs, - &host, - &target_imgref, - &img_config, - &do_upgrade_opts, - ) - .await; + return do_upgrade(storage, booted_cfs, &host, &target_imgref, &do_upgrade_opts) + .await; } UpdateAction::UpdateOrigin => { @@ -95,15 +88,7 @@ pub(crate) async fn switch_composefs( } } - do_upgrade( - storage, - booted_cfs, - &host, - &target_imgref, - &img_config, - &do_upgrade_opts, - ) - .await?; + do_upgrade(storage, booted_cfs, &host, &target_imgref, &do_upgrade_opts).await?; Ok(()) } diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index f092d489d..f9f6d21db 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -250,7 +250,6 @@ pub(crate) async fn do_upgrade( booted_cfs: &BootedComposefs, host: &Host, imgref: &ImageReference, - img_manifest_config: &ImgConfigManifest, opts: &DoUpgradeOpts, ) -> Result<()> { start_finalize_stated_svc()?; @@ -259,8 +258,7 @@ pub(crate) async fn do_upgrade( repo, entries, id, - fs, - manifest_digest: _, + manifest_digest, } = pull_composefs_repo( &imgref.transport, &imgref.image, @@ -282,7 +280,7 @@ pub(crate) async fn do_upgrade( let boot_digest = match boot_type { BootType::Bls => setup_composefs_bls_boot( - BootSetupType::Upgrade((storage, booted_cfs, &fs, &host)), + BootSetupType::Upgrade((storage, booted_cfs, &host)), repo, &id, entry, @@ -290,7 +288,7 @@ pub(crate) async fn do_upgrade( )?, BootType::Uki => setup_composefs_uki_boot( - BootSetupType::Upgrade((storage, booted_cfs, &fs, &host)), + BootSetupType::Upgrade((storage, booted_cfs, &host)), repo, &id, entries, @@ -307,7 +305,7 @@ pub(crate) async fn do_upgrade( }), boot_type, boot_digest, - img_manifest_config, + &manifest_digest, booted_cfs.cmdline.allow_missing_fsverity, ) .await?; @@ -443,15 +441,8 @@ pub(crate) async fn upgrade_composefs( } UpdateAction::Proceed => { - return do_upgrade( - storage, - composefs, - &host, - booted_imgref, - &img_config, - &do_upgrade_opts, - ) - .await; + return do_upgrade(storage, composefs, &host, booted_imgref, &do_upgrade_opts) + .await; } UpdateAction::UpdateOrigin => { @@ -479,15 +470,8 @@ pub(crate) async fn upgrade_composefs( } UpdateAction::Proceed => { - return do_upgrade( - storage, - composefs, - &host, - booted_imgref, - &img_config, - &do_upgrade_opts, - ) - .await; + return do_upgrade(storage, composefs, &host, booted_imgref, &do_upgrade_opts) + .await; } UpdateAction::UpdateOrigin => { @@ -497,22 +481,13 @@ pub(crate) async fn upgrade_composefs( } if opts.check { - let current_manifest = - get_imginfo(storage, &*composefs.cmdline.digest, Some(booted_imgref)).await?; + let current_manifest = get_imginfo(storage, &*composefs.cmdline.digest)?; let diff = ManifestDiff::new(¤t_manifest.manifest, &img_config.manifest); diff.print(); return Ok(()); } - do_upgrade( - storage, - composefs, - &host, - booted_imgref, - &img_config, - &do_upgrade_opts, - ) - .await?; + do_upgrade(storage, composefs, &host, booted_imgref, &do_upgrade_opts).await?; Ok(()) } diff --git a/crates/lib/src/composefs_consts.rs b/crates/lib/src/composefs_consts.rs index aca8b1510..8617f1005 100644 --- a/crates/lib/src/composefs_consts.rs +++ b/crates/lib/src/composefs_consts.rs @@ -20,6 +20,11 @@ pub(crate) const ORIGIN_KEY_BOOT_TYPE: &str = "boot_type"; /// Key to store the SHA256 sum of vmlinuz + initrd for a deployment pub(crate) const ORIGIN_KEY_BOOT_DIGEST: &str = "digest"; +/// Section in .origin file to store OCI image metadata +pub(crate) const ORIGIN_KEY_IMAGE: &str = "image"; +/// Key to store the OCI manifest digest (e.g. "sha256:abc...") +pub(crate) const ORIGIN_KEY_MANIFEST_DIGEST: &str = "manifest_digest"; + /// Filename for `loader/entries` pub(crate) const BOOT_LOADER_ENTRIES: &str = "entries"; /// Filename for staged boot loader entries @@ -42,3 +47,10 @@ pub(crate) const TYPE1_BOOT_DIR_PREFIX: &str = "bootc_composefs-"; /// The prefix for names of UKI and UKI Addons pub(crate) const UKI_NAME_PREFIX: &str = TYPE1_BOOT_DIR_PREFIX; + +/// Prefix for OCI tags owned by bootc in the composefs repository. +/// +/// Tags are created as `localhost/bootc-` to act as GC roots +/// that keep the manifest, config, and layer splitstreams alive. This is +/// analogous to how ostree uses `ostree/` refs. +pub(crate) const BOOTC_TAG_PREFIX: &str = "localhost/bootc-"; diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index fab8577ae..80a6cea24 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -202,7 +202,6 @@ use crate::utils::sigpolicy_from_opt; use bootc_kernel_cmdline::{INITRD_ARG_PREFIX, ROOTFLAGS, bytes, utf8}; use bootc_mount::Filesystem; - /// The toplevel boot directory pub(crate) const BOOT: &str = "boot"; /// Directory for transient runtime state @@ -1959,11 +1958,10 @@ async fn install_to_filesystem_impl( ) .await?; - setup_composefs_boot( rootfs, state, - &pull_result.config_digest, + &pull_result, state.composefs_options.allow_missing_verity, ) .await?; diff --git a/crates/lib/src/store/mod.rs b/crates/lib/src/store/mod.rs index 8fd09d826..6148022f3 100644 --- a/crates/lib/src/store/mod.rs +++ b/crates/lib/src/store/mod.rs @@ -48,8 +48,6 @@ use crate::utils::{deployment_fd, open_dir_remount_rw}; /// See pub type ComposefsRepository = composefs::repository::Repository; -/// A composefs filesystem type alias -pub type ComposefsFilesystem = composefs::tree::FileSystem; /// Path to the physical root pub const SYSROOT: &str = "sysroot"; diff --git a/tmt/tests/booted/readonly/030-test-composefs.nu b/tmt/tests/booted/readonly/030-test-composefs.nu index 82b0d57b7..0a7c8c53d 100644 --- a/tmt/tests/booted/readonly/030-test-composefs.nu +++ b/tmt/tests/booted/readonly/030-test-composefs.nu @@ -35,6 +35,16 @@ if $is_composefs { # When already on composefs, we can only test read-only operations print "# TODO composefs: skipping pull test - cfs oci pull requires write access to sysroot" bootc internals cfs --help + + # Verify that GC on a freshly booted system would not prune any + # images or streams. This validates that our OCI tags and + # manifest→image refs correctly root the entire chain. + # Note: a small number of orphaned objects is expected (e.g. from + # manifest splitstream rewrites) and is harmless. + print "# Verifying composefs GC dry-run does not prune images or streams" + let gc_output = (bootc internals composefs-gc --dry-run) + print $gc_output + assert (not ($gc_output | str contains "Pruned symlinks")) "GC dry-run should not prune any images or streams on a freshly booted system" } else { # When not on composefs, run the full test including initialization bootc internals test-composefs