Skip to main content

bootc_lib/
podstorage.rs

1//! # bootc-managed instance of containers-storage:
2//!
3//! The backend for podman and other tools is known as `container-storage:`,
4//! with a canonical instance that lives in `/var/lib/containers`.
5//!
6//! This is a `containers-storage:` instance which is owned by bootc.
7//! On ostree systems it lives at `/sysroot/ostree/bootc/storage`;
8//! on composefs systems the physical location is
9//! `/sysroot/composefs/bootc/storage` with a compatibility symlink
10//! at `ostree/bootc -> ../composefs/bootc`.
11//!
12//! At the current time, this is only used for Logically Bound Images.
13
14use std::collections::HashSet;
15use std::io::{Seek, Write};
16use std::os::unix::process::CommandExt;
17use std::process::{Command, Stdio};
18use std::sync::Arc;
19
20use anyhow::{Context, Result};
21use bootc_utils::{AsyncCommandRunExt, CommandRunExt, ExitStatusExt};
22use camino::{Utf8Path, Utf8PathBuf};
23use cap_std_ext::cap_std::fs::Dir;
24use cap_std_ext::cap_tempfile::TempDir;
25use cap_std_ext::cmdext::{CapStdExtCommandExt, CmdFds};
26use cap_std_ext::dirext::CapStdExtDirExt;
27use cap_std_ext::{cap_std, cap_tempfile};
28use fn_error_context::context;
29use ostree_ext::container::{ImageReference, Transport};
30use ostree_ext::ostree::{self};
31use std::os::fd::OwnedFd;
32use tokio::process::Command as AsyncCommand;
33
34// Pass only 100 args at a time just to avoid potentially overflowing argument
35// vectors; not that this should happen in reality, but just in case.
36const SUBCMD_ARGV_CHUNKING: usize = 100;
37
38/// Global directory path which we use for podman to point
39/// it at our storage. Unfortunately we can't yet use the
40/// /proc/self/fd/N trick because it currently breaks due
41/// to how the untar process is forked in the child.
42pub(crate) const STORAGE_ALIAS_DIR: &str = "/run/bootc/storage";
43/// We pass this via /proc/self/fd to the child process.
44pub(crate) const STORAGE_RUN_FD: i32 = 3;
45
46const LABELED: &str = ".bootc_labeled";
47
48/// The system path to the canonical containers-storage instance,
49/// used as the SELinux label reference path.
50const SYS_CSTOR_PATH: &str = "/var/lib/containers/storage";
51
52/// The path to the image storage, relative to the bootc root directory.
53pub(crate) const SUBPATH: &str = "storage";
54/// The path to the "runroot" with transient runtime state; this is
55/// relative to the /run directory
56const RUNROOT: &str = "bootc/storage";
57
58/// Only bare names and `containers-storage:` references can name an image in storage.
59fn resolvable_in_storage(image: &str) -> bool {
60    match ImageReference::try_from(image) {
61        Ok(r) => r.transport == Transport::ContainerStorage,
62        // No transport prefix
63        Err(_) => true,
64    }
65}
66
67/// A bootc-owned instance of `containers-storage:`.
68pub(crate) struct CStorage {
69    /// The root directory
70    sysroot: Dir,
71    /// The location of container storage
72    storage_root: Dir,
73    /// Our runtime state
74    run: Dir,
75    /// The SELinux policy used for labeling the storage.
76    sepolicy: Option<ostree::SePolicy>,
77    /// Disallow using this across multiple threads concurrently; while we
78    /// have internal locking in podman, in the future we may change how
79    /// things work here. And we don't have a use case right now for
80    /// concurrent operations.
81    _unsync: std::cell::Cell<()>,
82}
83
84#[derive(Debug, PartialEq, Eq)]
85pub(crate) enum PullMode {
86    /// Pull only if the image is not present
87    IfNotExists,
88    /// Always check for an update
89    #[allow(dead_code)]
90    Always,
91}
92
93#[allow(unsafe_code)]
94#[context("Binding storage roots")]
95pub(crate) fn bind_storage_roots(
96    cmd: &mut Command,
97    fds: &mut CmdFds,
98    storage_root: &Dir,
99    run_root: &Dir,
100) -> Result<()> {
101    // podman requires an absolute path, for two reasons right now:
102    // - It writes the file paths into `db.sql`, a sqlite database for unknown reasons
103    // - It forks helper binaries, so just giving it /proc/self/fd won't work as
104    //   those helpers may not get the fd passed. (which is also true of skopeo)
105    // We create a new mount namespace, which also has the helpful side effect
106    // of automatically cleaning up the global bind mount that the storage stack
107    // creates.
108
109    let storage_root = Arc::new(storage_root.try_clone().context("Cloning storage root")?);
110    let run_root: Arc<OwnedFd> = Arc::new(run_root.try_clone().context("Cloning runroot")?.into());
111    // SAFETY: All the APIs we call here are safe to invoke between fork and exec.
112    unsafe {
113        cmd.pre_exec(move || {
114            use rustix::fs::{Mode, OFlags};
115            // For reasons I don't understand, we can't just `mount("/proc/self/fd/N", "/path/to/target")`
116            // but it *does* work to fchdir(fd) + mount(".", "/path/to/target").
117            // I think it may be that mount doesn't like operating on the magic links?
118            // This trick only works if we set our working directory to the target *before*
119            // creating the new namespace too.
120            //
121            // I think we may be hitting this:
122            //
123            // "       EINVAL A bind operation (MS_BIND) was requested where source referred a mount namespace magic link (i.e., a /proc/pid/ns/mnt magic link or a bind mount to such a link) and the propagation type of the parent mount of target was
124            // MS_SHARED, but propagation of the requested bind mount could lead to a circular dependency that might prevent the mount namespace from ever being freed."
125            //
126            // But...how did we avoid that circular dependency by using the process cwd?
127            //
128            // I tried making the mounts recursively private, but that didn't help.
129            // (We do make them private below, but for a different reason.)
130            let oldwd = rustix::fs::open(
131                ".",
132                OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::RDONLY,
133                Mode::empty(),
134            )?;
135            rustix::process::fchdir(&storage_root)?;
136            rustix::thread::unshare_unsafe(rustix::thread::UnshareFlags::NEWNS)?;
137            // The new namespace inherits the caller's propagation. If that is shared
138            // (e.g. `install to-existing-root --source-imgref` run directly on a systemd
139            // host, which skips our usual unshare), the bind below would propagate back
140            // and stay mounted on the host's /run/bootc/storage after we exit.
141            // https://github.com/bootc-dev/bootc/issues/2455
142            rustix::mount::mount_change(
143                c"/",
144                rustix::mount::MountPropagationFlags::PRIVATE
145                    | rustix::mount::MountPropagationFlags::REC,
146            )?;
147            rustix::mount::mount_bind(".", STORAGE_ALIAS_DIR)?;
148            rustix::process::fchdir(&oldwd)?;
149            Ok(())
150        })
151    };
152    fds.take_fd_n(run_root, STORAGE_RUN_FD);
153    Ok(())
154}
155
156/// Set up `REGISTRY_AUTH_FILE` on a command, passing the bootc/ostree
157/// auth file via an anonymous tmpfile fd.
158///
159/// If no bootc-owned auth is configured, an empty `{}` is passed to
160/// prevent podman from falling back to user-owned auth paths.
161pub(crate) fn setup_auth(cmd: &mut Command, fds: &mut CmdFds, sysroot: &Dir) -> Result<()> {
162    let tmpd = &cap_std::fs::Dir::open_ambient_dir("/tmp", cap_std::ambient_authority())?;
163    let mut tempfile = cap_tempfile::TempFile::new_anonymous(tmpd).map(std::io::BufWriter::new)?;
164
165    // Keep this in sync with https://github.com/bootc-dev/containers-image-proxy-rs/blob/b5e0861ad5065f47eaf9cda0d48da3529cc1bc43/src/imageproxy.rs#L310
166    // We always override the auth to match the bootc setup.
167    let authfile_fd = ostree_ext::globals::get_global_authfile(sysroot)?.map(|v| v.1);
168    if let Some(mut fd) = authfile_fd {
169        std::io::copy(&mut fd, &mut tempfile)?;
170    } else {
171        // Note that if there's no bootc-owned auth, then we force an empty authfile to ensure
172        // that podman doesn't fall back to searching the user-owned paths.
173        tempfile.write_all(b"{}")?;
174    }
175
176    let tempfile = tempfile
177        .into_inner()
178        .map_err(|e| e.into_error())?
179        .into_std();
180    let fd: Arc<OwnedFd> = std::sync::Arc::new(tempfile.into());
181    let target_fd = fds.take_fd(fd);
182    cmd.env("REGISTRY_AUTH_FILE", format!("/proc/self/fd/{target_fd}"));
183
184    Ok(())
185}
186
187// Initialize a `podman` subprocess with:
188// - storage overridden to point to to storage_root
189// - Authentication (auth.json) using the bootc/ostree owned auth
190fn new_podman_cmd_in(sysroot: &Dir, storage_root: &Dir, run_root: &Dir) -> Result<Command> {
191    let mut cmd = Command::new(bootc_utils::podman_bin());
192    let mut fds = CmdFds::new();
193    bind_storage_roots(&mut cmd, &mut fds, storage_root, run_root)?;
194    let run_root = format!("/proc/self/fd/{STORAGE_RUN_FD}");
195    cmd.args(["--root", STORAGE_ALIAS_DIR, "--runroot", run_root.as_str()]);
196    setup_auth(&mut cmd, &mut fds, sysroot)?;
197    cmd.take_fds(fds);
198    Ok(cmd)
199}
200
201/// Adjust the provided command (skopeo or podman e.g.) to reference
202/// the provided path as an additional image store.
203pub fn set_additional_image_store<'c>(
204    cmd: &'c mut Command,
205    ais: impl AsRef<Utf8Path>,
206) -> &'c mut Command {
207    let ais = ais.as_ref();
208    let storage_opt = format!("additionalimagestore={ais}");
209    cmd.env("STORAGE_OPTS", storage_opt)
210}
211
212/// Ensure that "podman" is the first thing to touch the global storage
213/// instance. This is a workaround for <https://github.com/bootc-dev/bootc/pull/1101#issuecomment-2653862974>
214/// Basically podman has special upgrade logic for when it is the first thing
215/// to initialize the c/storage instance it sets the networking to netavark.
216/// If it's not the first thing, then it assumes an upgrade scenario and we
217/// may be using CNI.
218///
219/// But this legacy path is triggered through us using skopeo, turning off netavark
220/// by default. Work around this by ensuring that /usr/bin/podman is
221/// always the first thing to touch c/storage (at least, when invoked by us).
222///
223/// Call this function any time we're going to write to containers-storage.
224pub(crate) fn ensure_floating_c_storage_initialized() {
225    if let Err(e) = Command::new(bootc_utils::podman_bin())
226        .args(["system", "info"])
227        .stdout(Stdio::null())
228        .run_capture_stderr()
229    {
230        // Out of conservatism we don't make this operation fatal right now.
231        // If something went wrong, then we'll probably fail on a later operation
232        // anyways.
233        tracing::warn!("Failed to query podman system info: {e}");
234    }
235}
236
237impl CStorage {
238    /// Create a `podman image` Command instance prepared to operate on our alternative
239    /// root.
240    pub(crate) fn new_image_cmd(&self) -> Result<Command> {
241        let mut r = new_podman_cmd_in(&self.sysroot, &self.storage_root, &self.run)?;
242        // We want to limit things to only manipulating images by default.
243        r.arg("image");
244        Ok(r)
245    }
246
247    fn init_globals() -> Result<()> {
248        // Ensure our global storage alias dir exists
249        std::fs::create_dir_all(STORAGE_ALIAS_DIR)
250            .with_context(|| format!("Creating {STORAGE_ALIAS_DIR}"))?;
251        Ok(())
252    }
253
254    /// Ensure that the LSM (SELinux) labels are set on the bootc-owned
255    /// containers-storage: instance. We use a `LABELED` stamp file for
256    /// idempotence.
257    #[context("Labeling imgstorage dirs")]
258    pub(crate) fn ensure_labeled(&self) -> Result<()> {
259        if self.storage_root.try_exists(LABELED)? {
260            return Ok(());
261        }
262        let Some(sepolicy) = self.sepolicy.as_ref() else {
263            return Ok(());
264        };
265
266        // recursively set the labels because they were previously set to usr_t,
267        // and there is no policy defined to set them to the c/storage labels
268        crate::lsm::relabel_recurse(
269            &self.storage_root,
270            ".",
271            Some(Utf8Path::new(SYS_CSTOR_PATH)),
272            sepolicy,
273        )
274        .context("labeling storage root")?;
275
276        // fsync so relabel writes are durable before creating the stamp file
277        rustix::fs::fsync(
278            self.storage_root
279                .reopen_as_ownedfd()
280                .context("Reopening as owned fd")?,
281        )
282        .context("fsync")?;
283
284        self.storage_root.create(LABELED)?;
285
286        // Label the stamp file itself to match the storage directory context
287        crate::lsm::relabel(
288            &self.storage_root,
289            &self.storage_root.symlink_metadata(LABELED)?,
290            LABELED.into(),
291            Some(&Utf8Path::new(SYS_CSTOR_PATH).join(LABELED)),
292            sepolicy,
293        )
294        .context("labeling stamp file")?;
295
296        // fsync to persist the stamp file entry
297        rustix::fs::fsync(
298            self.storage_root
299                .reopen_as_ownedfd()
300                .context("Reopening as owned fd")?,
301        )
302        .context("fsync")?;
303
304        Ok(())
305    }
306
307    #[context("Creating imgstorage")]
308    pub(crate) fn create(
309        sysroot: &Dir,
310        run: &Dir,
311        sepolicy: Option<&ostree::SePolicy>,
312    ) -> Result<Self> {
313        Self::init_globals()?;
314
315        let bin = bootc_utils::podman_bin();
316        if !crate::utils::have_executable(bin)? {
317            anyhow::bail!(
318                "{bin} executable not found in PATH; it is required for container image storage operations"
319            );
320        }
321
322        let subpath = &Self::subpath();
323
324        // SAFETY: We know there's a parent
325        let parent = subpath.parent().unwrap();
326        let tmp = format!("{subpath}.tmp");
327        let existed = sysroot
328            .try_exists(subpath)
329            .with_context(|| format!("Querying {subpath}"))?;
330        if !existed {
331            sysroot.remove_all_optional(&tmp).context("Removing tmp")?;
332            sysroot
333                .create_dir_all(parent)
334                .with_context(|| format!("Creating {parent}"))?;
335            sysroot.create_dir_all(&tmp).context("Creating tmpdir")?;
336            let storage_root = sysroot.open_dir(&tmp).context("Open tmp")?;
337
338            // There's no explicit API to initialize a containers-storage:
339            // root, simply passing a path will attempt to auto-create it.
340            // We run "podman images" in the new root.
341            new_podman_cmd_in(&sysroot, &storage_root, &run)?
342                .stdout(Stdio::null())
343                .arg("images")
344                .run_capture_stderr()
345                .context("Initializing images")?;
346            drop(storage_root);
347            sysroot
348                .rename(&tmp, sysroot, subpath)
349                .context("Renaming tmpdir")?;
350            tracing::debug!("Created image store");
351        }
352
353        let s = Self::open(sysroot, run, sepolicy.cloned())?;
354        if existed {
355            // For pre-existing storage (e.g. on a booted system), ensure
356            // labels are correct now. For freshly created storage (e.g.
357            // during install), labeling is deferred until after all image
358            // pulls are complete via an explicit ensure_labeled() call.
359            s.ensure_labeled()?;
360        }
361        Ok(s)
362    }
363
364    #[context("Opening imgstorage")]
365    pub(crate) fn open(
366        sysroot: &Dir,
367        run: &Dir,
368        sepolicy: Option<ostree::SePolicy>,
369    ) -> Result<Self> {
370        tracing::trace!("Opening container image store");
371        Self::init_globals()?;
372        let subpath = &Self::subpath();
373        let storage_root = sysroot
374            .open_dir(subpath)
375            .with_context(|| format!("Opening {subpath}"))?;
376        // Always auto-create this if missing
377        run.create_dir_all(RUNROOT)
378            .with_context(|| format!("Creating {RUNROOT}"))?;
379        let run = run.open_dir(RUNROOT)?;
380        Ok(Self {
381            sysroot: sysroot.try_clone()?,
382            storage_root,
383            run,
384            sepolicy,
385            _unsync: Default::default(),
386        })
387    }
388
389    #[context("Listing images")]
390    pub(crate) async fn list_images(&self) -> Result<Vec<crate::podman::ImageListEntry>> {
391        let mut cmd = self.new_image_cmd()?;
392        cmd.args(["list", "--format=json"]);
393        cmd.stdin(Stdio::null());
394        // It's maximally convenient for us to just pipe the whole output to a tempfile
395        let mut stdout = tempfile::tempfile()?;
396        cmd.stdout(stdout.try_clone()?);
397        // Allocate stderr, which is passed to the status checker
398        let stderr = tempfile::tempfile()?;
399        cmd.stderr(stderr.try_clone()?);
400
401        // Spawn the child and wait
402        AsyncCommand::from(cmd)
403            .status()
404            .await?
405            .check_status_with_stderr(stderr)?;
406        // Spawn a helper thread to avoid blocking the main thread
407        // parsing JSON.
408        tokio::task::spawn_blocking(move || -> Result<_> {
409            stdout.seek(std::io::SeekFrom::Start(0))?;
410            let stdout = std::io::BufReader::new(stdout);
411            let r = serde_json::from_reader(stdout)?;
412            Ok(r)
413        })
414        .await?
415    }
416
417    #[context("Pruning")]
418    pub(crate) async fn prune_except_roots(&self, roots: &HashSet<&str>) -> Result<Vec<String>> {
419        let all_images = self.list_images().await?;
420        tracing::debug!("Images total: {}", all_images.len(),);
421        let mut garbage = Vec::new();
422        for image in all_images {
423            if image
424                .names
425                .iter()
426                .flatten()
427                .all(|name| !roots.contains(name.as_str()))
428            {
429                garbage.push(image.id);
430            }
431        }
432        tracing::debug!("Images to prune: {}", garbage.len());
433        for garbage in garbage.chunks(SUBCMD_ARGV_CHUNKING) {
434            let mut cmd = self.new_image_cmd()?;
435            cmd.stdin(Stdio::null());
436            cmd.stdout(Stdio::null());
437            cmd.arg("rm");
438            cmd.args(garbage);
439            AsyncCommand::from(cmd).run().await?;
440        }
441        Ok(garbage)
442    }
443
444    /// Return true if the image exists in the storage.
445    pub(crate) async fn exists(&self, image: &str) -> Result<bool> {
446        // Sadly https://docs.rs/containers-image-proxy/latest/containers_image_proxy/struct.ImageProxy.html#method.open_image_optional
447        // doesn't work with containers-storage yet
448        if !resolvable_in_storage(image) {
449            tracing::debug!("Not a storage reference: {image}");
450            return Ok(false);
451        }
452        let mut cmd = AsyncCommand::from(self.new_image_cmd()?);
453        cmd.args(["exists", image]);
454        Ok(cmd.status().await?.success())
455    }
456
457    /// Fetch the image if it is not already present; return whether
458    /// or not the image was fetched.
459    pub(crate) async fn pull(&self, image: &str, mode: PullMode) -> Result<bool> {
460        match mode {
461            PullMode::IfNotExists => {
462                if self.exists(image).await? {
463                    tracing::debug!("Image is already present: {image}");
464                    return Ok(false);
465                }
466            }
467            PullMode::Always => {}
468        };
469        let mut cmd = self.new_image_cmd()?;
470        cmd.stdin(Stdio::null());
471        cmd.stdout(Stdio::null());
472        cmd.args(["pull", image]);
473        tracing::debug!("Pulling image: {image}");
474        let mut cmd = AsyncCommand::from(cmd);
475        cmd.run().await.context("Failed to pull image")?;
476        Ok(true)
477    }
478
479    /// Copy an image from the default container storage (/var/lib/containers/)
480    /// to this storage.
481    #[context("Pulling from host storage: {image}")]
482    pub(crate) async fn pull_from_host_storage(&self, image: &str) -> Result<()> {
483        let mut cmd = Command::new(bootc_utils::podman_bin());
484        cmd.stdin(Stdio::null());
485        cmd.stdout(Stdio::null());
486        // An ephemeral place for the transient state;
487        let temp_runroot = TempDir::new(cap_std::ambient_authority())?;
488        let mut fds = CmdFds::new();
489        bind_storage_roots(&mut cmd, &mut fds, &self.storage_root, &temp_runroot)?;
490        cmd.take_fds(fds);
491
492        // The destination (target stateroot) + container storage dest
493        let storage_dest = &format!(
494            "containers-storage:[overlay@{STORAGE_ALIAS_DIR}+/proc/self/fd/{STORAGE_RUN_FD}]"
495        );
496        cmd.args(["image", "push", "--remove-signatures", image])
497            .arg(format!("{storage_dest}{image}"));
498        let mut cmd = AsyncCommand::from(cmd);
499        cmd.run().await?;
500        temp_runroot.close()?;
501        Ok(())
502    }
503
504    /// Pull an image with streaming progress display.
505    ///
506    /// Uses the podman native libpod HTTP API instead of shelling out,
507    /// enabling per-blob download progress. Registry auth is handled
508    /// via `REGISTRY_AUTH_FILE` on the podman service process.
509    ///
510    /// Always pulls (policy=always) so updated digests are fetched
511    /// even if an image with the same tag exists locally.
512    pub(crate) async fn pull_with_progress(&self, image: &str) -> Result<()> {
513        let client = crate::podman_client::PodmanClient::connect(
514            &self.sysroot,
515            &self.storage_root,
516            &self.run,
517        )
518        .await?;
519        client.pull_with_progress(image).await
520    }
521
522    pub(crate) fn subpath() -> Utf8PathBuf {
523        Utf8Path::new(crate::store::BOOTC_ROOT).join(SUBPATH)
524    }
525}
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530    static_assertions::assert_not_impl_any!(CStorage: Sync);
531
532    #[test]
533    fn test_resolvable_in_storage() {
534        assert!(resolvable_in_storage("quay.io/example/foo:latest"));
535        assert!(resolvable_in_storage("localhost:5000/example/foo:latest"));
536        assert!(resolvable_in_storage(
537            "containers-storage:localhost/foo:latest"
538        ));
539        assert!(!resolvable_in_storage(
540            "registry:quay.io/example/foo:latest"
541        ));
542        assert!(!resolvable_in_storage("oci:/var/tmp/image.oci:latest"));
543        assert!(!resolvable_in_storage("oci-archive:/var/tmp/foo.tar"));
544        assert!(!resolvable_in_storage("dir:/var/tmp/foo"));
545        assert!(!resolvable_in_storage(
546            "docker://quay.io/example/foo:latest"
547        ));
548        assert!(!resolvable_in_storage("docker-archive:/var/tmp/foo.tar"));
549        assert!(!resolvable_in_storage("docker-daemon:localhost/foo:latest"));
550    }
551}