From e1a15dcc91bb7306f2d69c34d0dd5a9d1e83f233 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Mon, 21 Sep 2026 13:27:27 -0400 Subject: [PATCH 1/2] install: Simplify offline deployment mounting Keep the explicit mount operation caller-owned and backend-neutral without persisting lifecycle state or reconstructing teardown across processes. Existing mount namespaces and recursive unmount provide the lifecycle boundary, while composefs mounting stays next to repository selection. Require --latest to select the deployment, even though only a single deployment is supported for now, so that choosing among several can be added later without changing what existing invocations mean. Assisted-by: AI Signed-off-by: Colin Walters --- crates/initramfs/src/lib.rs | 25 +- crates/lib/src/bootc_composefs/gc.rs | 4 + crates/lib/src/cli.rs | 5 +- crates/lib/src/install.rs | 9 +- crates/lib/src/lib.rs | 1 + crates/lib/src/mount.rs | 354 ++++++++++++++++++++ crates/mount/src/mount.rs | 7 + crates/ostree-ext/src/ostree_prepareroot.rs | 117 ++++++- 8 files changed, 508 insertions(+), 14 deletions(-) create mode 100644 crates/lib/src/mount.rs diff --git a/crates/initramfs/src/lib.rs b/crates/initramfs/src/lib.rs index 3a5ec13891..ed989c57da 100644 --- a/crates/initramfs/src/lib.rs +++ b/crates/initramfs/src/lib.rs @@ -67,9 +67,9 @@ fn mount_setattr(fd: impl AsFd, flags: libc::c_int, attr: &MountAttr) -> Result< Ok(()) } -/// Set mount to readonly +/// Set a detached mount tree read-only without reopening its path. #[context("Setting mount readonly")] -fn set_mount_readonly(fd: impl AsFd) -> Result<()> { +pub fn set_mount_readonly(fd: impl AsFd) -> Result<()> { let attr = MountAttr { attr_set: MOUNT_ATTR_RDONLY, attr_clr: 0, @@ -79,6 +79,18 @@ fn set_mount_readonly(fd: impl AsFd) -> Result<()> { mount_setattr(fd, libc::AT_EMPTY_PATH, &attr) } +/// Set a mount and every mount below it read-only. +#[context("Setting mount tree readonly")] +pub fn set_mount_tree_readonly(fd: impl AsFd) -> Result<()> { + let attr = MountAttr { + attr_set: MOUNT_ATTR_RDONLY, + attr_clr: 0, + propagation: 0, + userns_fd: 0, + }; + mount_setattr(fd, libc::AT_EMPTY_PATH | libc::AT_RECURSIVE, &attr) +} + /// Types of mounts supported by the configuration #[derive(Clone, Copy, Debug, Deserialize, PartialEq)] #[serde(rename_all = "lowercase")] @@ -111,12 +123,15 @@ pub struct MountConfig { pub transient: bool, } +/// The setup-root configuration, see `bootc-setup-root-conf(5)`. #[derive(Debug, Deserialize, Default, PartialEq)] -struct Config { +pub struct Config { + /// How `/etc` is mounted #[serde(default)] - etc: MountConfig, + pub etc: MountConfig, + /// How `/var` is mounted #[serde(default)] - var: MountConfig, + pub var: MountConfig, #[serde(default)] root: RootConfig, } diff --git a/crates/lib/src/bootc_composefs/gc.rs b/crates/lib/src/bootc_composefs/gc.rs index a8687c741c..5e0e771588 100644 --- a/crates/lib/src/bootc_composefs/gc.rs +++ b/crates/lib/src/bootc_composefs/gc.rs @@ -37,7 +37,11 @@ fn list_state_dirs(sysroot: &Dir) -> Result> { let state = sysroot .open_dir(STATE_DIR_RELATIVE) .context("Opening state dir")?; + list_deployment_state_dirs(&state) +} +/// List the per-deployment directories in an opened composefs state directory. +pub(crate) fn list_deployment_state_dirs(state: &Dir) -> Result> { let mut dirs = vec![]; for dir in state.entries_utf8()? { diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index bc11e07e2d..ababc5f280 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -303,6 +303,8 @@ pub(crate) struct UsrOverlayOpts { #[derive(Debug, clap::Subcommand, PartialEq, Eq)] pub(crate) enum InstallOpts { + /// Mount an installed deployment into a caller-owned directory. + Mount(crate::mount::MountOpts), /// Install to the target block device. /// /// This command must be invoked inside of the container, which will be @@ -735,7 +737,7 @@ pub(crate) enum SelinuxOpts { }, } -fn parse_absolute_path(value: &str) -> std::result::Result { +pub(crate) fn parse_absolute_path(value: &str) -> std::result::Result { let path = Utf8PathBuf::from(value); if path.is_absolute() { Ok(path) @@ -2370,6 +2372,7 @@ async fn run_from_opt(opt: Opt) -> Result { } }, Opt::Install(opts) => match opts { + InstallOpts::Mount(opts) => crate::mount::mount(opts).await, #[cfg(feature = "install-to-disk")] InstallOpts::ToDisk(opts) => crate::install::install_to_disk(opts).await, InstallOpts::ToFilesystem(opts) => { diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 38866768f6..4dec3d02b6 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -224,8 +224,6 @@ const ALONGSIDE_ROOT_MOUNT: &str = "/target"; pub(crate) const DESTRUCTIVE_CLEANUP: &str = "etc/bootc-destructive-cleanup"; /// This is an ext4 special directory we need to ignore. const LOST_AND_FOUND: &str = "lost+found"; -/// The filename of the composefs EROFS superblock; TODO move this into ostree -const OSTREE_COMPOSEFS_SUPER: &str = ".ostree.cfs"; /// The mount path for selinux const SELINUXFS: &str = "/sys/fs/selinux"; /// The mount path for uefi @@ -1277,11 +1275,14 @@ async fn install_container( .with_context(|| format!("Recursive SELinux relabeling of {d}"))?; } - if let Some(cfs_super) = root.open_optional(OSTREE_COMPOSEFS_SUPER)? { + if let Some(cfs_super) = root.open_optional(ostree_prepareroot::COMPOSEFS_IMAGE)? { let label = crate::lsm::require_label(policy, "/usr".into(), 0o644)?; crate::lsm::set_security_selinux(cfs_super.as_fd(), label.as_bytes())?; } else { - tracing::warn!("Missing {OSTREE_COMPOSEFS_SUPER}; composefs is not enabled?"); + tracing::warn!( + "Missing {}; composefs is not enabled?", + ostree_prepareroot::COMPOSEFS_IMAGE + ); } } diff --git a/crates/lib/src/lib.rs b/crates/lib/src/lib.rs index d9eccc0c81..09a646c756 100644 --- a/crates/lib/src/lib.rs +++ b/crates/lib/src/lib.rs @@ -85,6 +85,7 @@ mod lints; mod loader_entries; mod lsm; pub(crate) mod metadata; +mod mount; mod parsers; mod podman; pub(crate) mod podman_client; diff --git a/crates/lib/src/mount.rs b/crates/lib/src/mount.rs new file mode 100644 index 0000000000..56d3870a1c --- /dev/null +++ b/crates/lib/src/mount.rs @@ -0,0 +1,354 @@ +//! Explicit, caller-owned mounts of an offline deployment. +//! +//! Conceptually this redoes what the initramfs does at boot +//! (ostree-prepare-root for OSTree, bootc-initramfs-setup for composefs), only +//! for a deployment in an offline sysroot and at a directory the caller picks +//! instead of `/sysroot`: the root is the deployment's composefs image, with +//! `/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. +//! +//! 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 +//! down its own namespace). bootc keeps no state about the mount. + +use std::os::fd::{AsFd, AsRawFd}; + +use anyhow::{Context, Result, bail, ensure}; +use bootc_initramfs_setup::{ + Config as SetupRootConfig, MountType, SETUP_ROOT_CONF_PATH, mount_subdir, +}; +use camino::{Utf8Path, Utf8PathBuf}; +use cap_std_ext::{ + cap_std::{ambient_authority, fs::Dir}, + dirext::CapStdExtDirExt, +}; +use clap::Args; +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; + +const ETC: &str = "etc"; +const VAR: &str = "var"; + +#[derive(Debug, Args, PartialEq, Eq)] +pub(crate) struct MountOpts { + /// Offline target sysroot. + #[clap(long, value_parser = crate::cli::parse_absolute_path)] + pub(crate) sysroot: Utf8PathBuf, + + /// Mount the latest deployment. Currently the sysroot must contain exactly one. + /// + /// 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, + + /// Mount /etc and /var read-only too. The deployment root is always read-only. + #[clap(long)] + pub(crate) read_only: bool, + + /// Directory receiving the deployment mount. + #[clap(value_parser = crate::cli::parse_absolute_path)] + pub(crate) target: Utf8PathBuf, +} + +/// Open the mount target. Like mount(8), this mounts wherever the caller asks; +/// like systemd, it warns when that hides existing content. +fn open_mount_target(target: &Utf8Path) -> Result { + let target_dir = Dir::open_ambient_dir(target, ambient_authority()) + .with_context(|| format!("Opening mount target {target}"))?; + let mut entries = target_dir + .entries() + .with_context(|| format!("Reading mount target {target}"))?; + if entries.next().is_some() { + eprintln!("warning: mount target {target} is not empty; its contents will be hidden"); + } + Ok(target_dir) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DeploymentBackend { + Ostree, + Composefs, +} + +fn select_backend( + ostree_deployments: usize, + composefs_deployments: usize, +) -> Result { + match (ostree_deployments, composefs_deployments) { + (1, 0) => Ok(DeploymentBackend::Ostree), + (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" + ), + } +} + +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"); + let target = &opts.target; + let target_dir = open_mount_target(target)?; + let sysroot_dir = Dir::open_ambient_dir(&opts.sysroot, ambient_authority()) + .with_context(|| format!("Opening target sysroot {}", opts.sysroot))?; + + // The OSTree lock is held until the mount is assembled, so a concurrent + // OSTree operation on the offline sysroot cannot prune the deployment from + // under us. + let ostree_repo = sysroot_dir.open_dir_optional("ostree/repo")?; + let ostree_sysroot = if ostree_repo.is_some() { + // ostree only takes a path; go through the fd we already opened. + let path = format!("/proc/self/fd/{}", sysroot_dir.as_raw_fd()); + let sysroot = ostree::Sysroot::new(Some(&gio::File::for_path(path))); + sysroot + .load(gio::Cancellable::NONE) + .context("Loading target OSTree sysroot")?; + Some(ostree_ext::sysroot::SysrootLock::new_from_sysroot(&sysroot).await?) + } else { + None + }; + let ostree_deployments = ostree_sysroot + .as_ref() + .map(|s| s.deployments()) + .unwrap_or_default(); + // Check the state directory rather than the repository: OSTree systems + // using unified storage also have a composefs repository. + let composefs_deployments = sysroot_dir + .open_dir_optional(STATE_DIR_RELATIVE)? + .map(|state| crate::bootc_composefs::gc::list_deployment_state_dirs(&state)) + .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, + }, + ) + } + }; + + bootc_initramfs_setup::set_mount_readonly(&root_tree) + .context("Making detached deployment root read-only")?; + move_mount( + &root_tree, + "", + &target_dir, + ".", + MoveMountFlags::MOVE_MOUNT_F_EMPTY_PATH, + ) + .context("Attaching deployment root")?; + let assembly = (|| -> Result<()> { + // Reopen by path: target_dir still refers to the directory underneath the new mount. + let target_root = Dir::open_ambient_dir(target, ambient_authority()) + .context("Opening mounted deployment root")?; + match &state { + DeploymentState::Composefs(state) => mount_composefs_state(&target_root, state)?, + DeploymentState::Ostree { + deployment, + var, + etc_transient, + } => mount_ostree_state(&target_root, deployment, var, *etc_transient)?, + } + if opts.read_only { + bootc_initramfs_setup::set_mount_tree_readonly(&target_root) + .context("Making /etc and /var read-only")?; + } + Ok(()) + })(); + if let Err(error) = assembly { + return Err(match bootc_mount::unmount_recursive(target) { + Ok(()) => error, + Err(cleanup_error) => { + error.context(format!("cleanup of {target} also failed: {cleanup_error}")) + } + }) + .context("Assembling offline deployment mount"); + } + Ok(()) +} + +/// Where the machine-local state of the selected deployment lives. +enum DeploymentState { + /// The composefs per-deployment state directory. + Composefs(Dir), + /// The OSTree deployment directory, its stateroot's `/var`, and whether + /// prepare-root.conf enables `etc.transient`. + Ostree { + deployment: Dir, + var: Dir, + etc_transient: bool, + }, +} + +/// Mount `/etc` and `/var` as the composefs initramfs does at boot, honoring +/// the image's setup-root configuration (for example a transient `/etc`). +fn mount_composefs_state(root: &Dir, state: &Dir) -> Result<()> { + let config_path = SETUP_ROOT_CONF_PATH.trim_start_matches('/'); + let config: SetupRootConfig = root + .read_to_string_optional(config_path) + .with_context(|| format!("Reading {SETUP_ROOT_CONF_PATH}"))? + .map(|text| toml::from_str(&text)) + .transpose() + .with_context(|| format!("Parsing {SETUP_ROOT_CONF_PATH}"))? + .unwrap_or_default(); + mount_subdir(root, state, ETC, config.etc, MountType::Bind)?; + mount_subdir(root, state, VAR, config.var, MountType::Bind)?; + Ok(()) +} + +/// Mount `/etc` and `/var` as ostree-prepare-root does at boot: `/etc` is the +/// deployment's persistent copy, or a transient overlay of `/usr/etc` when +/// `prepare-root.conf` enables `etc.transient`. +fn mount_ostree_state(root: &Dir, deployment: &Dir, var: &Dir, etc_transient: bool) -> Result<()> { + if etc_transient { + let usr_etc = root.open_dir("usr/etc").context("Opening /usr/etc")?; + let overlay = bootc_initramfs_setup::overlay_transient(&usr_etc, "transient", None)?; + attach(&overlay, root, ETC)?; + } else { + let etc = deployment + .open_dir(ETC) + .context("Opening OSTree deployment /etc")?; + bind(&etc, root, ETC)?; + } + bind(var, root, VAR) +} + +fn bind(source: &Dir, target: &Dir, name: &str) -> Result<()> { + let tree = open_tree( + source, + ".", + OpenTreeFlags::OPEN_TREE_CLONE | OpenTreeFlags::OPEN_TREE_CLOEXEC, + ) + .with_context(|| format!("Cloning /{name}"))?; + attach(&tree, target, name) +} + +fn attach(tree: impl AsFd, target: &Dir, name: &str) -> Result<()> { + move_mount( + tree, + "", + target, + name, + MoveMountFlags::MOVE_MOUNT_F_EMPTY_PATH, + ) + .with_context(|| format!("Attaching /{name}")) +} + +#[cfg(test)] +mod tests { + use super::{DeploymentBackend, open_mount_target, select_backend}; + use crate::cli::{InstallOpts, Opt}; + use camino::Utf8Path; + use clap::Parser; + + #[test] + fn requires_deployment_selector() { + let cases: &[(&[&str], bool)] = &[ + (&["--sysroot=/sysroot", "/mnt"], false), + (&["--sysroot=/sysroot", "--latest", "/mnt"], true), + ( + &["--sysroot=/sysroot", "--latest", "--read-only", "/mnt"], + true, + ), + ]; + for (args, ok) in cases { + let argv = ["bootc", "install", "mount"].iter().chain(args.iter()); + match Opt::try_parse_from(argv) { + Ok(Opt::Install(InstallOpts::Mount(opts))) => { + assert!(ok, "{args:?} parsed without a selector"); + assert!(opts.latest); + } + Ok(o) => panic!("{args:?}: expected install mount, got {o:?}"), + Err(e) => { + assert!(!ok, "{args:?}: {e}"); + assert_eq!(e.kind(), clap::error::ErrorKind::MissingRequiredArgument); + assert!(e.to_string().contains("--latest"), "{e}"); + } + } + } + } + + #[test] + fn selects_only_unambiguous_backend() { + let cases = [ + (0, 0, None), + (1, 0, Some(DeploymentBackend::Ostree)), + (0, 1, Some(DeploymentBackend::Composefs)), + (1, 1, None), + (2, 0, None), + (0, 2, None), + ]; + for (ostree, composefs, expected) in cases { + assert_eq!(select_backend(ostree, composefs).ok(), expected); + } + } + + #[test] + fn accepts_any_target_directory() { + let temp = tempfile::tempdir().unwrap(); + let temp = Utf8Path::from_path(temp.path()).unwrap(); + let target = temp.join("target"); + std::fs::create_dir(&target).unwrap(); + open_mount_target(&target).unwrap(); + // Non-empty only warns, as with systemd. + std::fs::create_dir(target.join("nested")).unwrap(); + open_mount_target(&target).unwrap(); + assert!(open_mount_target(&temp.join("missing")).is_err()); + } +} diff --git a/crates/mount/src/mount.rs b/crates/mount/src/mount.rs index 2a87c769cb..1122089b52 100644 --- a/crates/mount/src/mount.rs +++ b/crates/mount/src/mount.rs @@ -203,6 +203,13 @@ pub fn mount_typed(dev: &str, fstype: &str, target: &Utf8Path) -> Result<()> { .run_inherited_with_cmd_context() } +/// Recursively unmount a tree assembled by a caller-owned operation. +pub fn unmount_recursive(target: &Utf8Path) -> Result<()> { + Command::new("umount") + .args(["--recursive", target.as_str()]) + .run_inherited_with_cmd_context() +} + /// If the fsid of the passed path matches the fsid of the same path rooted /// at /proc/1/root, it is assumed that these are indeed the same mounted /// filesystem between container and host. diff --git a/crates/ostree-ext/src/ostree_prepareroot.rs b/crates/ostree-ext/src/ostree_prepareroot.rs index d6178cde24..dd096e8083 100644 --- a/crates/ostree-ext/src/ostree_prepareroot.rs +++ b/crates/ostree-ext/src/ostree_prepareroot.rs @@ -4,6 +4,7 @@ // SPDX-License-Identifier: Apache-2.0 OR MIT use std::io::Read; +use std::os::fd::{AsFd, OwnedFd}; use std::str::FromStr; use anyhow::{Context, Result}; @@ -15,6 +16,7 @@ use ostree::glib::object::Cast; use ostree::prelude::FileExt; use ostree::{gio, glib}; +use crate::composefs::mount::{MountOptions, VerityRequirement, composefs_fsmount}; use crate::keyfileext::KeyFileExt; use crate::ostree_manual; use bootc_utils::ResultExt; @@ -22,6 +24,10 @@ use bootc_utils::ResultExt; /// The relative path to ostree-prepare-root's config. pub const CONF_PATH: &str = "ostree/prepare-root.conf"; +/// The composefs image ostree writes into each deployment directory, which +/// ostree-prepare-root mounts as the root filesystem. +pub const COMPOSEFS_IMAGE: &str = ".ostree.cfs"; + /// Load the ostree prepare-root config from the given ostree repository. pub fn load_config(root: &ostree::RepoFile) -> Result> { let cancellable = gio::Cancellable::NONE; @@ -167,15 +173,80 @@ pub fn overlayfs_enabled_in_config(config: &glib::KeyFile) -> Result { let root_transient = config .optional_bool("root", "transient")? .unwrap_or_default(); - let composefs = config - .optional_string("composefs", "enabled")? - .map(|s| ComposefsState::from_str(s.as_str())) - .transpose() + let composefs = composefs_state_in_config(config) .log_err_default() .unwrap_or_default(); Ok(root_transient || composefs.maybe_enabled()) } +/// Parse `composefs.enabled`, returning `None` if it is not set. +pub fn composefs_state_in_config(config: &glib::KeyFile) -> Result> { + config + .optional_string("composefs", "enabled")? + .map(|s| ComposefsState::from_str(s.as_str())) + .transpose() +} + +/// Mount a deployment's composefs image the way ostree-prepare-root does in +/// the initramfs: an overlayfs of [`COMPOSEFS_IMAGE`] whose file content comes +/// from the repository's `objects` directory. +/// +/// `config` is the deployment's prepare-root configuration. Returns a detached, +/// read-only mount, or `None` if composefs is disabled there, or if the +/// deployment has no image and the configuration does not require one. +/// +/// This differs from ostree-prepare-root in two ways. An unset +/// `composefs.enabled` uses the image when there is one (ostree-prepare-root +/// treats unset as off), and `signed` only requires fs-verity: neither the +/// commit signature nor the image digest is checked, as the public key lives +/// in the initramfs. Kernel arguments are not consulted. +pub fn mount_composefs( + deployment: &Dir, + repo: &Dir, + name: &str, + config: Option<&glib::KeyFile>, +) -> Result> { + let state = config.map(composefs_state_in_config).transpose()?.flatten(); + if state == Some(ComposefsState::Tristate(Tristate::Disabled)) { + return Ok(None); + } + let Some(image) = deployment + .open_optional(COMPOSEFS_IMAGE) + .with_context(|| format!("Opening {COMPOSEFS_IMAGE}"))? + else { + let required = matches!( + state, + Some(ComposefsState::Signed | ComposefsState::Verity) + | Some(ComposefsState::Tristate(Tristate::Enabled)) + ); + anyhow::ensure!( + !required, + "composefs is enabled, but {COMPOSEFS_IMAGE} is missing" + ); + return Ok(None); + }; + let verity = if state + .as_ref() + .is_some_and(ComposefsState::requires_fsverity) + { + VerityRequirement::Required + } else { + VerityRequirement::Disabled + }; + let objects = repo + .open_dir("objects") + .context("Opening repository objects")?; + let mount = composefs_fsmount( + image.into_std().into(), + name, + &[objects.as_fd()], + verity, + &MountOptions::default(), + ) + .with_context(|| format!("Mounting {COMPOSEFS_IMAGE}"))?; + Ok(Some(mount)) +} + #[cfg(test)] mod tests { use super::*; @@ -246,4 +317,42 @@ enabled = false assert!(overlayfs_enabled_in_config(&kf).unwrap()); } } + + #[test] + fn test_mount_composefs_without_image() { + let td = + cap_std_ext::cap_tempfile::tempdir(cap_std_ext::cap_std::ambient_authority()).unwrap(); + // (composefs.enabled, whether a missing image is an error) + let cases = [ + (None, false), + (Some("no"), false), + (Some("maybe"), false), + (Some("yes"), true), + (Some("verity"), true), + (Some("signed"), true), + ]; + for (enabled, err) in cases { + let kf = glib::KeyFile::new(); + if let Some(v) = enabled { + kf.set_string("composefs", "enabled", v); + } + match mount_composefs(&td, &td, "test", Some(&kf)) { + Ok(m) => { + assert!(!err, "{enabled:?}: expected an error"); + assert!(m.is_none(), "{enabled:?}"); + } + Err(e) => assert!(err, "{enabled:?}: {e}"), + } + } + assert!(mount_composefs(&td, &td, "test", None).unwrap().is_none()); + // An explicit "no" wins over an existing image, without mounting it. + td.write(COMPOSEFS_IMAGE, "").unwrap(); + let kf = glib::KeyFile::new(); + kf.set_string("composefs", "enabled", "no"); + assert!( + mount_composefs(&td, &td, "test", Some(&kf)) + .unwrap() + .is_none() + ); + } } From ab49ac17e367fb52a45e356f288290e2f4903290 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Mon, 21 Sep 2026 13:27:48 -0400 Subject: [PATCH 2/2] docs: Match offline mount guidance to caller cleanup Document the reduced API contract so callers use recursive unmount and the privileged test exercises the real mount boundary instead of an invented lifecycle record. Assisted-by: AI Signed-off-by: Colin Walters --- docs/src/SUMMARY.md | 1 + docs/src/bootc-install.md | 65 ++++++------- docs/src/man/bootc-install-mount.8.md | 97 +++++++++++++++++++ .../man/bootc-install-to-existing-root.8.md | 31 ++---- docs/src/man/bootc-install.8.md | 2 +- .../booted/test-install-outside-container.nu | 88 +++++++++++++++++ 6 files changed, 223 insertions(+), 61 deletions(-) create mode 100644 docs/src/man/bootc-install-mount.8.md diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index bad81a3499..164f695c37 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -43,6 +43,7 @@ - [`man bootc-install-to-disk`](man/bootc-install-to-disk.8.md) - [`man bootc-install-to-filesystem`](man/bootc-install-to-filesystem.8.md) - [`man bootc-install-to-existing-root`](man/bootc-install-to-existing-root.8.md) +- [`man bootc-install-mount`](man/bootc-install-mount.8.md) - [`man bootc-destructive-cleanup.service`](man/bootc-destructive-cleanup.service.5.md) # Bootc usage in containers diff --git a/docs/src/bootc-install.md b/docs/src/bootc-install.md index 6849c8adcf..88a25f30ef 100644 --- a/docs/src/bootc-install.md +++ b/docs/src/bootc-install.md @@ -164,18 +164,24 @@ a `bootc` kickstart command that drives `to-filesystem` this way. #### Postprocessing after to-filesystem -Some installation tools may want to inject additional data, such as adding -an `/etc/hostname` into the target root. At the current time, bootc does -not offer a direct API to do this. However, the backend for bootc is -ostree, and it is possible to enumerate the deployments via ostree APIs. +Some installation tools may want to inject additional data, such as adding an +`/etc/hostname` into the target root. Mount the offline deployment explicitly +in the caller's mount namespace: -You can use `ostree admin --sysroot=/path/to/target --print-current-dir` to -find the newly created deployment directory. For detailed examples and usage, -see the [Injecting configuration before first boot](#before-reboot-injecting-new-configuration) -section under `to-existing-root` documentation below. +```bash +mkdir /mnt/installed +bootc install mount --sysroot /path/to/target --latest /mnt/installed +# mutate /mnt/installed/etc and /mnt/installed/var as needed +umount -R /mnt/installed +``` -We hope to provide a bootc-supported method to find the deployment in -the future. +The deployment root and `/usr` are always read-only, while `/etc` and `/var` +are writable unless `--read-only` is given. `/var` is the deployment's state +directory on the target sysroot; if you set up a separate `/var` filesystem, +mount it on top yourself. +The mounts live in the caller's mount namespace until it removes them with +`umount -R`, so use a target with no unrelated mounts below it. See +[bootc-install-mount(8)](man/bootc-install-mount.8.md). However, for tools that do perform any changes, there is a new `bootc install finalize` command which is optional, but recommended @@ -251,22 +257,16 @@ previous installation. ##### Before reboot: Injecting new configuration After running `bootc install to-existing-root`, you may want to inject -configuration files (such as `/etc/fstab`, systemd units, or other configuration) -into the newly installed system before rebooting. The new deployment is located -in the ostree repository structure at: - -`/target/ostree/deploy//deploy/./` - -Where `` defaults to `default` unless specified via `--stateroot`. - -To find and modify the newly installed deployment: +configuration files (such as `/etc/fstab`, systemd units, or other +configuration) into the newly installed system before rebooting. Mount the +target explicitly and mutate it through the mounted view: ```bash -# Get the deployment path -DEPLOY_PATH=$(ostree admin --sysroot=/target --print-current-dir) +mkdir /mnt/installed +bootc install mount --sysroot /target --latest /mnt/installed # Add a systemd mount unit -cat > ${DEPLOY_PATH}/etc/systemd/system/data.mount < /mnt/installed/etc/systemd/system/data.mount </deploy/`. - -You can use `ostree admin --sysroot=/path/to/target --print-current-dir` to find -the deployment directory. For detailed examples, see -[Injecting configuration before first boot](#before-reboot-injecting-new-configuration). - -Installation software such as [Anaconda](https://github.com/rhinstaller/anaconda) -do this today to implement generic `%post` scripts and the like. - -However, it is very likely that a generic bootc API to do this will be added. +Per the [filesystem](filesystem.md) section, `/etc` and `/var` are +machine-local state by default. To inject additional content after installation, +use `bootc install mount --sysroot /path/to/target --latest /mnt/installed` +and mutate `/mnt/installed/etc` or `/mnt/installed/var`. This is the +backend-neutral interface for installation software such as +[Anaconda](https://github.com/rhinstaller/anaconda) to implement `%post` +scripts before first boot. ## Provisioning and first boot diff --git a/docs/src/man/bootc-install-mount.8.md b/docs/src/man/bootc-install-mount.8.md new file mode 100644 index 0000000000..b562fe9d33 --- /dev/null +++ b/docs/src/man/bootc-install-mount.8.md @@ -0,0 +1,97 @@ +# NAME + +bootc-install-mount - Mount an installed deployment into a caller-owned directory + +# SYNOPSIS + +bootc install mount **--sysroot**=*SYSROOT* **--latest** [**--read-only**] *TARGET* + +# DESCRIPTION + +Mount the deployment from an offline physical sysroot, such as one just +created by **bootc install to-filesystem**, at **TARGET**. Both the OSTree +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. + +The mount remains in the caller's mount namespace after this command exits; +bootc does not create a container, chroot, or private mount namespace. + +The root follows what the initramfs does at boot. For composefs it is the +deployment's image. For OSTree it is the deployment's composefs image +(`.ostree.cfs`), mounted as **ostree-prepare-root**(1) does, unless +`prepare-root.conf` sets `composefs.enabled = no` or the deployment has no +image, in which case it is the deployment directory itself. Unlike +ostree-prepare-root, bootc also uses the image when `composefs.enabled` is +unset, does not consult kernel arguments, and for `signed` only requires +fs-verity without checking the commit signature. + +The deployment root and `/usr` are always read-only. The persistent `/etc` +and `/var` are writable, unless **--read-only** is specified. The caller owns +the resulting mount tree and should clean it up recursively with **umount -R**. + +`/etc` and `/var` are set up the way the deployment's root setup will mount +them at boot: for composefs as configured by **bootc-setup-root-conf**(5), and +for OSTree following `etc.transient` in `prepare-root.conf`. A transient +`/etc` is a fresh overlay of the image's `/etc`, so changes to it are +discarded on unmount, just as they would be on reboot. A transient root +(`root.transient`) is not applied; the root stays read-only. + +`/var` is always the deployment's state directory on **SYSROOT**. bootc does +not look in the image or its configuration (such as `/etc/fstab` or systemd +mount units) for a separate `/var` filesystem, and does not consider kernel +arguments such as `systemd.volatile`. If the installation puts `/var` on its +own partition, mount that on top of *TARGET*`/var` yourself. + +Plain **chroot**(1) into *TARGET* is not enough to run programs from the +deployment: it sets up no `/proc`, `/sys`, `/dev` or `/run`. Use a tool +that provides those, such as `bwrap`, `podman run --rootfs`, or +**systemd-nspawn**(1). + +# OPTIONS + + +**TARGET** + + Directory receiving the deployment mount + + This argument is required. + +**--sysroot**=*SYSROOT* + + Offline target sysroot + +**--latest** + + Mount the latest deployment. Currently the sysroot must contain exactly one + +**--read-only** + + Mount /etc and /var read-only too. The deployment root is always read-only + + + +# EXAMPLES + +Mount an installation and modify its persistent configuration and state: + +```bash +mkdir /mnt/installed +bootc install mount --sysroot /mnt/sysroot --latest /mnt/installed +install -D -m 0644 hostname /mnt/installed/etc/hostname +umount -R /mnt/installed +``` + +# SEE ALSO + +**bootc**(8), **bootc-install**(8) + +# VERSION + + diff --git a/docs/src/man/bootc-install-to-existing-root.8.md b/docs/src/man/bootc-install-to-existing-root.8.md index 416d9ca815..168478fd8d 100644 --- a/docs/src/man/bootc-install-to-existing-root.8.md +++ b/docs/src/man/bootc-install-to-existing-root.8.md @@ -28,33 +28,14 @@ configuration files: If you need to inject new configuration files (such as custom `/etc/fstab` entries, systemd mount units, or other configuration) into the newly installed system before -rebooting, you can find the deployment directory in the ostree repository structure. -The new deployment is located at: - -``` -/ostree/deploy//deploy/./ -``` - -Where `` defaults to `default` unless you specified a different -value with `--stateroot`. - -To find the path to the newly installed deployment: - -```bash -# Get the full deployment path directly -DEPLOY_PATH=$(ostree admin --sysroot=/target --print-current-dir) -``` - -This will return the full path, for example: -`/target/ostree/deploy/default/deploy/807f233831a03d315289a4ba29c1670d8bd326d4569eabee7a84f25327997307.0` - -You can then modify files in that deployment. For example, to add systemd mount units: +rebooting, mount it with **bootc-install-mount**(8) and modify it through the +mounted view. For example, to add a systemd mount unit: ```bash -# Get deployment path -DEPLOY_PATH=$(ostree admin --sysroot=/target --print-current-dir) -# Add a systemd mount unit -vi ${DEPLOY_PATH}/etc/systemd/system/data.mount +mkdir /mnt/installed +bootc install mount --sysroot /target --latest /mnt/installed +vi /mnt/installed/etc/systemd/system/data.mount +umount -R /mnt/installed ``` #### Injecting kernel arguments for local state diff --git a/docs/src/man/bootc-install.8.md b/docs/src/man/bootc-install.8.md index 43a585a395..f29443aaa5 100644 --- a/docs/src/man/bootc-install.8.md +++ b/docs/src/man/bootc-install.8.md @@ -40,6 +40,7 @@ When installing with `systemd-boot`, bootc can let `systemd-boot` can handle enr | Command | Description | |---------|-------------| +| **bootc install mount** | Mount an installed deployment into a caller-owned directory | | **bootc install to-disk** | Install to the target block device | | **bootc install to-filesystem** | Install to an externally created filesystem structure | | **bootc install to-existing-root** | Install to the host root filesystem | @@ -52,4 +53,3 @@ When installing with `systemd-boot`, bootc can let `systemd-boot` can handle enr # VERSION - diff --git a/tmt/tests/booted/test-install-outside-container.nu b/tmt/tests/booted/test-install-outside-container.nu index 9107235e39..a228727f36 100644 --- a/tmt/tests/booted/test-install-outside-container.nu +++ b/tmt/tests/booted/test-install-outside-container.nu @@ -177,4 +177,92 @@ if $source_is_uki { } } +# Exercise `bootc install mount` on the installed disk. This plan runs in its +# own disposable VM, so there is no cleanup on failure. +def mount_options [path: string] { + findmnt --json --mountpoint $path --output OPTIONS | from json | get filesystems.0.options | split row "," +} + +def assert_write_denied [path: string] { + let result = (do { ^touch $path } | complete) + assert ($result.exit_code != 0) $"write unexpectedly succeeded at ($path)" +} + +let loop = (losetup --find --show --partscan ./disk.img | str trim) +let sysroot = "/var/mnt/install-mount-sysroot" +let dest = "/var/mnt/install-mount-dest" +mkdir $sysroot $dest +mount (discover_target_partitions $loop).root $sysroot + +# A deployment selector is mandatory, so that more can be added later. +let result = (do { ^bootc install mount --sysroot $sysroot $dest } | complete) +assert ($result.exit_code != 0) "install mount without --latest must fail" +assert ($result.stderr | str contains "--latest") $"unexpected error: ($result.stderr)" + +# Like mount(8), a non-empty target is used anyway (with a warning). +"hidden" | save ($dest | path join preexisting) +# --read-only covers the persistent state too. +let result = (do { ^bootc install mount --sysroot $sysroot --latest --read-only $dest } | complete) +assert equal $result.exit_code 0 $"install mount failed: ($result.stderr)" +assert ($result.stderr | str contains "not empty") $"expected a non-empty warning: ($result.stderr)" +assert (not ($dest | path join preexisting | path exists)) "the mount must hide the target's contents" +# Both backends mount the root from the deployment's composefs image, as the +# initramfs does; for OSTree that is its .ostree.cfs. +let root = (findmnt --json --mountpoint $dest --output FSTYPE,SOURCE | from json | get filesystems.0) +assert equal $root.fstype "overlay" $"deployment root must be composefs: ($root | to nuon)" +assert ($root.source | str starts-with "composefs:") $"deployment root must be composefs: ($root | to nuon)" +for path in [$dest ($dest | path join etc) ($dest | path join var)] { + assert ("ro" in (mount_options $path)) $"($path) must be mounted read-only" +} +for path in [root-sentinel usr/sentinel etc/sentinel var/sentinel] { + assert_write_denied ($dest | path join $path) +} +umount -R $dest +rm ($dest | path join preexisting) + +# By default only /etc and /var are writable. +bootc install mount --sysroot $sysroot --latest $dest +assert ("ro" in (mount_options $dest)) "deployment root must stay read-only" +for path in [($dest | path join etc) ($dest | path join var)] { + assert ("rw" in (mount_options $path)) $"($path) must be mounted read-write" +} +for path in [root-sentinel usr/sentinel] { + assert_write_denied ($dest | path join $path) +} +"etc sentinel" | save ($dest | path join etc/sentinel) +"var sentinel" | save ($dest | path join var/sentinel) +umount -R $dest + +# The writes must land in the deployment's persistent state on the sysroot. +let state = if (tap is_composefs) { + {deployments: "state/deploy", var: "state/os/default/var"} +} else { + {deployments: "ostree/deploy/default/deploy", var: "ostree/deploy/default/var"} +} +let deployment = (ls ($sysroot | path join $state.deployments) | where type == dir | get name | first) +assert equal (open ($deployment | path join etc/sentinel)) "etc sentinel" +assert equal (open ($sysroot | path join $state.var sentinel)) "var sentinel" + +# /etc follows prepare-root's etc.transient: a throwaway overlay of /usr/etc +# instead of the persistent copy. The composefs equivalent is baked into the +# image, so only OSTree can toggle it here. +if not (tap is_composefs) { + bootc install mount --sysroot $sysroot --latest $dest + mkdir ($dest | path join etc/ostree) + "[etc]\ntransient = true\n" | save -f ($dest | path join etc/ostree/prepare-root.conf) + umount -R $dest + + bootc install mount --sysroot $sysroot --latest $dest + let etc = ($dest | path join etc) + let fstype = (findmnt --json --mountpoint $etc --output FSTYPE | from json | get filesystems.0.fstype) + assert equal $fstype "overlay" "transient /etc must be an overlay" + assert (not ($etc | path join sentinel | path exists)) "transient /etc must not show the persistent copy" + "transient" | save ($etc | path join transient-sentinel) + umount -R $dest + assert (not ($deployment | path join etc/transient-sentinel | path exists)) "transient /etc writes must not persist" +} + +umount $sysroot +losetup -d $loop + tap ok