diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index dcc1282fe..4212169b5 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -268,8 +268,10 @@ pub(crate) fn get_sorted_staged_type1_boot_entries( get_sorted_type1_boot_entries_helper(boot_dir, ascending, true, bootloader) } +/// Like [`get_sorted_type1_boot_entries`], for an explicit `bootloader` (e.g. of +/// an offline sysroot) and optionally the staged entries. #[context("Getting sorted Type1 boot entries")] -fn get_sorted_type1_boot_entries_helper( +pub(crate) fn get_sorted_type1_boot_entries_helper( boot_dir: &Dir, ascending: bool, get_staged_entries: bool, @@ -444,48 +446,87 @@ pub(crate) async fn get_container_manifest_and_config( Ok((ImgConfigManifest { manifest, config }, manifest_digest)) } -/// Directory where BLS-compatible bootloaders expect Type 1 boot entries. -/// -/// Its presence says nothing about the bootloader on its own: EFI systems -/// have it too, and GRUB reads the same entries via `blscfg`. It is only -/// consulted when there are no EFI variables to inspect, and then only -/// together with [`GRUB_DIRS`] to tell a BLS-native bootloader apart from -/// GRUB (see [`classify_bootloader`]). -const BLS_ENTRIES_DIR: &str = "/boot/loader/entries"; - -/// Directories where GRUB keeps its own configuration and modules. Their -/// presence means GRUB owns the boot flow even if BLS Type 1 entries also -/// exist, because GRUB can consume those entries itself via the `blscfg` -/// module — Fedora and RHEL enable exactly that with -/// `GRUB_ENABLE_BLSCFG=true`. `/boot/grub2` is the Fedora/RHEL path, -/// `/boot/grub` the Debian/Ubuntu one. -const GRUB_DIRS: [&str; 2] = ["/boot/grub2", "/boot/grub"]; +/// The `/boot` directory probed for the bootloader when there are no EFI +/// variables to inspect. +const BOOT_DIR: &str = "/boot"; + +/// Directories (relative to `/boot`) where GRUB keeps its own configuration +/// and modules. Their presence means GRUB owns the boot flow even if BLS +/// Type 1 entries also exist, because GRUB can consume those entries itself +/// via the `blscfg` module — Fedora and RHEL enable exactly that with +/// `GRUB_ENABLE_BLSCFG=true`. `grub2` is the Fedora/RHEL path, `grub` the +/// Debian/Ubuntu one. +const GRUB_DIRS: [&str; 2] = ["grub2", "grub"]; + +/// What the boot directories say about the bootloader, for when there are no +/// EFI variables to ask: the live `/boot` of a host without them, or the +/// `/boot` and ESP of an offline sysroot. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) struct BootDirProbe { + /// A directory has BLS Type 1 entries ([`TYPE1_ENT_PATH`]). Its presence + /// says nothing on its own: EFI systems have it too, and GRUB reads the + /// same entries via `blscfg`. + pub(crate) bls_entries: bool, + /// A directory has one of [`GRUB_DIRS`]. + pub(crate) grub_dir: bool, +} + +impl BootDirProbe { + /// Probe each of `dirs`, e.g. `/boot` and an ESP. + pub(crate) fn from_dirs<'a>(dirs: impl IntoIterator) -> Self { + let mut probe = Self::default(); + for dir in dirs { + probe.bls_entries |= dir.is_dir(TYPE1_ENT_PATH); + probe.grub_dir |= GRUB_DIRS.iter().any(|d| dir.is_dir(d)); + } + probe + } + + /// The bootloader these files imply. + /// + /// Many non-EFI systems still lay down the BLS Type 1 entry layout + /// (Raspberry Pi with direct-kernel boot from Pi firmware, U-Boot with the + /// extlinux/BLS loader, coreboot with a linux payload, various + /// ARM/embedded boards). Treat those as BLS-compatible so `storage::new` + /// picks the ESP mount as `boot_dir` rather than `/sysroot/boot/`. Only + /// fall back to GRUB when there is no BLS layout. + /// + /// A BLS entries directory alone is not sufficient evidence, because GRUB + /// with `blscfg` reads the same directory. So GRUB's own directory wins + /// when both are present: a legacy-BIOS Fedora/RHEL install has + /// `/boot/grub2/` *and* `/boot/loader/entries/`, and is unambiguously + /// GRUB. Only a BLS layout with no GRUB directory implies a BLS-native + /// bootloader. + pub(crate) fn bootloader(&self) -> Bootloader { + if self.grub_dir { + tracing::debug!( + "A GRUB directory is present; treating bootloader as GRUB \ + even if BLS entries also exist (GRUB reads them via blscfg)" + ); + Bootloader::Grub + } else if self.bls_entries { + tracing::debug!( + "No GRUB directory, and {TYPE1_ENT_PATH} is a directory; \ + treating bootloader as BLS-compatible (systemd-boot)" + ); + Bootloader::Systemd + } else { + Bootloader::Grub + } + } +} /// Pure classifier for the bootloader kind, split from I/O for testability. /// /// - When `EFI_LOADER_INFO` is present, its content selects between systemd- /// boot, GRUB Confidential Compute, and generic GRUB (existing behavior). /// - When there are no EFI variables to inspect (`SystemNotUEFI` / -/// `MissingVar`), fall back to a filesystem probe: many non-EFI systems -/// still lay down the BLS Type 1 entry layout at `/boot/loader/entries/` -/// (Raspberry Pi with direct-kernel boot from Pi firmware, U-Boot with -/// the extlinux/BLS loader, coreboot with a linux payload, various -/// ARM/embedded boards). Treat those as BLS-compatible so `storage::new` -/// picks the ESP mount as `boot_dir` rather than `/sysroot/boot/`. Only -/// fall back to GRUB when neither an EFI system nor a BLS layout is -/// present. -/// -/// A BLS entries directory alone is not sufficient evidence, because GRUB -/// with `blscfg` reads the same directory. So GRUB's own directory wins -/// when both are present: a legacy-BIOS Fedora/RHEL install has -/// `/boot/grub2/` *and* `/boot/loader/entries/`, and is unambiguously -/// GRUB. Only a BLS layout with no GRUB directory implies a BLS-native -/// bootloader. +/// `MissingVar`), fall back to the filesystem `probe` +/// (see [`BootDirProbe::bootloader`]). /// - Other EFI read errors propagate. fn classify_bootloader( efi_loader_info: Result, - bls_entries_dir_present: bool, - grub_dir_present: bool, + probe: impl FnOnce() -> Result, ) -> Result { match efi_loader_info { Ok(loader) => { @@ -499,23 +540,8 @@ fn classify_bootloader( } } Err(EfiError::SystemNotUEFI) | Err(EfiError::MissingVar) => { - if grub_dir_present { - tracing::debug!( - "No EFI vars and a GRUB directory is present; treating \ - bootloader as GRUB even if BLS entries also exist \ - (GRUB reads them via blscfg)" - ); - Ok(Bootloader::Grub) - } else if bls_entries_dir_present { - tracing::debug!( - "No EFI vars, no GRUB directory, and {BLS_ENTRIES_DIR} is \ - a directory; treating bootloader as BLS-compatible \ - (systemd-boot)" - ); - Ok(Bootloader::Systemd) - } else { - Ok(Bootloader::Grub) - } + tracing::debug!("No EFI vars; probing the boot directory for the bootloader"); + Ok(probe()?.bootloader()) } Err(e) => anyhow::bail!("Failed to read EfiLoaderInfo: {e:?}"), } @@ -529,19 +555,17 @@ pub(crate) fn get_bootloader() -> Result { return Ok(*bootloader); } - let efi_result = read_uefi_var(EFI_LOADER_INFO); - let non_efi = matches!( - &efi_result, - Err(EfiError::SystemNotUEFI) | Err(EfiError::MissingVar), - ); - - let bootloader = classify_bootloader( - efi_result, - // The FS probes are only consulted in the non-EFI classification - // branch; skip the `stat(2)`s on EFI systems. - non_efi && std::path::Path::new(BLS_ENTRIES_DIR).is_dir(), - non_efi && GRUB_DIRS.iter().any(|d| std::path::Path::new(d).is_dir()), - )?; + // The filesystem probe is only consulted without EFI variables, so there + // are no `stat(2)`s on EFI systems. + let bootloader = + classify_bootloader( + read_uefi_var(EFI_LOADER_INFO), + || match Dir::open_ambient_dir(BOOT_DIR, cap_std_ext::cap_std::ambient_authority()) { + Ok(boot) => Ok(BootDirProbe::from_dirs([&boot])), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(BootDirProbe::default()), + Err(e) => Err(e).with_context(|| format!("Opening {BOOT_DIR}")), + }, + )?; // The bootloader cannot change over the lifetime of a single bootc // invocation, so cache unconditionally. @@ -1648,18 +1672,51 @@ mod tests { }, ]; for case in cases { - let got = classify_bootloader(case.efi, case.bls, case.grub_dir) + let probe = BootDirProbe { + bls_entries: case.bls, + grub_dir: case.grub_dir, + }; + let got = classify_bootloader(case.efi, || Ok(probe)) .unwrap_or_else(|e| panic!("{}: {e}", case.desc)); assert_eq!(got, case.expected, "{}", case.desc); } } + #[test] + fn boot_dir_probe_from_dirs() -> Result<()> { + // (directories created in the first dir, in the second, expected probe) + let cases: [(&[&str], &[&str], (bool, bool)); 5] = [ + (&[], &[], (false, false)), + (&["loader/entries"], &[], (true, false)), + (&[], &["loader/entries"], (true, false)), + (&["grub2", "loader/entries"], &[], (true, true)), + (&["grub", "loader"], &[], (false, true)), + ]; + for (first, second, (bls_entries, grub_dir)) in cases { + let dirs = [ + cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?, + cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?, + ]; + for (dir, paths) in dirs.iter().zip([first, second]) { + for path in paths { + dir.create_dir_all(path)?; + } + } + let probe = BootDirProbe::from_dirs(dirs.iter().map(|d| &**d)); + let expected = BootDirProbe { + bls_entries, + grub_dir, + }; + assert_eq!(probe, expected, "{first:?} {second:?}"); + } + Ok(()) + } + #[test] fn classify_bootloader_propagates_other_efi_errors() { let result = classify_bootloader( Err(EfiError::InvalidData("test-only synthetic error")), - false, - false, + || Ok(BootDirProbe::default()), ); assert!(result.is_err(), "InvalidData should propagate as an error"); } diff --git a/crates/lib/src/mount.rs b/crates/lib/src/mount.rs index 56d3870a1..89d97afcc 100644 --- a/crates/lib/src/mount.rs +++ b/crates/lib/src/mount.rs @@ -7,6 +7,10 @@ //! `/etc` and `/var` mounted from its state as they will be at boot. Keep it in //! line with those, and share their code where bootc has it. //! +//! The deployment is the one the default boot entry would boot, found from the +//! entries on `/boot` and the ESP the same way `bootc status` orders them; that +//! is the offline analogue of the kernel command line a booted system consults. +//! //! Unlike the `install to-*` commands, this deliberately does not enter a //! private mount namespace: the assembled tree is left in the caller's //! namespace, and the caller cleans it up with `umount -R` (or by tearing @@ -18,21 +22,39 @@ use anyhow::{Context, Result, bail, ensure}; use bootc_initramfs_setup::{ Config as SetupRootConfig, MountType, SETUP_ROOT_CONF_PATH, mount_subdir, }; +use bootc_mount::tempmount::TempMount; use camino::{Utf8Path, Utf8PathBuf}; use cap_std_ext::{ cap_std::{ambient_authority, fs::Dir}, dirext::CapStdExtDirExt, }; use clap::Args; +use linux_kernel_cmdline::utf8::Cmdline; use ostree::gio; use ostree_ext::keyfileext::KeyFileExt; use ostree_ext::{ostree, ostree_prepareroot}; use rustix::mount::{MoveMountFlags, OpenTreeFlags, move_mount, open_tree}; -use crate::composefs_consts::STATE_DIR_RELATIVE; +use crate::bootc_composefs::boot::mount_esp_readonly; +use crate::bootc_composefs::status::{ + BootDirProbe, ComposefsCmdline, get_sorted_grub_uki_boot_entries, + get_sorted_type1_boot_entries_helper, +}; +use crate::composefs_consts::{STATE_DIR_RELATIVE, TYPE1_ENT_PATH, USER_CFG}; +use crate::install::ARCH_USES_EFI; +use crate::parsers::bls_config::{BLSConfig, BLSConfigType}; +use crate::spec::Bootloader; const ETC: &str = "etc"; const VAR: &str = "var"; +/// Where `/boot` is expected in the sysroot, as OSTree requires. +const BOOT: &str = "boot"; +/// Where a mounted ESP is looked for in the sysroot when `--esp` is not +/// given, in order: `/efi` as systemd-gpt-auto-generator mounts it, then the +/// traditional `/boot/efi`. +const DEFAULT_ESP_PATHS: &[&str] = &["efi", "boot/efi"]; +/// The kernel argument naming an OSTree deployment's boot symlink. +const OSTREE_KARG: &str = "ostree"; #[derive(Debug, Args, PartialEq, Eq)] pub(crate) struct MountOpts { @@ -40,13 +62,20 @@ pub(crate) struct MountOpts { #[clap(long, value_parser = crate::cli::parse_absolute_path)] pub(crate) sysroot: Utf8PathBuf, - /// Mount the latest deployment. Currently the sysroot must contain exactly one. + /// Mount the deployment the default boot entry boots. /// /// This is required so that other ways to select a deployment can be /// added later without changing what an invocation means. #[clap(long, required = true)] pub(crate) latest: bool, + /// The mounted EFI System Partition, where systemd-boot keeps its boot entries. + /// + /// Defaults to `/efi` or `/boot/efi` if either are mounted, otherwise the ESP will be + /// discovered (if applicable) from the block device(s) backing the target root. + #[clap(long, value_parser = crate::cli::parse_absolute_path, hide = !ARCH_USES_EFI)] + pub(crate) esp: Option, + /// Mount /etc and /var read-only too. The deployment root is always read-only. #[clap(long)] pub(crate) read_only: bool, @@ -70,12 +99,162 @@ fn open_mount_target(target: &Utf8Path) -> Result { Ok(target_dir) } +/// Which backend's deployment a boot entry boots, from its kernel command line. +#[derive(Debug, Clone, PartialEq, Eq)] +enum EntryTarget { + /// A composefs deployment, by its ID. + Composefs(String), + /// An OSTree deployment. Which one is left to OSTree, which orders its + /// deployments from these same entries. + Ostree, +} + +impl EntryTarget { + fn from_cmdline(cmdline: &Cmdline) -> Result> { + if let Some(composefs) = ComposefsCmdline::find_in_cmdline(cmdline)? { + return Ok(Some(Self::Composefs(composefs.digest.into()))); + } + Ok(cmdline.find(OSTREE_KARG).map(|_| Self::Ostree)) + } + + fn from_bls(entry: &BLSConfig) -> Result { + let name = || entry.title.as_deref().unwrap_or_default(); + match &entry.cfg_type { + // bootc names UKIs after the deployment, as `bootc status` relies on. + BLSConfigType::EFI { .. } => Ok(Self::Composefs(entry.get_verity()?)), + BLSConfigType::NonEFI { .. } => { + Self::from_cmdline(entry.get_cmdline()?)?.with_context(|| { + format!( + "default boot entry {:?} has no composefs or {OSTREE_KARG} kernel argument", + name() + ) + }) + } + BLSConfigType::Unknown => bail!("default boot entry {:?} has an unknown type", name()), + } + } +} + +/// Sorted Type 1 entries of `dir` (not the staged ones, which do not boot). +fn type1_entries(dir: &Dir, bootloader: Bootloader) -> Result> { + if !dir.try_exists(TYPE1_ENT_PATH)? { + return Ok(Vec::new()); + } + get_sorted_type1_boot_entries_helper(dir, true, false, bootloader) +} + +/// What the default boot entry on `boot` and `esp` boots, or `None` without entries. +/// +/// This orders entries as `bootc status` does; it does not consult EFI variables +/// (such as a one-time boot entry), boot counting, or a configured default. +fn default_entry_target(boot: &Dir, esp: Option<&Dir>) -> Result> { + // Offline there are no EFI variables to ask, only the files. + let bootloader = BootDirProbe::from_dirs(std::iter::once(boot).chain(esp)).bootloader(); + if bootloader == Bootloader::Systemd { + // Entries may be on the ESP, or on /boot when that is the ESP. + let mut entries = type1_entries(boot, bootloader)?; + if let Some(esp) = esp { + entries.extend(type1_entries(esp, bootloader)?); + } + entries.sort(); + return entries.first().map(EntryTarget::from_bls).transpose(); + } + // bootc's UKI entries for GRUB, which take precedence as in `bootc status`. + if boot.try_exists(format!("grub2/{USER_CFG}"))? { + let mut buf = String::new(); + let menuentries = get_sorted_grub_uki_boot_entries(boot, &mut buf)?; + return menuentries + .first() + .map(|entry| entry.get_verity().map(EntryTarget::Composefs)) + .transpose(); + } + type1_entries(boot, bootloader)? + .first() + .map(EntryTarget::from_bls) + .transpose() +} + +/// Where the ESP was found. +enum Esp { + /// Given by `--esp`, or mounted at one of [`DEFAULT_ESP_PATHS`]. + Mounted(Dir), + /// Discovered from the block devices backing the sysroot, and mounted + /// while the boot entries are read. + Discovered(T), +} + +impl Esp { + fn dir(&self) -> &Dir { + match self { + Self::Mounted(dir) => dir, + Self::Discovered(mount) => &mount.fd, + } + } +} + +/// The ESP when `--esp` is not given: the first of [`DEFAULT_ESP_PATHS`] in +/// `sysroot` that is a mount point, else whatever `discover` finds. +fn find_esp( + sysroot: &Dir, + is_mountpoint: impl Fn(&Dir) -> Result, + discover: impl FnOnce() -> Result>, +) -> Result>> { + for path in DEFAULT_ESP_PATHS { + let Some(dir) = sysroot + .open_dir_optional(path) + .with_context(|| format!("Opening {path}"))? + else { + continue; + }; + if is_mountpoint(&dir).with_context(|| format!("Checking whether {path} is mounted"))? { + tracing::debug!("Using the ESP mounted at {path}"); + return Ok(Some(Esp::Mounted(dir))); + } + } + Ok(discover()?.map(Esp::Discovered)) +} + +/// Find the ESP among the block devices backing `sysroot` as install does, +/// and mount it (or clone its existing mount) to read the boot entries. +fn discover_esp(sysroot: &Dir) -> Result> { + let esps = bootc_blockdev::list_dev_by_dir(sysroot)?.find_colocated_esps()?; + let Some(esp) = esps.as_ref().and_then(|esps| esps.first()) else { + tracing::debug!("No ESP found among the sysroot's block devices"); + return Ok(None); + }; + let path = esp.path(); + tracing::debug!("Using the ESP discovered at {path}"); + mount_esp_readonly(&path) + .with_context(|| format!("Mounting ESP {path}")) + .map(Some) +} + +/// The ESP to read boot entries from when `--esp` is not given; none on +/// architectures without one. +fn default_esp(sysroot: &Dir) -> Result> { + if !ARCH_USES_EFI { + return Ok(None); + } + // As install does, a directory is taken as mounted when that is unknown. + let is_mountpoint = |dir: &Dir| Ok(dir.is_mountpoint(".")?.unwrap_or(true)); + find_esp(sysroot, is_mountpoint, || { + // The ESP is only needed with systemd-boot, and even then a sole + // deployment is found without it, so don't fail on sysroots whose + // devices can't be inspected. + discover_esp(sysroot).or_else(|e| { + eprintln!("warning: could not discover the ESP: {e:#}"); + Ok(None) + }) + }) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum DeploymentBackend { Ostree, Composefs, } +/// Without boot entries to go by, only a sole deployment is unambiguous. fn select_backend( ostree_deployments: usize, composefs_deployments: usize, @@ -85,11 +264,47 @@ fn select_backend( (0, 1) => Ok(DeploymentBackend::Composefs), (0, 0) => bail!("target contains no deployment"), (o, c) => bail!( - "target must contain exactly one deployment (found {o} OSTree, {c} composefs); refusing ambiguous selection" + "found no boot entries to select among {o} OSTree and {c} composefs deployments; \ + mount /boot at SYSROOT/boot and pass --esp" ), } } +/// The deployment selected by `--latest`: the one the default boot entry +/// boots, or without entries the sole deployment. Returns its index in the +/// backend's deployment list. +fn select_deployment( + target: Option<&EntryTarget>, + ostree_deployments: usize, + composefs_deployments: &[String], +) -> Result<(DeploymentBackend, usize)> { + match target { + None => { + let backend = select_backend(ostree_deployments, composefs_deployments.len())?; + Ok((backend, 0)) + } + Some(EntryTarget::Composefs(id)) => composefs_deployments + .iter() + .position(|d| d == id) + .map(|i| (DeploymentBackend::Composefs, i)) + .with_context(|| { + format!( + "default boot entry boots composefs deployment {id}, which is not in {STATE_DIR_RELATIVE}" + ) + }), + // OSTree loads its deployments from the entries in version order, the + // default first, as `bootc status` shows them. Don't re-sort them here: + // e.g. GRUB's file name order differs from that at 10 deployments. + Some(EntryTarget::Ostree) => { + ensure!( + ostree_deployments > 0, + "default boot entry boots an OSTree deployment, but OSTree found none in /boot" + ); + Ok((DeploymentBackend::Ostree, 0)) + } + } +} + pub(crate) async fn mount(opts: MountOpts) -> Result<()> { // clap already requires --latest, the only selector so far. ensure!(opts.latest, "no deployment selected; pass --latest"); @@ -125,63 +340,90 @@ pub(crate) async fn mount(opts: MountOpts) -> Result<()> { .transpose()? .unwrap_or_default(); - let (root_tree, state) = - match select_backend(ostree_deployments.len(), composefs_deployments.len())? { - DeploymentBackend::Composefs => { - let id = &composefs_deployments[0]; - let state = sysroot_dir - .open_dir(format!("{STATE_DIR_RELATIVE}/{id}")) - .with_context(|| format!("Opening composefs deployment state {id}"))?; - let repo = crate::bootc_composefs::repo::open_composefs_repo(&sysroot_dir)?; - let image = repo.mount(id).context("Mounting composefs image")?; - (image, DeploymentState::Composefs(state)) - } - DeploymentBackend::Ostree => { - let sysroot = ostree_sysroot.as_deref().expect("OSTree backend selected"); - let repo = ostree_repo.as_ref().expect("OSTree backend selected"); - let deployment = &ostree_deployments[0]; - let source = sysroot.deployment_dirpath(deployment); - let deployment_dir = sysroot_dir - .open_dir(source.as_str()) - .with_context(|| format!("Opening OSTree deployment {source}"))?; - let var = sysroot_dir - .open_dir(format!("ostree/deploy/{}/{VAR}", deployment.stateroot())) - .context("Opening OSTree stateroot /var")?; - let config = ostree_prepareroot::load_config_from_root(&deployment_dir) - .context("Loading the deployment's prepare-root.conf")?; - let etc_transient = config - .as_ref() - .map(|config| config.optional_bool("etc", "transient")) - .transpose() - .context("Parsing etc.transient")? - .flatten() - .unwrap_or_default(); - let composefs = ostree_prepareroot::mount_composefs( - &deployment_dir, - repo, - deployment.csum().as_str(), - config.as_ref(), - )?; - let root_tree = match composefs { - Some(root_tree) => root_tree, - // Without composefs, prepare-root uses the checkout itself. - None => open_tree( - &sysroot_dir, - source.as_str(), - OpenTreeFlags::OPEN_TREE_CLONE | OpenTreeFlags::OPEN_TREE_CLOEXEC, - ) - .context("Cloning OSTree deployment tree")?, - }; - ( - root_tree, - DeploymentState::Ostree { - deployment: deployment_dir, - var, - etc_transient, - }, + // The boot entries say which deployment boots. OSTree reads /boot at the + // same place when loading its deployments. + let boot = sysroot_dir + .open_dir_optional(BOOT) + .with_context(|| format!("Opening {}/{BOOT}", opts.sysroot))?; + let esp = match &opts.esp { + Some(esp) => Some(Esp::Mounted( + Dir::open_ambient_dir(esp, ambient_authority()) + .with_context(|| format!("Opening ESP {esp}"))?, + )), + None => default_esp(&sysroot_dir) + .with_context(|| format!("Finding the ESP of {}", opts.sysroot))?, + }; + let entry_target = boot + .as_ref() + .map(|boot| default_entry_target(boot, esp.as_ref().map(Esp::dir))) + .transpose() + .context("Finding the default boot entry")? + .flatten(); + // Unmount a discovered ESP now; it is not part of the deployment mount. + drop(esp); + tracing::debug!("Default boot entry boots {entry_target:?}"); + let (backend, index) = select_deployment( + entry_target.as_ref(), + ostree_deployments.len(), + &composefs_deployments, + )?; + + let (root_tree, state) = match backend { + DeploymentBackend::Composefs => { + let id = &composefs_deployments[index]; + let state = sysroot_dir + .open_dir(format!("{STATE_DIR_RELATIVE}/{id}")) + .with_context(|| format!("Opening composefs deployment state {id}"))?; + let repo = crate::bootc_composefs::repo::open_composefs_repo(&sysroot_dir)?; + let image = repo.mount(id).context("Mounting composefs image")?; + (image, DeploymentState::Composefs(state)) + } + DeploymentBackend::Ostree => { + let sysroot = ostree_sysroot.as_deref().expect("OSTree backend selected"); + let repo = ostree_repo.as_ref().expect("OSTree backend selected"); + let deployment = &ostree_deployments[index]; + let source = sysroot.deployment_dirpath(deployment); + let deployment_dir = sysroot_dir + .open_dir(source.as_str()) + .with_context(|| format!("Opening OSTree deployment {source}"))?; + let var = sysroot_dir + .open_dir(format!("ostree/deploy/{}/{VAR}", deployment.stateroot())) + .context("Opening OSTree stateroot /var")?; + let config = ostree_prepareroot::load_config_from_root(&deployment_dir) + .context("Loading the deployment's prepare-root.conf")?; + let etc_transient = config + .as_ref() + .map(|config| config.optional_bool("etc", "transient")) + .transpose() + .context("Parsing etc.transient")? + .flatten() + .unwrap_or_default(); + let composefs = ostree_prepareroot::mount_composefs( + &deployment_dir, + repo, + deployment.csum().as_str(), + config.as_ref(), + )?; + let root_tree = match composefs { + Some(root_tree) => root_tree, + // Without composefs, prepare-root uses the checkout itself. + None => open_tree( + &sysroot_dir, + source.as_str(), + OpenTreeFlags::OPEN_TREE_CLONE | OpenTreeFlags::OPEN_TREE_CLOEXEC, ) - } - }; + .context("Cloning OSTree deployment tree")?, + }; + ( + root_tree, + DeploymentState::Ostree { + deployment: deployment_dir, + var, + etc_transient, + }, + ) + } + }; bootc_initramfs_setup::set_mount_readonly(&root_tree) .context("Making detached deployment root read-only")?; @@ -292,11 +534,244 @@ fn attach(tree: impl AsFd, target: &Dir, name: &str) -> Result<()> { #[cfg(test)] mod tests { - use super::{DeploymentBackend, open_mount_target, select_backend}; + use super::*; use crate::cli::{InstallOpts, Opt}; - use camino::Utf8Path; + use cap_std_ext::cap_tempfile; use clap::Parser; + // fs-verity SHA-512 digests, as composefs deployment IDs are. + const DIGEST_A: &str = "7e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b67e11ac46e3e022053e7226a20104ac656bf72d1a84e3a398b7cce70e9df188b6"; + const DIGEST_B: &str = "febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01febdf62805de2ae7b6b597f2a9775d9c8a753ba1e5f09298fc8fbe0b0d13bf01"; + const BOOTCSUM: &str = "0a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f9"; + + fn composefs_type1(version: &str, sort_key: &str, digest: &str) -> String { + format!( + "title bootc {version}\nversion {version}\nsort-key {sort_key}\n\ + linux /bootc_composefs-{digest}/vmlinuz\ninitrd /bootc_composefs-{digest}/initrd\n\ + options root=UUID=abc rw composefs={digest}\n" + ) + } + + fn uki_type1(version: &str, sort_key: &str, digest: &str) -> String { + format!( + "title bootc {version}\nversion {version}\nsort-key {sort_key}\n\ + uki /EFI/Linux/bootc/bootc_composefs-{digest}.efi\n" + ) + } + + fn ostree_type1(version: &str, serial: u32) -> String { + format!( + "title ostree {version}\nversion {version}\n\ + linux /ostree/default-{BOOTCSUM}/vmlinuz\ninitrd /ostree/default-{BOOTCSUM}/initramfs.img\n\ + options root=UUID=abc rw ostree=/ostree/boot.1/default/{BOOTCSUM}/{serial}\n" + ) + } + + fn grub_uki_menuentry(digest: &str) -> String { + format!( + "menuentry \"bootc: ({digest})\" {{\n insmod fat\n insmod chain\n \ + search --no-floppy --set=root --fs-uuid \"${{EFI_PART_UUID}}\"\n \ + chainloader /EFI/Linux/bootc/bootc_composefs-{digest}.efi\n}}\n" + ) + } + + #[test] + fn default_entry_target_follows_bootloader_order() -> Result<()> { + let composefs = |d: &str| Some(EntryTarget::Composefs(d.into())); + // (description, files in /boot, files on the ESP, expected) + let cases: Vec<(&str, Vec<(&str, String)>, Vec<(&str, String)>, _)> = vec![ + ("no entries", vec![], vec![], None), + ( + // GRUB sorts by file name, highest first, whatever the sort-key. + "grub type1", + vec![ + ("grub2/grub.cfg", String::new()), + ( + "loader/entries/bootc_os-1-0.conf", + composefs_type1("1", "0", DIGEST_A), + ), + ( + "loader/entries/bootc_os-2-1.conf", + composefs_type1("2", "9", DIGEST_B), + ), + ], + vec![], + composefs(DIGEST_B), + ), + ( + "grub uki", + vec![( + "grub2/user.cfg", + grub_uki_menuentry(DIGEST_A) + &grub_uki_menuentry(DIGEST_B), + )], + vec![], + composefs(DIGEST_A), + ), + ( + // systemd-boot sorts by sort-key; staged entries do not boot. + "systemd-boot on the ESP", + vec![], + vec![ + ("loader/entries/a.conf", uki_type1("1", "2", DIGEST_A)), + ("loader/entries/b.conf", uki_type1("2", "1", DIGEST_B)), + ( + "loader/entries.staged/c.conf", + uki_type1("3", "0", DIGEST_A), + ), + ], + composefs(DIGEST_B), + ), + ( + "systemd-boot with the ESP as /boot", + vec![ + ("loader/entries/a.conf", composefs_type1("1", "1", DIGEST_A)), + ("loader/entries/b.conf", composefs_type1("2", "2", DIGEST_B)), + ], + vec![], + composefs(DIGEST_A), + ), + ( + "ostree on grub", + vec![ + ("grub2/grub.cfg", String::new()), + ("loader/entries/ostree-1.conf", ostree_type1("1", 1)), + ("loader/entries/ostree-2.conf", ostree_type1("2", 0)), + ], + vec![], + Some(EntryTarget::Ostree), + ), + ]; + for (desc, boot_files, esp_files, expected) in cases { + let boot = cap_tempfile::tempdir(ambient_authority())?; + let esp = cap_tempfile::tempdir(ambient_authority())?; + for (dir, files) in [(&boot, &boot_files), (&esp, &esp_files)] { + for (path, contents) in files { + dir.create_dir_all(Utf8Path::new(path).parent().unwrap())?; + dir.atomic_write(path, contents)?; + } + } + let found = + default_entry_target(&boot, Some(&esp)).with_context(|| desc.to_string())?; + assert_eq!(found, expected, "{desc}"); + } + Ok(()) + } + + #[test] + fn finds_esp_in_preference_order() -> Result<()> { + const MOUNTED: &str = "mounted"; + const DISCOVERED: &str = "discovered"; + // (description, directories in the sysroot, those mounted, whether + // discovery finds an ESP, expected) + let cases: &[(&str, &[&str], &[&str], bool, Option<&str>)] = &[ + ("nothing", &[], &[], false, None), + ( + "unmounted directories", + &["efi", "boot/efi"], + &[], + false, + None, + ), + ( + "discovered when nothing is mounted", + &["efi", "boot/efi"], + &[], + true, + Some(DISCOVERED), + ), + ("/efi", &["efi"], &["efi"], true, Some("efi")), + ( + "/efi over /boot/efi", + &["efi", "boot/efi"], + &["efi", "boot/efi"], + true, + Some("efi"), + ), + ( + "/boot/efi when /efi is not mounted", + &["efi", "boot/efi"], + &["boot/efi"], + true, + Some("boot/efi"), + ), + ]; + for (desc, dirs, mounted, discoverable, expected) in cases { + let sysroot = cap_tempfile::tempdir(ambient_authority())?; + for dir in *dirs { + sysroot.create_dir_all(dir)?; + } + // Mark the "mounted" directories with their own path. + for dir in *mounted { + sysroot.atomic_write(format!("{dir}/{MOUNTED}"), dir)?; + } + let is_mountpoint = |dir: &Dir| Ok(dir.try_exists(MOUNTED)?); + let found = find_esp(&sysroot, is_mountpoint, || { + Ok(discoverable.then_some(DISCOVERED)) + }) + .with_context(|| desc.to_string())?; + let found = match &found { + None => None, + Some(Esp::Mounted(dir)) => Some(dir.read_to_string(MOUNTED)?), + Some(Esp::Discovered(d)) => Some(d.to_string()), + }; + assert_eq!(found.as_deref(), *expected, "{desc}"); + } + Ok(()) + } + + #[test] + fn entry_target_from_cmdline() -> Result<()> { + let cases = [ + ("root=UUID=abc rw", None), + ( + &*format!("rw composefs={DIGEST_A}"), + Some(EntryTarget::Composefs(DIGEST_A.into())), + ), + ( + &*format!("rw ostree=/ostree/boot.0/default/{BOOTCSUM}/2"), + Some(EntryTarget::Ostree), + ), + ]; + for (cmdline, expected) in cases { + let found = EntryTarget::from_cmdline(&Cmdline::from(cmdline))?; + assert_eq!(found, expected, "{cmdline}"); + } + Ok(()) + } + + #[test] + fn selects_deployment_of_default_entry() { + let composefs = [DIGEST_A.to_string(), DIGEST_B.to_string()]; + // (target, OSTree deployments, expected) + let cases = [ + ( + Some(EntryTarget::Composefs(DIGEST_B.into())), + 2, + Some((DeploymentBackend::Composefs, 1)), + ), + (Some(EntryTarget::Composefs("missing".into())), 2, None), + // OSTree's first deployment is its default, however many there are. + ( + Some(EntryTarget::Ostree), + 12, + Some((DeploymentBackend::Ostree, 0)), + ), + (Some(EntryTarget::Ostree), 0, None), + // Without boot entries, only a sole deployment is selected. + (None, 2, None), + (None, 1, None), + (None, 0, None), + ]; + for (target, ostree, expected) in cases { + let found = select_deployment(target.as_ref(), ostree, &composefs).ok(); + assert_eq!(found, expected, "{target:?} {ostree}"); + } + assert_eq!( + select_deployment(None, 0, &composefs[..1]).unwrap(), + (DeploymentBackend::Composefs, 0) + ); + } + #[test] fn requires_deployment_selector() { let cases: &[(&[&str], bool)] = &[ diff --git a/docs/src/man/bootc-install-mount.8.md b/docs/src/man/bootc-install-mount.8.md index b562fe9d3..0d314eed9 100644 --- a/docs/src/man/bootc-install-mount.8.md +++ b/docs/src/man/bootc-install-mount.8.md @@ -4,7 +4,7 @@ bootc-install-mount - Mount an installed deployment into a caller-owned director # SYNOPSIS -bootc install mount **--sysroot**=*SYSROOT* **--latest** [**--read-only**] *TARGET* +bootc install mount **--sysroot**=*SYSROOT* **--latest** [**--esp**=*ESP*] [**--read-only**] *TARGET* # DESCRIPTION @@ -14,11 +14,28 @@ and composefs backends are supported. **TARGET** is an absolute path to a directory; as with **mount**(8), whatever is there is hidden (bootc warns if it is not empty). -A deployment selector is required; currently the only one is **--latest**. -For now the sysroot must also contain exactly one deployment, as it does -right after installation; otherwise bootc refuses to guess which one to -mount. Requiring the selector leaves room to choose among multiple -deployments in the future without changing what existing invocations mean. +A deployment selector is required; currently the only one is **--latest**, +which mounts the deployment the default boot entry boots, as the kernel +command line of a booted system would name it. Requiring the selector leaves +room to choose among multiple deployments in the future without changing +what existing invocations mean. + +bootc reads the boot entries from *SYSROOT*`/boot` and the ESP. If `/boot` +is a separate partition, mount it at *SYSROOT*`/boot` first, where OSTree +also expects it. The ESP is **--esp** if given. Otherwise, on architectures +that use an ESP, it is *SYSROOT*`/efi` or *SYSROOT*`/boot/efi` if either is +mounted (in that order, `/efi` being where **systemd-gpt-auto-generator**(8) +mounts it), or else the ESP bootc discovers from the block device(s) backing +*SYSROOT*, as **bootc-install**(8) does, mounting it while reading the +entries. The entries are ordered as **bootc-status**(8) orders them. +For GRUB, the default entry is the first of `grub2/user.cfg` (UKIs) or else +the Type 1 entry whose file name sorts last; for systemd-boot, the first +Type 1 entry on the ESP (or in `/boot`) in Boot Loader Specification order. +If that entry boots an OSTree deployment, the deployment is OSTree's default, +the first in its own order. Staged entries, EFI variables (such as a one-time boot +entry), boot counting and a configured default are not considered. When no +boot entries are found at all, the sysroot must contain exactly one +deployment, as it does right after installation. The mount remains in the caller's mount namespace after this command exits; bootc does not create a container, chroot, or private mount namespace. @@ -69,7 +86,11 @@ that provides those, such as `bwrap`, `podman run --rootfs`, or **--latest** - Mount the latest deployment. Currently the sysroot must contain exactly one + Mount the deployment the default boot entry boots + +**--esp**=*ESP* + + The mounted EFI System Partition, where systemd-boot keeps its boot entries **--read-only** diff --git a/tmt/tests/booted/test-install-outside-container.nu b/tmt/tests/booted/test-install-outside-container.nu index af1565bea..282775d36 100644 --- a/tmt/tests/booted/test-install-outside-container.nu +++ b/tmt/tests/booted/test-install-outside-container.nu @@ -262,6 +262,82 @@ if not (tap is_composefs) { assert (not ($deployment | path join etc/transient-sentinel | path exists)) "transient /etc writes must not persist" } +# --latest follows the default boot entry rather than counting deployments. +let esp = "/var/mnt/install-mount-esp" +mkdir $esp +mount (discover_target_partitions $loop).esp $esp +if (tap is_composefs) { + # Point every boot entry at a deployment that does not exist: the mount + # must then fail, naming it, whichever bootloader reads which entries. + let id = ($deployment | path basename) + let missing = ($id | str replace -a -r "." "f") + let entries = ([ + (glob $"($sysroot)/boot/loader/entries/*.conf") + (glob $"($sysroot)/boot/grub2/user.cfg") + (glob $"($esp)/loader/entries/*.conf") + ] | flatten) + assert (($entries | length) > 0) "the installation must have boot entries" + for f in $entries { open --raw $f | into binary | decode utf-8 | str replace -a $id $missing | save -f $f } + let assert_follows_entries = {|result: record, desc: string| + assert ($result.exit_code != 0) $"install mount must follow the default boot entry \(($desc)\)" + assert ($result.stderr | str contains $missing) $"unexpected error \(($desc)\): ($result.stderr)" + } + # With neither --esp nor a mounted ESP, it is discovered from the disk. + do $assert_follows_entries (do { ^bootc install mount --sysroot $sysroot --latest $dest } | complete) "discovered" + do $assert_follows_entries (do { ^bootc install mount --sysroot $sysroot --esp $esp --latest $dest } | complete) "--esp" + # A mounted /boot/efi comes before discovery: an empty one hides the + # entries BLS bootloaders (systemd-boot, grub-cc) keep on the ESP, + # leaving the sole deployment. + let entries_on_esp = ((glob $"($esp)/loader/entries/*.conf" | length) > 0) + let efi = ($sysroot | path join efi) + let boot_efi = ($sysroot | path join boot/efi) + mkdir $efi $boot_efi + mount -t tmpfs tmpfs $boot_efi + let result = (do { ^bootc install mount --sysroot $sysroot --latest $dest } | complete) + if $entries_on_esp { + assert equal $result.exit_code 0 $"install mount must read the ESP at /boot/efi: ($result.stderr)" + umount -R -l $dest + } else { + do $assert_follows_entries $result "empty /boot/efi" + } + # And a mounted /efi comes before /boot/efi. + mount --bind $esp $efi + do $assert_follows_entries (do { ^bootc install mount --sysroot $sysroot --latest $dest } | complete) "/efi" + umount -l $efi $boot_efi + for f in $entries { open --raw $f | into binary | decode utf-8 | str replace -a $missing $id | save -f $f } + bootc install mount --sysroot $sysroot --esp $esp --latest $dest + assert equal (open ($dest | path join etc/sentinel)) "etc sentinel" + umount -R -l $dest +} else { + # --latest must pick OSTree's default deployment. Use more than ten, so + # that sorting the entries by file name (ostree-10 before ostree-9) would + # pick the wrong one. + # Drop the etc.transient config from above, so /etc is the persistent + # copy again and can carry a marker per deployment. + rm ($deployment | path join etc/ostree/prepare-root.conf) + let csum = ($deployment | path basename | split row "." | first) + for _ in 1..10 { + ostree admin deploy --sysroot $sysroot --os default --retain $csum + } + let deployments = (ls ($sysroot | path join $state.deployments) | where type == dir | get name) + assert equal ($deployments | length) 11 + # Each deployment of the same commit is told apart by its serial; the + # newest (serial 10) is the default until set-default moves another one + # (index 5 in newest-first order, serial 5) to the front. + for d in $deployments { + $d | path basename | split row "." | last | save -f ($d | path join etc/which) + } + for case in [[default expected]; [null "10"] [5 "5"]] { + if $case.default != null { + ostree admin set-default --sysroot $sysroot $case.default + } + bootc install mount --sysroot $sysroot --latest $dest + assert equal (open ($dest | path join etc/which)) $case.expected + umount -R -l $dest + } +} +umount -l $esp + umount -l $sysroot losetup -d $loop