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!"