From 9273143c13cf04c6ac18357295aeeee78f387560 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Mon, 28 Sep 2026 20:54:03 -0400 Subject: [PATCH] install mount: Select the deployment from the default boot entry `--latest` counted deployments and refused anything but exactly one. That fails on any installation that has been upgraded, and it can't choose when a sysroot holds both OSTree and composefs deployments (e.g. mid-migration). A booted system answers "which deployment" from its kernel command line. Offline, the equivalent is the command line of the boot entry the bootloader would pick. So read the entries from /boot and the ESP and order them as `bootc status` does, sharing its bootloader probe (now taking the boot directories rather than looking at the host's /boot) and entry sorting. A composefs default entry names its deployment by digest (or UKI name). For an `ostree=` one, OSTree's own deployment order, which it loads from the same entries, already has the default first, so use that rather than re-sorting the entries: GRUB's file name order differs from it at ten or more deployments. /boot has to be at SYSROOT/boot, since OSTree loads its deployments from there. The ESP comes from a new `--esp`, defaulting to a mounted SYSROOT/efi, then SYSROOT/boot/efi, matching what systemd-gpt-auto-generator would set up, and otherwise to the ESP discovered from the disks backing the sysroot, as install finds it. Architectures without an ESP skip all that. With no entries at all, e.g. when no ESP is found on a systemd-boot install, a sole deployment is still selected as before. Generated-by: AI --- crates/lib/src/bootc_composefs/status.rs | 191 ++++-- crates/lib/src/mount.rs | 597 ++++++++++++++++-- docs/src/man/bootc-install-mount.8.md | 35 +- .../booted/test-install-outside-container.nu | 76 +++ 4 files changed, 764 insertions(+), 135 deletions(-) diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index dcc1282fe8..4212169b53 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 56d3870a1c..89d97afcca 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 b562fe9d33..0d314eed97 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 af1565bea8..282775d363 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