From cad0c036c9d6970a8eb73e7e798c568ee0851428 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Fri, 3 Apr 2026 13:00:51 +0000 Subject: [PATCH] status: Add --sysroot flag to query non-booted sysroots Add `bootc status --sysroot ` to query the deployment state of an sysroot at an arbitrary path. This is designed for install tooling (Anaconda, osbuild, custom installers) that needs to discover deployment metadata immediately after installation without rebooting. The returned Host uses the same schema as `bootc status --json`. Since the target sysroot is not the running system, `booted` and `staged` are null -- all deployments appear in `otherDeployments`, ordered by priority (first entry is what will boot next). Assisted-by: https://github.com/cgwalters/cgwalters#llms Signed-off-by: Colin Walters --- .../backwards_compat/bcompat_boot.rs | 7 +- crates/lib/src/bootc_composefs/boot.rs | 4 +- crates/lib/src/bootc_composefs/delete.rs | 2 +- crates/lib/src/bootc_composefs/finalize.rs | 2 +- crates/lib/src/bootc_composefs/gc.rs | 32 +++-- crates/lib/src/bootc_composefs/rollback.rs | 10 +- crates/lib/src/bootc_composefs/state.rs | 4 +- crates/lib/src/bootc_composefs/status.rs | 128 +++++++++++++++--- crates/lib/src/cli.rs | 10 +- crates/lib/src/spec.rs | 22 ++- crates/lib/src/status.rs | 77 ++++++++++- crates/lib/src/store/mod.rs | 43 +++++- docs/src/host-v1.schema.json | 12 +- docs/src/man/bootc-status.8.md | 4 + .../test-install-to-filesystem-var-mount.sh | 23 ++++ 15 files changed, 325 insertions(+), 55 deletions(-) diff --git a/crates/lib/src/bootc_composefs/backwards_compat/bcompat_boot.rs b/crates/lib/src/bootc_composefs/backwards_compat/bcompat_boot.rs index bf203c0d4..628aa7ddc 100644 --- a/crates/lib/src/bootc_composefs/backwards_compat/bcompat_boot.rs +++ b/crates/lib/src/bootc_composefs/backwards_compat/bcompat_boot.rs @@ -277,8 +277,9 @@ fn handle_bls_conf( cfs_cmdline: &ComposefsCmdline, boot_dir: &Dir, is_uki: bool, + bootloader: crate::spec::Bootloader, ) -> Result<()> { - let entries = get_sorted_type1_boot_entries(boot_dir, true)?; + let entries = get_sorted_type1_boot_entries(boot_dir, true, bootloader)?; let (rename_transaction, new_bls_entries) = stage_bls_entry_changes(storage, boot_dir, &entries, cfs_cmdline)?; @@ -324,7 +325,7 @@ pub(crate) async fn prepend_custom_prefix( match get_boot_type(storage, cfs_cmdline)? { BootType::Bls => { - handle_bls_conf(storage, cfs_cmdline, boot_dir, false)?; + handle_bls_conf(storage, cfs_cmdline, boot_dir, false, bootloader)?; } BootType::Uki => match bootloader.kind()? { @@ -389,7 +390,7 @@ pub(crate) async fn prepend_custom_prefix( } BootloaderKind::BLSCompatible => { - handle_bls_conf(storage, cfs_cmdline, boot_dir, true)?; + handle_bls_conf(storage, cfs_cmdline, boot_dir, true, bootloader)?; } }, }; diff --git a/crates/lib/src/bootc_composefs/boot.rs b/crates/lib/src/bootc_composefs/boot.rs index dd4079c7a..325199f59 100644 --- a/crates/lib/src/bootc_composefs/boot.rs +++ b/crates/lib/src/bootc_composefs/boot.rs @@ -537,7 +537,7 @@ pub(crate) fn setup_composefs_bls_boot( } BootSetupType::Upgrade((storage, booted_cfs, host)) => { - let bootloader = host.require_composefs_booted()?.bootloader.clone(); + let bootloader = host.require_composefs_booted()?.require_bootloader()?; let boot_dir = storage.require_boot_dir()?; let current_cfg = get_booted_bls(&boot_dir, booted_cfs)?; @@ -1095,7 +1095,7 @@ pub(crate) fn setup_composefs_uki_boot( BootSetupType::Upgrade((storage, booted_cfs, host)) => { let sysroot = Utf8PathBuf::from("/sysroot"); // Still needed for root_path - let bootloader = host.require_composefs_booted()?.bootloader.clone(); + let bootloader = host.require_composefs_booted()?.require_bootloader()?; // Locate ESP partition device by walking up to the root disk(s) let root_dev = bootc_blockdev::list_dev_by_dir(&storage.physical_root)?; diff --git a/crates/lib/src/bootc_composefs/delete.rs b/crates/lib/src/bootc_composefs/delete.rs index 97295f389..f950cf008 100644 --- a/crates/lib/src/bootc_composefs/delete.rs +++ b/crates/lib/src/bootc_composefs/delete.rs @@ -148,7 +148,7 @@ fn delete_depl_boot_entries( ) -> Result<()> { let boot_dir = storage.require_boot_dir()?; - match deployment.deployment.bootloader.kind()? { + match deployment.deployment.require_bootloader()?.kind()? { BootloaderKind::GRUBClassic => match deployment.deployment.boot_type { BootType::Bls => delete_type1_conf_file(deployment, boot_dir, deleting_staged), BootType::Uki => { diff --git a/crates/lib/src/bootc_composefs/finalize.rs b/crates/lib/src/bootc_composefs/finalize.rs index 9e0f5e3d4..2158c68fa 100644 --- a/crates/lib/src/bootc_composefs/finalize.rs +++ b/crates/lib/src/bootc_composefs/finalize.rs @@ -131,7 +131,7 @@ pub(crate) async fn composefs_backend_finalize( let boot_dir = storage.require_boot_dir()?; - match booted_composefs.bootloader.kind()? { + match booted_composefs.require_bootloader()?.kind()? { BootloaderKind::GRUBClassic => match staged_composefs.boot_type { BootType::Bls => { let entries_dir = boot_dir.open_dir("loader")?; diff --git a/crates/lib/src/bootc_composefs/gc.rs b/crates/lib/src/bootc_composefs/gc.rs index fb534c914..c52e18ee9 100644 --- a/crates/lib/src/bootc_composefs/gc.rs +++ b/crates/lib/src/bootc_composefs/gc.rs @@ -264,7 +264,8 @@ pub(crate) async fn composefs_gc( let sysroot = &storage.physical_root; - let bootloader_entries = list_bootloader_entries(storage)?; + let bootloader_entries = + list_bootloader_entries(storage, booted_cfs_status.require_bootloader()?)?; let boot_binaries = collect_boot_binaries(storage)?; tracing::debug!("bootloader_entries: {bootloader_entries:?}"); @@ -501,6 +502,7 @@ pub(crate) async fn composefs_gc( mod tests { use super::*; use crate::bootc_composefs::status::list_type1_entries; + use crate::spec::Bootloader; use crate::testutils::{ChangeType, TestRoot}; /// Reproduce the shared-entry GC bug from issue #2102. @@ -555,7 +557,7 @@ mod tests { ); // Collect what the BLS entries reference - let bls_entries = list_type1_entries(&boot_dir)?; + let bls_entries = list_type1_entries(&boot_dir, Bootloader::Systemd)?; assert_eq!(bls_entries.len(), 2, "D (primary) + C (secondary)"); // The fix: unreferenced_boot_binaries uses boot_artifact_name. @@ -585,7 +587,7 @@ mod tests { // A's dir + C's dir still on disk (boot binary cleanup hasn't run) assert_eq!(on_disk_2.len(), 2); - let bls_entries_2 = list_type1_entries(&root.boot_dir()?)?; + let bls_entries_2 = list_type1_entries(&root.boot_dir()?, Bootloader::Systemd)?; // D (primary) + B (secondary) assert_eq!(bls_entries_2.len(), 2); @@ -638,7 +640,7 @@ mod tests { let digest_b = root.current().verity.clone(); let boot_dir = root.boot_dir()?; - let bls_entries = list_type1_entries(&boot_dir)?; + let bls_entries = list_type1_entries(&boot_dir, Bootloader::Systemd)?; assert_eq!(bls_entries.len(), 2, "Should find both BLS entries"); @@ -709,7 +711,7 @@ mod tests { assert_eq!(on_disk.len(), 2, "Should see A's and C's boot dirs"); // BLS entries should correctly reference boot artifact names - let bls_entries = list_type1_entries(&boot_dir)?; + let bls_entries = list_type1_entries(&boot_dir, Bootloader::Systemd)?; assert_eq!(bls_entries.len(), 2); // No boot dirs should be unreferenced (all are in use) @@ -723,7 +725,7 @@ mod tests { root.gc_deployment(&digest_a)?; let boot_dir = root.boot_dir()?; - let bls_entries = list_type1_entries(&boot_dir)?; + let bls_entries = list_type1_entries(&boot_dir, Bootloader::Systemd)?; assert_eq!(bls_entries.len(), 2, "B (secondary) + C (primary)"); let mut on_disk = Vec::new(); @@ -771,7 +773,7 @@ mod tests { let mut on_disk = Vec::new(); collect_type1_boot_binaries(&boot_dir, &mut on_disk)?; - let bls_entries = list_type1_entries(&boot_dir)?; + let bls_entries = list_type1_entries(&boot_dir, Bootloader::Systemd)?; assert_eq!(bls_entries.len(), 2, "D (primary) + C (secondary)"); let unreferenced = unreferenced_boot_binaries(&on_disk, &bls_entries); @@ -830,7 +832,7 @@ mod tests { assert_eq!(on_disk[0].1, digest_a, "The boot dir belongs to A"); // BLS entries: D (primary) + C (secondary), both referencing A's dir - let bls_entries = list_type1_entries(&boot_dir)?; + let bls_entries = list_type1_entries(&boot_dir, Bootloader::Systemd)?; assert_eq!(bls_entries.len(), 2); for entry in &bls_entries { assert_eq!( @@ -843,7 +845,7 @@ mod tests { root.gc_deployment(&digest_a)?; let boot_dir = root.boot_dir()?; - let bls_entries = list_type1_entries(&boot_dir)?; + let bls_entries = list_type1_entries(&boot_dir, Bootloader::Systemd)?; // D (primary) + C (secondary) — A was already evicted from BLS assert_eq!(bls_entries.len(), 2); @@ -864,7 +866,7 @@ mod tests { // D is the only deployment left let boot_dir = root.boot_dir()?; - let bls_entries = list_type1_entries(&boot_dir)?; + let bls_entries = list_type1_entries(&boot_dir, Bootloader::Systemd)?; assert_eq!(bls_entries.len(), 1, "Only D remains"); assert_eq!(bls_entries[0].fsverity, digest_d); assert_eq!( @@ -901,7 +903,7 @@ mod tests { // -- Pre-migration: all entries lack the prefix -- let boot_dir = root.boot_dir()?; - let raw_entries = get_sorted_type1_boot_entries(&boot_dir, true)?; + let raw_entries = get_sorted_type1_boot_entries(&boot_dir, true, Bootloader::Systemd)?; assert_eq!(raw_entries.len(), 2); let needs_migration: Vec<_> = raw_entries @@ -924,7 +926,7 @@ mod tests { // -- Post-migration: all entries have the prefix -- let boot_dir = root.boot_dir()?; - let raw_entries = get_sorted_type1_boot_entries(&boot_dir, true)?; + let raw_entries = get_sorted_type1_boot_entries(&boot_dir, true, Bootloader::Systemd)?; assert_eq!(raw_entries.len(), 2); let needs_migration: Vec<_> = raw_entries @@ -942,7 +944,7 @@ mod tests { assert_eq!(on_disk.len(), 2, "Both dirs visible after migration"); // GC filter correctly identifies all dirs as referenced - let bls_entries = list_type1_entries(&boot_dir)?; + let bls_entries = list_type1_entries(&boot_dir, Bootloader::Systemd)?; let unreferenced = unreferenced_boot_binaries(&on_disk, &bls_entries); assert!( unreferenced.is_empty(), @@ -953,7 +955,7 @@ mod tests { root.upgrade(3, ChangeType::Kernel)?; let boot_dir = root.boot_dir()?; - let raw_entries = get_sorted_type1_boot_entries(&boot_dir, true)?; + let raw_entries = get_sorted_type1_boot_entries(&boot_dir, true, Bootloader::Systemd)?; // All entries (both migrated and new) should have the prefix for entry in &raw_entries { let (_, has_prefix) = entry.boot_artifact_info()?; @@ -970,7 +972,7 @@ mod tests { assert_eq!(on_disk.len(), 3, "Three boot dirs on disk"); // Only 2 BLS entries (primary + secondary), so one dir is unreferenced - let bls_entries = list_type1_entries(&boot_dir)?; + let bls_entries = list_type1_entries(&boot_dir, Bootloader::Systemd)?; assert_eq!(bls_entries.len(), 2); let unreferenced = unreferenced_boot_binaries(&on_disk, &bls_entries); assert_eq!( diff --git a/crates/lib/src/bootc_composefs/rollback.rs b/crates/lib/src/bootc_composefs/rollback.rs index 307545463..37a769cc9 100644 --- a/crates/lib/src/bootc_composefs/rollback.rs +++ b/crates/lib/src/bootc_composefs/rollback.rs @@ -130,7 +130,7 @@ fn rollback_grub_uki_entries(boot_dir: &Dir) -> Result<()> { #[context("Rolling back {bootloader} entries")] fn rollback_composefs_entries(host: &Host, boot_dir: &Dir, bootloader: Bootloader) -> Result<()> { // Get all boot entries sorted in descending order by sort-key - let mut all_configs = get_sorted_type1_boot_entries(&boot_dir, false)?; + let mut all_configs = get_sorted_type1_boot_entries(&boot_dir, false, bootloader)?; // TODO(Johan-Liebert): Currently assuming there are only two deployments assert!(all_configs.len() == 2); @@ -252,10 +252,12 @@ pub(crate) async fn composefs_rollback( let boot_dir = storage.require_boot_dir()?; - match &rollback_entry.bootloader.kind()? { + let bootloader = rollback_entry.require_bootloader()?; + + match bootloader.kind()? { BootloaderKind::GRUBClassic => match rollback_entry.boot_type { BootType::Bls => { - rollback_composefs_entries(&host, boot_dir, rollback_entry.bootloader.clone())?; + rollback_composefs_entries(&host, boot_dir, bootloader)?; } BootType::Uki => { rollback_grub_uki_entries(boot_dir)?; @@ -264,7 +266,7 @@ pub(crate) async fn composefs_rollback( BootloaderKind::BLSCompatible => { // We use BLS entries for systemd UKI as well - rollback_composefs_entries(&host, boot_dir, rollback_entry.bootloader.clone())?; + rollback_composefs_entries(&host, boot_dir, bootloader)?; } } diff --git a/crates/lib/src/bootc_composefs/state.rs b/crates/lib/src/bootc_composefs/state.rs index e43447a2f..1a34f818a 100644 --- a/crates/lib/src/bootc_composefs/state.rs +++ b/crates/lib/src/bootc_composefs/state.rs @@ -27,7 +27,7 @@ use rustix::{ use crate::bootc_composefs::boot::BootType; use crate::bootc_composefs::status::{ - ComposefsCmdline, StagedDeployment, get_sorted_type1_boot_entries, + ComposefsCmdline, StagedDeployment, get_bootloader, get_sorted_type1_boot_entries, }; use crate::parsers::bls_config::{BLSConfigType, EFIKey}; use crate::store::{BootedComposefs, Storage}; @@ -65,7 +65,7 @@ pub(crate) fn read_origin(sysroot: &Dir, deployment_id: &str) -> Result Result { - let sorted_entries = get_sorted_type1_boot_entries(boot_dir, true)?; + let sorted_entries = get_sorted_type1_boot_entries(boot_dir, true, get_bootloader()?)?; for entry in sorted_entries { match &entry.cfg_type { diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index e37790373..d62899a01 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize}; use crate::{ bootc_composefs::{ - boot::BootType, + boot::{BootType, EFI_LINUX}, selinux::are_selinux_policies_compatible, state::{get_composefs_usr_overlay_status, read_origin}, utils::{compute_store_boot_digest_for_uki, get_uki_cmdline}, @@ -34,6 +34,7 @@ use crate::{ use std::str::FromStr; use bootc_utils::try_deserialize_timestamp; +use camino::Utf8Path; use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt}; use ostree_container::OstreeImageReference; use ostree_ext::container::{self as ostree_container}; @@ -252,8 +253,8 @@ fn get_sorted_grub_uki_boot_entries_helper<'a>( pub(crate) fn get_sorted_type1_boot_entries( boot_dir: &Dir, ascending: bool, + bootloader: Bootloader, ) -> Result> { - let bootloader = get_bootloader()?; get_sorted_type1_boot_entries_helper(boot_dir, ascending, false, bootloader) } @@ -262,8 +263,8 @@ pub(crate) fn get_sorted_type1_boot_entries( pub(crate) fn get_sorted_staged_type1_boot_entries( boot_dir: &Dir, ascending: bool, + bootloader: Bootloader, ) -> Result> { - let bootloader = get_bootloader()?; get_sorted_type1_boot_entries_helper(boot_dir, ascending, true, bootloader) } @@ -347,13 +348,16 @@ fn get_sorted_type1_boot_entries_helper( .collect()) } -pub(crate) fn list_type1_entries(boot_dir: &Dir) -> Result> { +pub(crate) fn list_type1_entries( + boot_dir: &Dir, + bootloader: Bootloader, +) -> Result> { // Type1 Entry - let boot_entries = get_sorted_type1_boot_entries(boot_dir, true)?; + let boot_entries = get_sorted_type1_boot_entries(boot_dir, true, bootloader)?; // We wouldn't want to delete the staged deployment if the GC runs when a // deployment is staged - let staged_boot_entries = get_sorted_staged_type1_boot_entries(boot_dir, true)?; + let staged_boot_entries = get_sorted_staged_type1_boot_entries(boot_dir, true, bootloader)?; boot_entries .into_iter() @@ -369,11 +373,19 @@ pub(crate) fn list_type1_entries(boot_dir: &Dir) -> Result> /// Get all Type1/Type2 bootloader entries /// +/// The bootloader is passed in explicitly rather than read from a live EFI +/// variable via [`get_bootloader`], so that this can be used both for the +/// booted system (where the caller passes `get_bootloader()?`) and for an +/// arbitrary non-booted sysroot (see [`composefs_status_from_sysroot`], +/// which derives it from the target's on-disk layout instead). +/// /// # Returns /// The fsverity of EROFS images corresponding to boot entries #[fn_error_context::context("Listing bootloader entries")] -pub(crate) fn list_bootloader_entries(storage: &Storage) -> Result> { - let bootloader = get_bootloader()?; +pub(crate) fn list_bootloader_entries( + storage: &Storage, + bootloader: Bootloader, +) -> Result> { let boot_dir = storage.require_boot_dir()?; let entries = match bootloader.kind()? { @@ -401,11 +413,11 @@ pub(crate) fn list_bootloader_entries(storage: &Storage) -> Result, anyhow::Error>>()? } else { - list_type1_entries(boot_dir)? + list_type1_entries(boot_dir, bootloader)? } } - BootloaderKind::BLSCompatible => list_type1_entries(boot_dir)?, + BootloaderKind::BLSCompatible => list_type1_entries(boot_dir, bootloader)?, }; Ok(entries) @@ -545,6 +557,7 @@ fn boot_entry_from_composefs_deployment( origin: tini::Ini, verity: &str, missing_verity_allowed: bool, + bootloader: Option, ) -> Result { let image = match origin.get::("origin", ORIGIN_CONTAINER) { Some(img_name_from_config) => { @@ -594,7 +607,7 @@ fn boot_entry_from_composefs_deployment( composefs: Some(crate::spec::BootEntryComposefs { verity: verity.into(), boot_type, - bootloader: get_bootloader()?, + bootloader, boot_digest, missing_verity_allowed, }), @@ -614,6 +627,77 @@ pub(crate) async fn get_composefs_status( composefs_deployment_status_from(&storage, booted_cfs.cmdline).await } +/// List boot entries for a composefs-native sysroot at an arbitrary path, +/// without requiring the sysroot to be booted. +/// +/// There's no notion of booted/staged/rollback here since none of that is +/// meaningful before the target has ever been booted; all deployments +/// found are simply returned as a flat list, mirroring how +/// [`crate::status::get_host_from_sysroot`] handles the ostree case. We +/// never report a `bootloader` for these entries (see +/// [`boot_entry_from_composefs_deployment`]'s `None` argument below): +/// `grub-cc` and `systemd` write an identical on-disk layout, so it can't +/// be determined without booting, and unlike the booted case there's no +/// menu order to get right, so we don't need to guess one either. +pub(crate) fn composefs_status_from_sysroot( + sysroot_path: &Utf8Path, + sysroot_dir: Dir, +) -> Result> { + // `list_bootloader_entries` needs *a* `Bootloader` to pick which + // on-disk format to parse (classic grub.cfg vs BLS `.conf` files) and + // a sort order; `Grub` is accurate whenever `boot/grub2` exists, and + // otherwise `Systemd` is just a placeholder to select the BLS format + // (its sort order isn't used since we return an unordered list). + let (boot_dir, bootloader) = if sysroot_dir.exists("boot/grub2") { + ( + sysroot_dir.open_dir("boot").context("Opening boot dir")?, + Bootloader::Grub, + ) + } else if let Some(esp_dir) = sysroot_dir + .open_dir_optional("boot/efi")? + .filter(|d| d.exists(TYPE1_ENT_PATH) || d.exists(EFI_LINUX)) + { + (esp_dir, Bootloader::Systemd) + } else { + anyhow::bail!( + "Could not find a supported bootloader layout under {sysroot_path}/boot \ + (expected boot/grub2 or boot/efi/{TYPE1_ENT_PATH})" + ); + }; + + let storage = Storage::new_composefs_sysroot(sysroot_path, sysroot_dir, boot_dir)?; + let bootloader_entries = list_bootloader_entries(&storage, bootloader)?; + + bootloader_entries + .into_iter() + .filter_map(|entry| { + match read_origin(&storage.physical_root, &entry.fsverity) { + Ok(Some(ini)) => Some( + boot_entry_from_composefs_deployment( + &storage, + ini, + &entry.fsverity, + // We have no `/proc/cmdline` to read for a sysroot that + // hasn't been booted, so we can't tell whether missing + // fs-verity was permitted for this deployment; assume + // the (stricter) default of `false`. + false, + // See this function's doc comment: we don't know + // (and can't vouch for) the real bootloader here. + None, + ) + .with_context(|| format!("Deployment {}", entry.fsverity)), + ), + Ok(None) => { + tracing::warn!("No origin file for deployment {}", entry.fsverity); + None + } + Err(e) => Some(Err(e)), + } + }) + .collect() +} + /// Check whether any deployment is capable of being soft rebooted or not #[context("Checking soft reboot capability")] fn set_soft_reboot_capability( @@ -629,8 +713,11 @@ fn set_soft_reboot_capability( let mut bls_entries = bls_entries.ok_or_else(|| anyhow::anyhow!("BLS entries not provided"))?; - let staged_entries = - get_sorted_staged_type1_boot_entries(storage.require_boot_dir()?, false)?; + let staged_entries = get_sorted_staged_type1_boot_entries( + storage.require_boot_dir()?, + false, + booted.require_bootloader()?, + )?; // We will have a duplicate booted entry here, but that's fine as we only use this // vector to check for existence of an entry @@ -828,9 +915,10 @@ async fn composefs_deployment_status_from( let booted_composefs_digest = &cmdline.digest; let boot_dir = storage.require_boot_dir()?; + let bootloader = get_bootloader()?; // This is our source of truth - let bootloader_entry_verity = list_bootloader_entries(storage)?; + let bootloader_entry_verity = list_bootloader_entries(storage, bootloader)?; let host_spec = HostSpec { image: None, @@ -882,6 +970,7 @@ async fn composefs_deployment_status_from( ini, &verity_digest, cmdline.allow_missing_fsverity, + Some(bootloader), )?; // SAFETY: boot_entry.composefs will always be present @@ -932,15 +1021,16 @@ async fn composefs_deployment_status_from( }; let booted_cfs = host.require_composefs_booted()?; + let booted_bootloader = booted_cfs.require_bootloader()?; let mut grub_menu_string = String::new(); - let (is_rollback_queued, sorted_bls_config, grub_menu_entries) = match booted_cfs - .bootloader + let (is_rollback_queued, sorted_bls_config, grub_menu_entries) = match booted_bootloader .kind()? { BootloaderKind::GRUBClassic => match boot_type { BootType::Bls => { - let bls_configs = get_sorted_type1_boot_entries(boot_dir, false)?; + let bls_configs = + get_sorted_type1_boot_entries(boot_dir, false, booted_bootloader)?; let bls_config = bls_configs .first() .ok_or_else(|| anyhow::anyhow!("First boot entry not found"))?; @@ -980,7 +1070,7 @@ async fn composefs_deployment_status_from( // We will have BLS stuff and the UKI stuff in the same DIR BootloaderKind::BLSCompatible => { - let bls_configs = get_sorted_type1_boot_entries(boot_dir, true)?; + let bls_configs = get_sorted_type1_boot_entries(boot_dir, true, booted_bootloader)?; let bls_config = bls_configs .first() .ok_or(anyhow::anyhow!("First boot entry not found"))?; @@ -1282,7 +1372,7 @@ mod tests { tempdir.atomic_write("loader/entries/active.conf", active_entry)?; tempdir.atomic_write("loader/entries.staged/staged.conf", staged_entry)?; - let result = list_type1_entries(&tempdir)?; + let result = list_type1_entries(&tempdir, Bootloader::Systemd)?; assert_eq!(result.len(), 2); let verity_set: std::collections::HashSet<&str> = diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index b70eb44ce..bf67785f2 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -274,6 +274,13 @@ pub(crate) struct StatusOpts { /// Include additional fields in human readable format. #[clap(long, short = 'v')] pub(crate) verbose: bool, + + /// Query a sysroot at an arbitrary path instead of the booted system. + /// + /// Useful for inspecting a freshly installed (not yet booted) system, + /// e.g. `bootc status --json --sysroot /mnt`. + #[clap(long)] + pub(crate) sysroot: Option, } /// Add a transient overlayfs on /usr @@ -2546,7 +2553,8 @@ mod tests { format: None, format_version: None, booted: false, - verbose: false + verbose: false, + sysroot: None, }) )); assert!(matches!( diff --git a/crates/lib/src/spec.rs b/crates/lib/src/spec.rs index 9fa8d7a25..d3447549b 100644 --- a/crates/lib/src/spec.rs +++ b/crates/lib/src/spec.rs @@ -302,8 +302,14 @@ pub struct BootEntryComposefs { pub verity: String, /// Whether this deployment is to be booted via Type1 (vmlinuz + initrd) or Type2 (UKI) entry pub boot_type: BootType, - /// Whether we boot using systemd or grub - pub bootloader: Bootloader, + /// Whether we boot using systemd or grub. + /// + /// This is `None` when the deployment was discovered by querying a + /// non-booted sysroot (e.g. `bootc status --sysroot`): `grub-cc` and + /// `systemd` both use the same on-disk BLS layout, so they can't be + /// distinguished without booting the system (bootupd tracks this, but + /// that state isn't available offline). + pub bootloader: Option, /// The sha256sum of vmlinuz + initrd /// Only `Some` for Type1 boot entries pub boot_digest: Option, @@ -311,6 +317,18 @@ pub struct BootEntryComposefs { pub missing_verity_allowed: bool, } +impl BootEntryComposefs { + /// Get the bootloader, failing if it's unknown. + /// + /// This should always succeed for entries obtained from the booted + /// system; it's only ever `None` for entries discovered via + /// `bootc status --sysroot` on a composefs target. + pub(crate) fn require_bootloader(&self) -> Result { + self.bootloader + .ok_or_else(|| anyhow::anyhow!("Bootloader is not known for this deployment")) + } +} + /// A bootable entry #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(rename_all = "camelCase")] diff --git a/crates/lib/src/status.rs b/crates/lib/src/status.rs index c6c3e8232..3d0baf2f5 100644 --- a/crates/lib/src/status.rs +++ b/crates/lib/src/status.rs @@ -472,6 +472,77 @@ pub(crate) async fn get_host() -> Result { Ok(host) } +/// Build a [`Host`] with no booted/staged/rollback deployment, just a flat +/// list of `otherDeployments`. Used by [`get_host_from_sysroot`] for both +/// the ostree and composefs cases, since neither has any notion of +/// "booted" for a sysroot that (as far as we know) hasn't been booted yet. +fn host_from_other_deployments(other_deployments: Vec) -> Host { + let spec = other_deployments + .first() + .and_then(|entry| entry.image.as_ref()) + .map(|img| HostSpec { + image: Some(img.image.clone()), + boot_order: BootOrder::Default, + }) + .unwrap_or_default(); + + let mut host = Host::new(spec); + host.status = HostStatus { + staged: None, + booted: None, + rollback: None, + other_deployments, + rollback_queued: false, + ty: Some(HostType::BootcHost), + usr_overlay: None, + }; + host +} + +/// Query the status of a sysroot at an arbitrary path. +/// +/// This is designed for callers that have just completed an installation +/// and want to discover the deployment state (image digest, stateroot, +/// ostree metadata, etc.) without rebooting. The returned [`Host`] uses +/// the same schema as `bootc status --json`. +/// +/// Since the target sysroot is not the running system, `booted` and +/// `staged` will be `None`; all deployments appear in `otherDeployments`. +/// +/// The sysroot may be either ostree- or composefs-backed; we distinguish +/// the two by checking for an `ostree/deploy` directory (populated by +/// `bootc install` for the ostree storage backend) and otherwise assume +/// composefs. +#[context("Querying status for sysroot")] +pub(crate) fn get_host_from_sysroot(sysroot_path: &camino::Utf8Path) -> Result { + let sysroot_dir = cap_std_ext::cap_std::fs::Dir::open_ambient_dir( + sysroot_path, + cap_std_ext::cap_std::ambient_authority(), + ) + .with_context(|| format!("Opening sysroot at {sysroot_path}"))?; + + if !sysroot_dir.exists("ostree/deploy") { + let other_deployments = crate::bootc_composefs::status::composefs_status_from_sysroot( + sysroot_path, + sysroot_dir, + )?; + return Ok(host_from_other_deployments(other_deployments)); + } + + let sysroot = ostree::Sysroot::new(Some(&ostree::gio::File::for_path(sysroot_path))); + sysroot.load(ostree::gio::Cancellable::NONE)?; + let sysroot_lock = SysrootLock::from_assumed_locked(&sysroot); + + let deployments = sysroot.deployments(); + let all_deployments = deployments + .iter() + .map(|d| boot_entry_from_deployment(&sysroot_lock, d)) + .collect::>>() + .context("Enumerating deployments")?; + + Ok(host_from_other_deployments(all_deployments)) +} + /// Implementation of the `bootc status` CLI command. #[context("Status")] pub(crate) async fn status(opts: super::cli::StatusOpts) -> Result<()> { @@ -480,7 +551,11 @@ pub(crate) async fn status(opts: super::cli::StatusOpts) -> Result<()> { 0 | 1 => {} o => anyhow::bail!("Unsupported format version: {o}"), }; - let mut host = get_host().await?; + let mut host = if let Some(ref sysroot_path) = opts.sysroot { + get_host_from_sysroot(sysroot_path)? + } else { + get_host().await? + }; // We could support querying the staged or rollback deployments // here too, but it's not a common use case at the moment. diff --git a/crates/lib/src/store/mod.rs b/crates/lib/src/store/mod.rs index 78926d173..2a6bc3ba6 100644 --- a/crates/lib/src/store/mod.rs +++ b/crates/lib/src/store/mod.rs @@ -96,7 +96,7 @@ use std::sync::Arc; use anyhow::{Context, Result}; use bootc_mount::tempmount::TempMount; -use camino::Utf8PathBuf; +use camino::{Utf8Path, Utf8PathBuf}; use cap_std_ext::cap_std; use cap_std_ext::cap_std::fs::{ Dir, DirBuilder, DirBuilderExt as _, Permissions, PermissionsExt as _, @@ -507,6 +507,47 @@ impl Storage { }) } + /// Create a read-only storage accessor for a composefs-native sysroot + /// at an arbitrary path. + /// + /// This is the composefs counterpart to [`Self::new_ostree`]: it's used + /// for non-booted scenarios (e.g. `bootc status --sysroot` querying a + /// freshly installed target) rather than the running system. Unlike + /// [`BootedStorage::new`], it doesn't rely on `/proc/cmdline`, `/run` + /// transient state, or EFI variables -- the caller must have already + /// opened `physical_root` and determined `boot_dir` by inspecting the + /// target filesystem layout (see + /// `bootc_composefs::status::composefs_status_from_sysroot`). + /// + /// The composefs repository is opened read-only (with fs-verity + /// enforcement relaxed) since this is only used to read deployment + /// metadata, not to mount or verify a boot. + pub(crate) fn new_composefs_sysroot( + sysroot_path: &Utf8Path, + physical_root: Dir, + boot_dir: Dir, + ) -> Result { + let mut composefs = ComposefsRepository::open_path(&physical_root, COMPOSEFS) + .context("Opening composefs repository")?; + // We're reading metadata for a status query, not verifying a live + // boot, so we don't need (and can't obtain) runtime information + // about whether fs-verity was enforced for this deployment. + composefs.set_insecure(); + + let run = physical_root.try_clone()?; + + Ok(Self { + physical_root, + physical_root_path: sysroot_path.to_owned(), + run, + boot_dir: Some(boot_dir), + esp: None, + ostree: Default::default(), + composefs: OnceCell::from(Arc::new(composefs)), + imgstore: Default::default(), + }) + } + /// Returns `boot_dir` if it exists pub(crate) fn require_boot_dir(&self) -> Result<&Dir> { self.boot_dir diff --git a/docs/src/host-v1.schema.json b/docs/src/host-v1.schema.json index 0503068a7..be78f4b8a 100644 --- a/docs/src/host-v1.schema.json +++ b/docs/src/host-v1.schema.json @@ -140,8 +140,15 @@ "$ref": "#/$defs/BootType" }, "bootloader": { - "description": "Whether we boot using systemd or grub", - "$ref": "#/$defs/Bootloader" + "description": "Whether we boot using systemd or grub.\n\nThis is `None` when the deployment was discovered by querying a\nnon-booted sysroot (e.g. `bootc status --sysroot`): `grub-cc` and\n`systemd` both use the same on-disk BLS layout, so they can't be\ndistinguished without booting the system (bootupd tracks this, but\nthat state isn't available offline).", + "anyOf": [ + { + "$ref": "#/$defs/Bootloader" + }, + { + "type": "null" + } + ] }, "missingVerityAllowed": { "description": "Whether fs-verity validation is optional", @@ -155,7 +162,6 @@ "required": [ "verity", "bootType", - "bootloader", "missingVerityAllowed" ] }, diff --git a/docs/src/man/bootc-status.8.md b/docs/src/man/bootc-status.8.md index 71be313b7..8250f70b0 100644 --- a/docs/src/man/bootc-status.8.md +++ b/docs/src/man/bootc-status.8.md @@ -57,6 +57,10 @@ However, if the `incompatible` flag is set on a deployment, then there are layer Include additional fields in human readable format +**--sysroot**=*SYSROOT* + + Query a sysroot at an arbitrary path instead of the booted system + # EXAMPLES diff --git a/tmt/tests/booted/test-install-to-filesystem-var-mount.sh b/tmt/tests/booted/test-install-to-filesystem-var-mount.sh index 94cdd4d3f..92a517fe0 100644 --- a/tmt/tests/booted/test-install-to-filesystem-var-mount.sh +++ b/tmt/tests/booted/test-install-to-filesystem-var-mount.sh @@ -221,5 +221,28 @@ else fi fi +# `bootc status --sysroot` should be able to query the freshly installed +# target directly, without rebooting into it. +echo "Verifying 'bootc status --sysroot'..." +sysroot_status=$(bootc status --json --sysroot /var/mnt/target) + +test "$(jq '.status.booted' <<< "$sysroot_status")" = "null" +test "$(jq '.status.staged' <<< "$sysroot_status")" = "null" +test "$(jq '.status.otherDeployments | length' <<< "$sysroot_status")" = "1" + +if [[ $is_composefs == "null" ]]; then + test -n "$(jq -r '.status.otherDeployments[0].ostree.checksum' <<< "$sysroot_status")" + test -n "$(jq -r '.status.otherDeployments[0].ostree.stateroot' <<< "$sysroot_status")" +else + sysroot_boot_type=$(jq -r '.status.otherDeployments[0].composefs.bootType' <<< "$sysroot_status" | tr '[:upper:]' '[:lower:]') + + test "$sysroot_boot_type" = "$boot_type" + test -n "$(jq -r '.status.otherDeployments[0].composefs.verity' <<< "$sysroot_status")" + + # `grub-cc` and `systemd` share the same on-disk BLS-in-ESP layout, so + # the bootloader can't be reliably determined without booting; bootc + # leaves it unset (null) rather than guess. + test "$(jq '.status.otherDeployments[0].composefs.bootloader' <<< "$sysroot_status")" = "null" +fi echo "Installation to-filesystem with separate /var mount succeeded!"