Skip to main content

bootc_lib/
mount.rs

1//! Explicit, caller-owned mounts of an offline deployment.
2//!
3//! Conceptually this redoes what the initramfs does at boot
4//! (ostree-prepare-root for OSTree, bootc-initramfs-setup for composefs), only
5//! for a deployment in an offline sysroot and at a directory the caller picks
6//! instead of `/sysroot`: the root is the deployment's composefs image, with
7//! `/etc` and `/var` mounted from its state as they will be at boot. Keep it in
8//! line with those, and share their code where bootc has it.
9//!
10//! Unlike the `install to-*` commands, this deliberately does not enter a
11//! private mount namespace: the assembled tree is left in the caller's
12//! namespace, and the caller cleans it up with `umount -R` (or by tearing
13//! down its own namespace). bootc keeps no state about the mount.
14
15use std::os::fd::{AsFd, AsRawFd};
16
17use anyhow::{Context, Result, bail, ensure};
18use bootc_initramfs_setup::{
19    Config as SetupRootConfig, MountType, SETUP_ROOT_CONF_PATH, mount_subdir,
20};
21use camino::{Utf8Path, Utf8PathBuf};
22use cap_std_ext::{
23    cap_std::{ambient_authority, fs::Dir},
24    dirext::CapStdExtDirExt,
25};
26use clap::Args;
27use ostree::gio;
28use ostree_ext::keyfileext::KeyFileExt;
29use ostree_ext::{ostree, ostree_prepareroot};
30use rustix::mount::{MoveMountFlags, OpenTreeFlags, move_mount, open_tree};
31
32use crate::composefs_consts::STATE_DIR_RELATIVE;
33
34const ETC: &str = "etc";
35const VAR: &str = "var";
36
37#[derive(Debug, Args, PartialEq, Eq)]
38pub(crate) struct MountOpts {
39    /// Offline target sysroot.
40    #[clap(long, value_parser = crate::cli::parse_absolute_path)]
41    pub(crate) sysroot: Utf8PathBuf,
42
43    /// Mount the latest deployment. Currently the sysroot must contain exactly one.
44    ///
45    /// This is required so that other ways to select a deployment can be
46    /// added later without changing what an invocation means.
47    #[clap(long, required = true)]
48    pub(crate) latest: bool,
49
50    /// Mount /etc and /var read-only too. The deployment root is always read-only.
51    #[clap(long)]
52    pub(crate) read_only: bool,
53
54    /// Directory receiving the deployment mount.
55    #[clap(value_parser = crate::cli::parse_absolute_path)]
56    pub(crate) target: Utf8PathBuf,
57}
58
59/// Open the mount target. Like mount(8), this mounts wherever the caller asks;
60/// like systemd, it warns when that hides existing content.
61fn open_mount_target(target: &Utf8Path) -> Result<Dir> {
62    let target_dir = Dir::open_ambient_dir(target, ambient_authority())
63        .with_context(|| format!("Opening mount target {target}"))?;
64    let mut entries = target_dir
65        .entries()
66        .with_context(|| format!("Reading mount target {target}"))?;
67    if entries.next().is_some() {
68        eprintln!("warning: mount target {target} is not empty; its contents will be hidden");
69    }
70    Ok(target_dir)
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74enum DeploymentBackend {
75    Ostree,
76    Composefs,
77}
78
79fn select_backend(
80    ostree_deployments: usize,
81    composefs_deployments: usize,
82) -> Result<DeploymentBackend> {
83    match (ostree_deployments, composefs_deployments) {
84        (1, 0) => Ok(DeploymentBackend::Ostree),
85        (0, 1) => Ok(DeploymentBackend::Composefs),
86        (0, 0) => bail!("target contains no deployment"),
87        (o, c) => bail!(
88            "target must contain exactly one deployment (found {o} OSTree, {c} composefs); refusing ambiguous selection"
89        ),
90    }
91}
92
93pub(crate) async fn mount(opts: MountOpts) -> Result<()> {
94    // clap already requires --latest, the only selector so far.
95    ensure!(opts.latest, "no deployment selected; pass --latest");
96    let target = &opts.target;
97    let target_dir = open_mount_target(target)?;
98    let sysroot_dir = Dir::open_ambient_dir(&opts.sysroot, ambient_authority())
99        .with_context(|| format!("Opening target sysroot {}", opts.sysroot))?;
100
101    // The OSTree lock is held until the mount is assembled, so a concurrent
102    // OSTree operation on the offline sysroot cannot prune the deployment from
103    // under us.
104    let ostree_repo = sysroot_dir.open_dir_optional("ostree/repo")?;
105    let ostree_sysroot = if ostree_repo.is_some() {
106        // ostree only takes a path; go through the fd we already opened.
107        let path = format!("/proc/self/fd/{}", sysroot_dir.as_raw_fd());
108        let sysroot = ostree::Sysroot::new(Some(&gio::File::for_path(path)));
109        sysroot
110            .load(gio::Cancellable::NONE)
111            .context("Loading target OSTree sysroot")?;
112        Some(ostree_ext::sysroot::SysrootLock::new_from_sysroot(&sysroot).await?)
113    } else {
114        None
115    };
116    let ostree_deployments = ostree_sysroot
117        .as_ref()
118        .map(|s| s.deployments())
119        .unwrap_or_default();
120    // Check the state directory rather than the repository: OSTree systems
121    // using unified storage also have a composefs repository.
122    let composefs_deployments = sysroot_dir
123        .open_dir_optional(STATE_DIR_RELATIVE)?
124        .map(|state| crate::bootc_composefs::gc::list_deployment_state_dirs(&state))
125        .transpose()?
126        .unwrap_or_default();
127
128    let (root_tree, state) =
129        match select_backend(ostree_deployments.len(), composefs_deployments.len())? {
130            DeploymentBackend::Composefs => {
131                let id = &composefs_deployments[0];
132                let state = sysroot_dir
133                    .open_dir(format!("{STATE_DIR_RELATIVE}/{id}"))
134                    .with_context(|| format!("Opening composefs deployment state {id}"))?;
135                let repo = crate::bootc_composefs::repo::open_composefs_repo(&sysroot_dir)?;
136                let image = repo.mount(id).context("Mounting composefs image")?;
137                (image, DeploymentState::Composefs(state))
138            }
139            DeploymentBackend::Ostree => {
140                let sysroot = ostree_sysroot.as_deref().expect("OSTree backend selected");
141                let repo = ostree_repo.as_ref().expect("OSTree backend selected");
142                let deployment = &ostree_deployments[0];
143                let source = sysroot.deployment_dirpath(deployment);
144                let deployment_dir = sysroot_dir
145                    .open_dir(source.as_str())
146                    .with_context(|| format!("Opening OSTree deployment {source}"))?;
147                let var = sysroot_dir
148                    .open_dir(format!("ostree/deploy/{}/{VAR}", deployment.stateroot()))
149                    .context("Opening OSTree stateroot /var")?;
150                let config = ostree_prepareroot::load_config_from_root(&deployment_dir)
151                    .context("Loading the deployment's prepare-root.conf")?;
152                let etc_transient = config
153                    .as_ref()
154                    .map(|config| config.optional_bool("etc", "transient"))
155                    .transpose()
156                    .context("Parsing etc.transient")?
157                    .flatten()
158                    .unwrap_or_default();
159                let composefs = ostree_prepareroot::mount_composefs(
160                    &deployment_dir,
161                    repo,
162                    deployment.csum().as_str(),
163                    config.as_ref(),
164                )?;
165                let root_tree = match composefs {
166                    Some(root_tree) => root_tree,
167                    // Without composefs, prepare-root uses the checkout itself.
168                    None => open_tree(
169                        &sysroot_dir,
170                        source.as_str(),
171                        OpenTreeFlags::OPEN_TREE_CLONE | OpenTreeFlags::OPEN_TREE_CLOEXEC,
172                    )
173                    .context("Cloning OSTree deployment tree")?,
174                };
175                (
176                    root_tree,
177                    DeploymentState::Ostree {
178                        deployment: deployment_dir,
179                        var,
180                        etc_transient,
181                    },
182                )
183            }
184        };
185
186    bootc_initramfs_setup::set_mount_readonly(&root_tree)
187        .context("Making detached deployment root read-only")?;
188    move_mount(
189        &root_tree,
190        "",
191        &target_dir,
192        ".",
193        MoveMountFlags::MOVE_MOUNT_F_EMPTY_PATH,
194    )
195    .context("Attaching deployment root")?;
196    let assembly = (|| -> Result<()> {
197        // Reopen by path: target_dir still refers to the directory underneath the new mount.
198        let target_root = Dir::open_ambient_dir(target, ambient_authority())
199            .context("Opening mounted deployment root")?;
200        match &state {
201            DeploymentState::Composefs(state) => mount_composefs_state(&target_root, state)?,
202            DeploymentState::Ostree {
203                deployment,
204                var,
205                etc_transient,
206            } => mount_ostree_state(&target_root, deployment, var, *etc_transient)?,
207        }
208        if opts.read_only {
209            bootc_initramfs_setup::set_mount_tree_readonly(&target_root)
210                .context("Making /etc and /var read-only")?;
211        }
212        Ok(())
213    })();
214    if let Err(error) = assembly {
215        return Err(match bootc_mount::unmount_recursive(target) {
216            Ok(()) => error,
217            Err(cleanup_error) => {
218                error.context(format!("cleanup of {target} also failed: {cleanup_error}"))
219            }
220        })
221        .context("Assembling offline deployment mount");
222    }
223    Ok(())
224}
225
226/// Where the machine-local state of the selected deployment lives.
227enum DeploymentState {
228    /// The composefs per-deployment state directory.
229    Composefs(Dir),
230    /// The OSTree deployment directory, its stateroot's `/var`, and whether
231    /// prepare-root.conf enables `etc.transient`.
232    Ostree {
233        deployment: Dir,
234        var: Dir,
235        etc_transient: bool,
236    },
237}
238
239/// Mount `/etc` and `/var` as the composefs initramfs does at boot, honoring
240/// the image's setup-root configuration (for example a transient `/etc`).
241fn mount_composefs_state(root: &Dir, state: &Dir) -> Result<()> {
242    let config_path = SETUP_ROOT_CONF_PATH.trim_start_matches('/');
243    let config: SetupRootConfig = root
244        .read_to_string_optional(config_path)
245        .with_context(|| format!("Reading {SETUP_ROOT_CONF_PATH}"))?
246        .map(|text| toml::from_str(&text))
247        .transpose()
248        .with_context(|| format!("Parsing {SETUP_ROOT_CONF_PATH}"))?
249        .unwrap_or_default();
250    mount_subdir(root, state, ETC, config.etc, MountType::Bind)?;
251    mount_subdir(root, state, VAR, config.var, MountType::Bind)?;
252    Ok(())
253}
254
255/// Mount `/etc` and `/var` as ostree-prepare-root does at boot: `/etc` is the
256/// deployment's persistent copy, or a transient overlay of `/usr/etc` when
257/// `prepare-root.conf` enables `etc.transient`.
258fn mount_ostree_state(root: &Dir, deployment: &Dir, var: &Dir, etc_transient: bool) -> Result<()> {
259    if etc_transient {
260        let usr_etc = root.open_dir("usr/etc").context("Opening /usr/etc")?;
261        let overlay = bootc_initramfs_setup::overlay_transient(&usr_etc, "transient", None)?;
262        attach(&overlay, root, ETC)?;
263    } else {
264        let etc = deployment
265            .open_dir(ETC)
266            .context("Opening OSTree deployment /etc")?;
267        bind(&etc, root, ETC)?;
268    }
269    bind(var, root, VAR)
270}
271
272fn bind(source: &Dir, target: &Dir, name: &str) -> Result<()> {
273    let tree = open_tree(
274        source,
275        ".",
276        OpenTreeFlags::OPEN_TREE_CLONE | OpenTreeFlags::OPEN_TREE_CLOEXEC,
277    )
278    .with_context(|| format!("Cloning /{name}"))?;
279    attach(&tree, target, name)
280}
281
282fn attach(tree: impl AsFd, target: &Dir, name: &str) -> Result<()> {
283    move_mount(
284        tree,
285        "",
286        target,
287        name,
288        MoveMountFlags::MOVE_MOUNT_F_EMPTY_PATH,
289    )
290    .with_context(|| format!("Attaching /{name}"))
291}
292
293#[cfg(test)]
294mod tests {
295    use super::{DeploymentBackend, open_mount_target, select_backend};
296    use crate::cli::{InstallOpts, Opt};
297    use camino::Utf8Path;
298    use clap::Parser;
299
300    #[test]
301    fn requires_deployment_selector() {
302        let cases: &[(&[&str], bool)] = &[
303            (&["--sysroot=/sysroot", "/mnt"], false),
304            (&["--sysroot=/sysroot", "--latest", "/mnt"], true),
305            (
306                &["--sysroot=/sysroot", "--latest", "--read-only", "/mnt"],
307                true,
308            ),
309        ];
310        for (args, ok) in cases {
311            let argv = ["bootc", "install", "mount"].iter().chain(args.iter());
312            match Opt::try_parse_from(argv) {
313                Ok(Opt::Install(InstallOpts::Mount(opts))) => {
314                    assert!(ok, "{args:?} parsed without a selector");
315                    assert!(opts.latest);
316                }
317                Ok(o) => panic!("{args:?}: expected install mount, got {o:?}"),
318                Err(e) => {
319                    assert!(!ok, "{args:?}: {e}");
320                    assert_eq!(e.kind(), clap::error::ErrorKind::MissingRequiredArgument);
321                    assert!(e.to_string().contains("--latest"), "{e}");
322                }
323            }
324        }
325    }
326
327    #[test]
328    fn selects_only_unambiguous_backend() {
329        let cases = [
330            (0, 0, None),
331            (1, 0, Some(DeploymentBackend::Ostree)),
332            (0, 1, Some(DeploymentBackend::Composefs)),
333            (1, 1, None),
334            (2, 0, None),
335            (0, 2, None),
336        ];
337        for (ostree, composefs, expected) in cases {
338            assert_eq!(select_backend(ostree, composefs).ok(), expected);
339        }
340    }
341
342    #[test]
343    fn accepts_any_target_directory() {
344        let temp = tempfile::tempdir().unwrap();
345        let temp = Utf8Path::from_path(temp.path()).unwrap();
346        let target = temp.join("target");
347        std::fs::create_dir(&target).unwrap();
348        open_mount_target(&target).unwrap();
349        // Non-empty only warns, as with systemd.
350        std::fs::create_dir(target.join("nested")).unwrap();
351        open_mount_target(&target).unwrap();
352        assert!(open_mount_target(&temp.join("missing")).is_err());
353    }
354}