Skip to content

Commit

Permalink
Initialize a containers-storage: owned by bootc, use for bound images
Browse files Browse the repository at this point in the history
Initial work for: containers#721

- Initialize a containers-storage: instance at install time
  (that defaults to empty)
- "Open" it (but do nothing with it) as part of the core CLI
  operations

Further APIs and work will build on top of this.

Signed-off-by: Colin Walters <[email protected]>
  • Loading branch information
cgwalters committed Jul 28, 2024
1 parent c39b664 commit ea78d29
Show file tree
Hide file tree
Showing 13 changed files with 349 additions and 76 deletions.
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ install:
install -D -m 0755 -t $(DESTDIR)$(prefix)/bin target/release/bootc
install -d -m 0755 $(DESTDIR)$(prefix)/lib/bootc/bound-images.d
install -d -m 0755 $(DESTDIR)$(prefix)/lib/bootc/kargs.d
ln -s /sysroot/ostree/bootc/storage $(DESTDIR)$(prefix)/lib/bootc/storage
install -d -m 0755 $(DESTDIR)$(prefix)/lib/systemd/system-generators/
ln -f $(DESTDIR)$(prefix)/bin/bootc $(DESTDIR)$(prefix)/lib/systemd/system-generators/bootc-systemd-generator
install -d $(DESTDIR)$(prefix)/lib/bootc/install
Expand Down
2 changes: 1 addition & 1 deletion lib/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ liboverdrop = "0.1.0"
libsystemd = "0.7"
openssl = "^0.10.64"
regex = "1.10.4"
rustix = { "version" = "0.38.34", features = ["thread", "fs", "system", "process"] }
rustix = { "version" = "0.38.34", features = ["thread", "fs", "system", "process", "mount"] }
schemars = { version = "0.8.17", features = ["chrono"] }
serde = { workspace = true, features = ["derive"] }
serde_ignored = "0.1.10"
Expand Down
29 changes: 16 additions & 13 deletions lib/src/boundimage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@
//! pre-pulled (and in the future, pinned) before a new image root
//! is considered ready.
use crate::task::Task;
use anyhow::{Context, Result};
use camino::Utf8Path;
use cap_std_ext::cap_std::fs::Dir;
use cap_std_ext::dirext::CapStdExtDirExt;
use fn_error_context::context;
use ostree_ext::containers_image_proxy;
use ostree_ext::ostree::Deployment;
use ostree_ext::sysroot::SysrootLock;

use crate::imgstorage::PullMode;
use crate::store::Storage;

/// The path in a root for bound images; this directory should only contain
/// symbolic links to `.container` or `.image` files.
Expand All @@ -37,10 +38,10 @@ pub(crate) struct ResolvedBoundImage {
}

