diff --git a/crates/initramfs/src/lib.rs b/crates/initramfs/src/lib.rs index 3a5ec1389..ed989c57d 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 a8687c741..5e0e77158 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 bc11e07e2..ababc5f28 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/lib.rs b/crates/lib/src/lib.rs index d9eccc0c8..09a646c75 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 000000000..11b8e621f --- /dev/null +++ b/crates/lib/src/mount.rs @@ -0,0 +1,335 @@ +//! Explicit, caller-owned mounts of an offline deployment. +//! +//! 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; + +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, + + /// Empty directory receiving the deployment mount. + #[clap(value_parser = crate::cli::parse_absolute_path)] + pub(crate) target: Utf8PathBuf, +} + +/// Open the mount target, checking that it is an empty directory which is not +/// already a mountpoint and does not overlap the sysroot. +fn open_mount_target(sysroot: &Utf8Path, target: &Utf8Path) -> Result<(Utf8PathBuf, Dir)> { + let sysroot = sysroot + .canonicalize_utf8() + .with_context(|| format!("Resolving sysroot {sysroot}"))?; + let target = target + .canonicalize_utf8() + .with_context(|| format!("Resolving mount target {target}"))?; + ensure!( + !target.starts_with(&sysroot) && !sysroot.starts_with(&target), + "sysroot {sysroot} and mount target {target} must not overlap" + ); + let target_dir = Dir::open_ambient_dir(&target, ambient_authority()) + .with_context(|| format!("Opening mount target {target}"))?; + ensure!( + target_dir.is_mountpoint(".")? != Some(true), + "mount target {target} is already a mountpoint" + ); + ensure!( + target_dir.entries()?.next().is_none(), + "mount target {target} is not empty" + ); + Ok((target, 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, target_dir) = open_mount_target(&opts.sysroot, &opts.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 deployment tree is attached, so a + // concurrent OSTree operation on the offline sysroot cannot prune it from + // under us. + let ostree_sysroot = if sysroot_dir.open_dir_optional("ostree/repo")?.is_some() { + let sysroot = ostree::Sysroot::new(Some(&gio::File::for_path(&opts.sysroot))); + 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 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 root_tree = 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, + }, + ) + } + }; + + 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 } => { + mount_ostree_state(&target_root, deployment, var)? + } + } + 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 and its stateroot's `/var`. + Ostree { deployment: Dir, var: Dir }, +} + +/// 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) -> Result<()> { + let etc_transient = ostree_prepareroot::load_config_from_root(deployment)? + .map(|config| config.optional_bool("etc", "transient")) + .transpose() + .context("Parsing etc.transient")? + .flatten() + .unwrap_or_default(); + 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 rejects_overlap_and_nonempty_targets() { + let temp = tempfile::tempdir().unwrap(); + let temp = Utf8Path::from_path(temp.path()).unwrap(); + let sysroot = temp.join("sysroot"); + let target = temp.join("target"); + std::fs::create_dir(&sysroot).unwrap(); + std::fs::create_dir(&target).unwrap(); + open_mount_target(&sysroot, &target).unwrap(); + assert!(open_mount_target(&sysroot, &sysroot).is_err()); + assert!(open_mount_target(&sysroot, &sysroot.join("..")).is_err()); + std::fs::create_dir(target.join("nested")).unwrap(); + assert!(open_mount_target(&sysroot, &target).is_err()); + } +} diff --git a/crates/mount/src/mount.rs b/crates/mount/src/mount.rs index 2a87c769c..1122089b5 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/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index bad81a349..164f695c3 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 6849c8adc..88a25f30e 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 000000000..948959952 --- /dev/null +++ b/docs/src/man/bootc-install-mount.8.md @@ -0,0 +1,87 @@ +# 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** must be an absolute path to +an empty directory outside the sysroot. + +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 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** + + Empty 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 416d9ca81..168478fd8 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 43a585a39..f29443aaa 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 9107235e3..0c8f4d8bd 100644 --- a/tmt/tests/booted/test-install-outside-container.nu +++ b/tmt/tests/booted/test-install-outside-container.nu @@ -177,4 +177,81 @@ 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)" + +# --read-only covers the persistent state too. +bootc install mount --sysroot $sysroot --latest --read-only $dest +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 + +# 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