/// Given a deployment, pull all container images it references.
pub(crate) fn pull_bound_images(sysroot: &SysrootLock, deployment: &Deployment) -> Result<()> {
pub(crate) async fn pull_bound_images(sysroot: &Storage, deployment: &Deployment) -> Result<()> {
let deployment_root = &crate::utils::deployment_fd(sysroot, deployment)?;
let bound_images = query_bound_images(deployment_root)?;
pull_images(deployment_root, bound_images)
pull_images(sysroot, bound_images).await
}

#[context("Querying bound images")]
Expand Down Expand Up @@ -133,18 +134,20 @@ fn parse_container_file(file_contents: &tini::Ini) -> Result<BoundImage> {
Ok(bound_image)
}

#[context("pull bound images")]
pub(crate) fn pull_images(_deployment_root: &Dir, bound_images: Vec<BoundImage>) -> Result<()> {
#[context("Pulling bound images")]
pub(crate) async fn pull_images(sysroot: &Storage, bound_images: Vec<BoundImage>) -> Result<()> {
tracing::debug!("Pulling bound images: {}", bound_images.len());
//TODO: do this in parallel
for bound_image in bound_images {
let mut task = Task::new("Pulling bound image", "/usr/bin/podman")
.arg("pull")
.arg(&bound_image.image);
if let Some(auth_file) = &bound_image.auth_file {
task = task.arg("--authfile").arg(auth_file);
}
task.run()?;
let image = &bound_image.image;
let desc = format!("Updating bound image: {image}");
crate::utils::async_task_with_spinner(&desc, async move {
sysroot
.imgstore
.pull(&bound_image.image, PullMode::IfNotExists)
.await
})
.await?;
}

Ok(())
Expand Down
4 changes: 3 additions & 1 deletion lib/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -430,10 +430,12 @@ pub(crate) async fn get_locked_sysroot() -> Result<ostree_ext::sysroot::SysrootL
Ok(sysroot)
}

/// Load global storage state, expecting that we're booted into a bootc system.
#[context("Initializing storage")]
pub(crate) async fn get_storage() -> Result<crate::store::Storage> {
let global_run = Dir::open_ambient_dir("/run", cap_std::ambient_authority())?;
let sysroot = get_locked_sysroot().await?;
crate::store::Storage::new(sysroot)
crate::store::Storage::new(sysroot, &global_run)
}

#[context("Querying root privilege")]
Expand Down
2 changes: 1 addition & 1 deletion lib/src/deploy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,7 @@ pub(crate) async fn stage(
)
.await?;

crate::boundimage::pull_bound_images(sysroot, &deployment)?;
crate::boundimage::pull_bound_images(sysroot, &deployment).await?;

crate::deploy::cleanup(sysroot).await?;
println!("Queued for next boot: {:#}", spec.image);
Expand Down
12 changes: 11 additions & 1 deletion lib/src/image.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,29 @@ use anyhow::{Context, Result};
use fn_error_context::context;
use ostree_ext::container::{ImageReference, Transport};

use crate::utils::CommandRunExt;

/// The name of the image we push to containers-storage if nothing is specified.
const IMAGE_DEFAULT: &str = "localhost/bootc";

#[context("Listing images")]
pub(crate) async fn list_entrypoint() -> Result<()> {
let sysroot = crate::cli::get_locked_sysroot().await?;
let sysroot = crate::cli::get_storage().await?;
let repo = &sysroot.repo();

let images = ostree_ext::container::store::list_images(repo).context("Querying images")?;

println!("# Host images");
for image in images {
println!("{image}");
}
println!("");

println!("# Logically bound images");
let mut listcmd = sysroot.imgstore.new_image_cmd()?;
listcmd.arg("list");
listcmd.run()?;

Ok(())
}

Expand Down
228 changes: 228 additions & 0 deletions lib/src/imgstorage.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
//! # bootc-managed container storage
//!
//! The default storage for this project uses ostree, canonically storing all of its state in
//! `/sysroot/ostree`.
//!
//! This containers-storage: which canonically lives in `/sysroot/ostree/bootc`.
use std::io::{Read, Seek};
use std::os::unix::process::CommandExt;
use std::process::Command;
use std::sync::Arc;

use anyhow::{Context, Result};
use camino::Utf8Path;
use cap_std_ext::cap_std;
use cap_std_ext::cap_std::fs::Dir;
use cap_std_ext::cap_tempfile::TempDir;
use cap_std_ext::dirext::CapStdExtDirExt;
use fn_error_context::context;
use rustix::io::DupFlags;
use std::os::fd::{AsFd, FromRawFd, OwnedFd};
use tokio::process::Command as AsyncCommand;

use crate::utils::{AsyncCommandRunExt, CommandRunExt};

/// Global directory path which we use for podman to point
/// it at our storage.
pub(crate) const STORAGE_ALIAS_DIR: &str = "/run/bootc/storage";
/// And a similar alias for the runtime state. The 3 here is hardcoded,
/// and set up in a fork below too.
pub(crate) const STORAGE_RUN_ALIAS_DIR: &str = "/proc/self/fd/3";
const STORAGE_RUN_FD: i32 = 3;

/// The path to the storage, relative to the physical system root.
pub(crate) const SUBPATH: &str = "ostree/bootc/storage";
/// The path to the "runroot" with transient runtime state; this is
/// relative to the /run directory
const RUNROOT: &str = "bootc/storage";
pub(crate) struct Storage {
/// The root directory
sysroot: Dir,
/// The location of container storage
storage_root: Dir,
#[allow(dead_code)]
/// Our runtime state
run: Dir,
}

#[derive(Debug, PartialEq, Eq)]
pub(crate) enum PullMode {
/// Pull only if the image is not present
IfNotExists,
/// Always check for an update
#[allow(dead_code)]
Always,
}

async fn run_cmd_async(cmd: Command) -> Result<()> {
let mut cmd = tokio::process::Command::from(cmd);
cmd.kill_on_drop(true);
let mut stderr = tempfile::tempfile()?;
cmd.stderr(stderr.try_clone()?);
if let Err(e) = cmd.run().await {
stderr.seek(std::io::SeekFrom::Start(0))?;
let mut stderr_buf = String::new();
// Ignore errors
let _ = stderr.read_to_string(&mut stderr_buf);
return Err(anyhow::anyhow!("{e}: {stderr_buf}"));
}
Ok(())
}

#[allow(unsafe_code)]
#[context("Binding storage roots")]
fn bind_storage_roots(cmd: &mut Command, storage_root: &Dir, run_root: &Dir) -> Result<()> {
// podman requires an absolute path, for two reasons right now:
// - It writes the file paths into `db.sql`, a sqlite database for unknown reasons
// - It forks helper binaries, so just giving it /proc/self/fd won't work as
// those helpers may not get the fd passed. (which is also true of skopeo)
// We create a new mount namespace, which also has the helpful side effect
// of automatically cleaning up the global bind mount that the storage stack
// creates.

let storage_root = Arc::new(storage_root.try_clone().context("Cloning storage root")?);
let run_root = Arc::new(run_root.try_clone().context("Cloning runroot")?);
// SAFETY: All the APIs we call here are safe to invoke between fork and exec.
unsafe {
cmd.pre_exec(move || {
// Set our working directory here, because this is the only way I could
// get the mount() below to work.
rustix::process::fchdir(&storage_root)?;
rustix::thread::unshare(rustix::thread::UnshareFlags::NEWNS)?;
rustix::mount::mount_bind(".", STORAGE_ALIAS_DIR)?;
// Set up the runtime dir via /proc/self/fd, which works because it's
// not passed to any child processes. Note that we dup it without
// setting O_CLOEXEC intentionally
let mut ofd = OwnedFd::from_raw_fd(STORAGE_RUN_FD);
rustix::io::dup3(run_root.as_fd(), &mut ofd, DupFlags::empty())?;
// We didn't actually "own" this fd to start with
std::mem::forget(ofd);
Ok(())
})
};
Ok(())
}

fn new_podman_cmd_in(storage_root: &Dir, run_root: &Dir) -> Result<Command> {
let mut cmd = Command::new("podman");
bind_storage_roots(&mut cmd, storage_root, run_root)?;
cmd.args([
"--root",
STORAGE_ALIAS_DIR,
"--runroot",
STORAGE_RUN_ALIAS_DIR,
]);
Ok(cmd)
}

impl Storage {
/// Create a `podman image` Command instance prepared to operate on our alternative
/// root.
pub(crate) fn new_image_cmd(&self) -> Result<Command> {
let mut r = new_podman_cmd_in(&self.storage_root, &self.run)?;
// We want to limit things to only manipulating images by default.
r.arg("image");
Ok(r)
}

fn init_globals() -> Result<()> {
// Ensure our global storage alias dirs exist
for d in [STORAGE_ALIAS_DIR] {
std::fs::create_dir_all(d).with_context(|| format!("Creating {d}"))?;
}
Ok(())
}

#[context("Creating imgstorage")]
pub(crate) fn create(sysroot: &Dir, run: &Dir) -> Result<Self> {
Self::init_globals()?;
let subpath = Utf8Path::new(SUBPATH);
// SAFETY: We know there's a parent
let parent = subpath.parent().unwrap();
if !sysroot
.try_exists(subpath)
.with_context(|| format!("Querying {subpath}"))?
{
let tmp = format!("{SUBPATH}.tmp");
sysroot.remove_all_optional(&tmp).context("Removing tmp")?;
sysroot
.create_dir_all(parent)
.with_context(|| format!("Creating {parent}"))?;
sysroot.create_dir_all(&tmp).context("Creating tmpdir")?;
let storage_root = sysroot.open_dir(&tmp).context("Open tmp")?;
// There's no explicit API to initialize a containers-storage:
// root, simply passing a path will attempt to auto-create it.
// We run "podman images" in the new root.
new_podman_cmd_in(&storage_root, &run)?
.arg("images")
.run()
.context("Initializing images")?;
drop(storage_root);
sysroot
.rename(&tmp, sysroot, subpath)
.context("Renaming tmpdir")?;
}
Self::open(sysroot, run)
}

#[context("Opening imgstorage")]
pub(crate) fn open(sysroot: &Dir, run: &Dir) -> Result<Self> {
Self::init_globals()?;
let storage_root = sysroot
.open_dir(SUBPATH)
.with_context(|| format!("Opening {SUBPATH}"))?;
// Always auto-create this if missing
run.create_dir_all(RUNROOT)
.with_context(|| format!("Creating {RUNROOT}"))?;
let run = run.open_dir(RUNROOT)?;
Ok(Self {
sysroot: sysroot.try_clone()?,
storage_root,
run,
})
}

/// Fetch the image if it is not already present; return whether
/// or not the image was fetched.
pub(crate) async fn pull(&self, image: &str, mode: PullMode) -> Result<bool> {
match mode {
PullMode::IfNotExists => {
// Sadly https://docs.rs/containers-image-proxy/latest/containers_image_proxy/struct.ImageProxy.html#method.open_image_optional
// doesn't work with containers-storage yet
let mut cmd = AsyncCommand::from(self.new_image_cmd()?);
cmd.args(["exists", image]);
let exists = cmd.status().await?.success();
if exists {
return Ok(false);
}
}
PullMode::Always => {}
};
let mut cmd = self.new_image_cmd()?;
cmd.args(["pull", image]);
let authfile = ostree_ext::globals::get_global_authfile(&self.sysroot)?
.map(|(authfile, _fd)| authfile);
if let Some(authfile) = authfile {
cmd.args(["--authfile", authfile.as_str()]);
}
run_cmd_async(cmd).await.context("Failed to pull image")?;
Ok(true)
}

pub(crate) async fn pull_from_host_storage(&self, image: &str) -> Result<()> {
let mut cmd = Command::new("podman");
// An ephemeral place for the transient state
let temp_runroot = TempDir::new(cap_std::ambient_authority())?;
bind_storage_roots(&mut cmd, &self.storage_root, &temp_runroot)?;

// The destination (target stateroot) + container storage dest
let storage_dest =
&format!("containers-storage:[overlay@{STORAGE_ALIAS_DIR}+{STORAGE_RUN_ALIAS_DIR}]");
cmd.args(["image", "push", image])
.arg(format!("{storage_dest}{image}"));
run_cmd_async(cmd).await?;
temp_runroot.close()?;
Ok(())
}
}
Loading

0 comments on commit ea78d29

Please sign in to comment.