refactor: cut provision

The runtime runs images; building them belongs to the OCI ecosystem —
CI builds, digests, scanning — and a keyed local layer could never
offer a digest. Provision also installed packages through TSI, whose
per-stream throughput makes a real package install crawl exactly at
first use, and the bare-command shape mutated the shared cache entry
under the image's own name.

0.1 runs images: run, pull, probe — distro aliases, OCI references
with digest pins, local rootfs archives. The one honest idea provision
carried stays in SPEC's Later: provision by booting, for locked-down
hosts that cannot run any builder.
This commit is contained in:
vhaudiquet committed 2026-10-30 15:57:10 +01:00
1 parent e632fa2125
commit 0ad07c182f
10 files changed
+56 -946

No files matched your search

+10 -6
View File
@@ -185,8 +185,8 @@ they need host setup the budget cannot afford. Port publishing is how
a later in-guest agent serves the host (Later).
**Memory and CPUs.** Default 1G and all logical CPUs; `-m` overrides.
A per-command machine is provisioned per command, and agent consumers
run several concurrently.
Each command gets its own machine, and agent consumers run several
concurrently.
## Trust model
@@ -279,10 +279,14 @@ build is deleted.
correct-and-slow v1 posture graduates into this.
- **Guest agent, long-lived machines.** Exec without a boot; the shape
to grow into when per-command VMs stop being right.
- **Layer eviction.** Provisioned layers are already keyed: a spec
file (base, packages, run) hashed with its base reference, named by
`--layer` for `run --layer`. Nothing prunes the derived entries yet;
eviction (LRU or an explicit prune) keeps them bounded.
- **A build story for hosts that cannot build.** Provision was cut
from 0.1: a runtime that also builds images is two products, and
the OCI ecosystem already builds images — CI, digests, scanning.
What has no answer yet is the locked-down host with no daemon, no
namespaces, no way to run a builder. There, provision by booting —
the guest installs, the host stays confined, the artifact is a
keyed cache entry. Waits on TSI throughput and on someone actually
needing it.
- **passt** (`krun_set_passt_fd`) if TSI throughput or UDP listening
ever matters.
- **An agent in the guest** over port publishing: an http backend
+3 -36
View File
@@ -29,34 +29,6 @@ pub enum Command {
/// away. Persistence crosses a --volume share or it does not
/// happen.
Run(RunArgs),
/// Bake a provisioned layer over a base image
///
/// Two shapes. --spec FILE describes the layer in checked-in YAML
/// (base, packages, run); the layer is keyed by the file's
/// content, so the same spec never provisions twice and a changed
/// spec never reuses an old layer, and --layer NAME names it for
/// `run --layer NAME`. `provision IMAGE -- COMMAND` bakes the
/// command into the image's cache entry itself: every later boot
/// of that image carries what the command installed.
Provision {
/// Base image: distro name or OCI reference; not needed when
/// the spec carries a base
image: Option<String>,
/// Provisioning spec file (YAML: base, packages, run); the
/// layer it describes is keyed by the file's content
#[arg(long)]
spec: Option<std::path::PathBuf>,
/// Name the provisioned layer, so `run --layer NAME` boots it
#[arg(long)]
layer: Option<String>,
/// Ignore the cache: re-download the base and re-provision
#[arg(long)]
no_cache: bool,
/// Provisioning command, baked into the image's cache entry
/// (use with IMAGE, without --spec)
#[arg(last = true)]
command: Vec<String>,
},
/// Fetch or refresh an image's cache entry without booting it
///
/// Unpinned OCI tags are re-checked against the registry digest
@@ -82,9 +54,8 @@ pub enum Command {
#[derive(Args, Debug)]
pub struct RunArgs {
/// Distro name, OCI image reference, or a local rootfs archive
/// path (./rootfs.tar.gz); not needed with --layer
#[arg(required_unless_present = "layer")]
pub image: Option<String>,
/// path (./rootfs.tar.gz)
pub image: String,
/// Keep the machine's stdin open: the host's stdin feeds the
/// guest, so shells and prompts read typed input
#[arg(short = 'i', long)]
@@ -122,10 +93,6 @@ pub struct RunArgs {
/// Ignore the cache: download the image fresh
#[arg(long)]
pub no_cache: bool,
/// Boot a named provisioned layer (see provision --layer)
/// instead of an image
#[arg(long)]
pub layer: Option<String>,
}
/// Parse one `HOST:GUEST[:ro]` volume specification. The guest path
@@ -199,7 +166,7 @@ mod tests {
};
assert!(args.interactive);
assert!(args.tty);
assert_eq!(args.image.as_deref(), Some("alpine"));
assert_eq!(args.image, "alpine");
}
#[test]
+1 -139
View File
@@ -20,146 +20,11 @@ fn main() -> std::process::ExitCode {
logger::init(cli.verbose);
match cli.command {
Command::Run(args) => cmd_run(&args, cli.verbose),
Command::Provision {
image,
spec,
layer,
no_cache,
command,
} => cmd_provision(image, spec, layer, no_cache, &command, cli.verbose),
Command::Pull { image, no_cache } => cmd_pull(&image, no_cache, cli.verbose),
Command::Probe => cmd_probe(),
}
}
fn cmd_provision(
image: Option<String>,
spec: Option<std::path::PathBuf>,
layer: Option<String>,
no_cache: bool,
command: &[String],
cli_verbose: bool,
) -> std::process::ExitCode {
use ecrou::cache::RootfsCache;
use ecrou::rootfs::PrepareRequest;
use ecrou::spec::ProvisionSpec;
// Two shapes: `provision IMAGE -- COMMAND` bakes a one-off
// command; `provision --spec FILE --layer NAME` bakes a checked-in
// spec into a named, content-keyed layer. A spec plus a command
// has no single meaning.
if spec.is_none() == command.is_empty() {
return fail(&anyhow::anyhow!(
"provision needs either a spec file (--spec) or a command: \
ecrou provision <IMAGE> -- <COMMAND...>"
));
}
let level = if cli_verbose {
LogLevel::Verbose
} else {
LogLevel::Normal
};
let mut options = RunOptions {
log_level: level,
// The machine exists to change the rootfs, not to cross a
// share: the workspace would ride the persist into the cache.
..RunOptions::default()
};
options.workdir = "/".to_owned();
let progress: &dyn Progress = &TermProgress::new();
let dir = match RootfsCache::default_dir() {
Ok(dir) => dir,
Err(err) => return fail(&anyhow::Error::new(err)),
};
let cache = RootfsCache::new(dir);
// The spec decides the request: its base (or the positional
// image), its key, and its script — the packages through the
// base's package manager, then the run block. The key hashes the
// raw bytes and the base reference, so the cache entries two
// projects see can never cross.
let mut request = PrepareRequest::new(String::new());
request.no_cache = no_cache;
let mut script;
match &spec {
Some(path) => {
let bytes = match std::fs::read(path) {
Ok(bytes) => bytes,
Err(err) => return fail(&anyhow::anyhow!("reading {}: {err}", path.display())),
};
let parsed = match ProvisionSpec::parse(&bytes) {
Ok(parsed) => parsed,
Err(err) => return fail(&err),
};
let base = match parsed.base.clone().or_else(|| image.clone()) {
Some(base) => base,
None => {
return fail(&anyhow::anyhow!(
"the spec has no base and no image was named: \
ecrou provision <IMAGE> --spec {path}",
path = path.display()
))
}
};
script = parsed.script();
let key = ProvisionSpec::key(&bytes, &base);
request.image = base;
request.layer_key = Some(key);
}
None => {
if layer.is_some() {
return fail(&anyhow::anyhow!(
"--layer names a provisioned spec, not a bare command"
));
}
request.image = image
.clone()
.expect("clap requires an image without --spec");
script = command.join(" ");
script.push('\n');
}
}
options.image = request.image.clone();
options.command = vec!["/bin/sh".to_owned(), "-c".to_owned(), script];
let outcome = cache.prepare_provisioned(&request, progress, |rootfs| {
let code = ecrou::vm::run_prepared(rootfs, &options)?;
if code != 0 {
anyhow::bail!(
"the provisioning command exited {code}; the cache entry was left unprovisioned"
);
}
Ok(())
});
match outcome {
Ok(_rootfs) => {
// The pointer lands only after the entry does: a name
// that resolves is a layer that boots.
if let (Some(name), Some(key)) = (&layer, &request.layer_key) {
let path = match cache.resolved_entry_path(
&request.image,
&request.resolved_arch(),
Some(key),
) {
Ok(path) => path,
Err(err) => return fail(&anyhow::Error::new(err)),
};
let filename = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
if let Err(err) = cache.write_layer_pointer(name, &filename) {
return fail(&anyhow::Error::new(err));
}
}
std::process::ExitCode::SUCCESS
}
Err(err) => fail(&anyhow::Error::new(err)),
}
}
fn cmd_run(args: &RunArgs, cli_verbose: bool) -> std::process::ExitCode {
// A tty drives the payload's output through the terminal while the
// capture share points the same streams at files: one of them has
@@ -171,9 +36,7 @@ fn cmd_run(args: &RunArgs, cli_verbose: bool) -> std::process::ExitCode {
));
}
let mut options = RunOptions {
// Without a layer the image is required by clap; with one the
// field is dead — the layer entry carries the rootfs.
image: args.image.clone().unwrap_or_default(),
image: args.image.clone(),
command: args.command.clone(),
memory: args.memory.clone(),
workdir: args.workdir.clone(),
@@ -187,7 +50,6 @@ fn cmd_run(args: &RunArgs, cli_verbose: bool) -> std::process::ExitCode {
interactive: args.interactive,
tty: args.tty,
no_cache: args.no_cache,
layer: args.layer.clone(),
capture: args.capture.clone(),
..RunOptions::default()
};
+12 -254
View File
@@ -1,53 +1,24 @@
//! The rootfs tarball cache: entries, freshness, and provisioned layers.
//! The rootfs tarball cache: entries and freshness.
//!
//! One directory of content-addressed-ish entries: every downloaded or
//! provisioned rootfs lives under a name derived from its source, an
//! advisory lock keeps concurrent runs from downloading the same image
//! twice, a digest sidecar lets floating tags be re-checked cheaply,
//! and layer pointers name provisioned entries so runs can boot them
//! without re-deriving keys. The lifecycle that drives it — prepare,
//! provision, persist — lives in [`crate::rootfs`].
//! One directory of content-addressed-ish entries: every downloaded
//! rootfs lives under a name derived from its source, an advisory lock
//! keeps concurrent runs from downloading the same image twice, and a
//! digest sidecar lets floating tags be re-checked cheaply. The
//! lifecycle that drives it — prepare, pull — lives in
//! [`crate::rootfs`].
use std::path::{Path, PathBuf};
use anyhow::{anyhow, Context};
use crate::distro::{map_arch, resolve_source, Distro, ImageSource};
use crate::download::{digest_sidecar, download_image};
use crate::error::Error;
use crate::distro::{map_arch, Distro, ImageSource};
use crate::download::digest_sidecar;
use crate::extract::extract_tarball;
use crate::progress::Progress;
use crate::registry::fetch_oci_digest;
use crate::rootfs::PreparedRootfs;
use crate::Result;
/// Sidecar suffix marking a cache entry that holds a provisioned rootfs.
pub(crate) const PROVISIONED_SUFFIX: &str = "provisioned";
/// Download `source` into the cache at `cache_path`, and drop any
/// provisioned marker: whatever was provisioned belonged to the entry
/// this download replaces, and a stale marker would present the
/// pristine image as provisioned and skip the next provisioning for
/// good.
pub(crate) fn fill_entry(
source: &ImageSource,
cache_path: &Path,
arch: &str,
progress: &dyn Progress,
) -> Result<()> {
download_image(source, cache_path, arch, progress)?;
let marker = sidecar(cache_path, PROVISIONED_SUFFIX);
if marker.exists() {
std::fs::remove_file(&marker).with_context(|| {
format!(
"Failed to drop the stale provisioned marker {}",
marker.display()
)
})?;
}
Ok(())
}
/// Whether an OCI image with a floating tag (":latest") has moved since
/// it was cached: fetch the current manifest digest from the registry
/// and compare it against the digest stored from the last download.
@@ -136,35 +107,6 @@ impl EntryLock {
}
}
/// A layer name is a file name under the cache's `layers/` directory
/// and a component of entry file names: keep it to a safe charset so a
/// name like `../x` or `a/b` cannot climb out of the cache directory.
fn validate_layer_name(name: &str) -> Result<()> {
let ok = !name.is_empty()
&& name != "."
&& name != ".."
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
&& !name.starts_with('.');
if ok {
Ok(())
} else {
Err(Error::Other(anyhow!(
"invalid layer name {name:?}: use letters, digits, '_' or '-', \
not starting with '.'"
)))
}
}
/// Sidecar path for a cache entry: `foo.tar.gz` → `foo.tar.gz.<suffix>`.
pub(crate) fn sidecar(path: &Path, suffix: &str) -> PathBuf {
let mut s = path.as_os_str().to_owned();
s.push(".");
s.push(suffix);
PathBuf::from(s)
}
/// The image tarball cache (default location: `~/.cache/ecrou`).
#[derive(Debug, Clone)]
pub struct RootfsCache {
@@ -191,106 +133,10 @@ impl RootfsCache {
pub fn tarball_path(&self, source: &ImageSource, arch: &str) -> PathBuf {
self.dir.join(generate_cache_filename(source, arch))
}
/// The cache path for a prepared entry, keyed or not: a layer key
/// is spliced into the filename, so a layer and its base — and two
/// layers of one base — live in separate entries, never overwrite
/// each other, and evict separately. The key is validated here, at
/// the splice point, so no caller can derive a path outside the
/// cache directory.
pub fn entry_path(
&self,
source: &ImageSource,
arch: &str,
layer_key: Option<&str>,
) -> Result<PathBuf> {
match layer_key {
None => Ok(self.tarball_path(source, arch)),
Some(key) => {
validate_layer_name(key)?;
let filename = generate_cache_filename(source, arch);
let stem = filename
.rsplit_once('.')
.map(|(stem, _)| stem)
.unwrap_or(&filename);
Ok(self.dir.join(format!("{stem}.{key}.tar.gz")))
}
}
}
/// Extracts a named layer into a fresh scratch directory: the boot
/// side of `run --layer`. A missing pointer or a pruned entry is a
/// named error, not a download — layers are provisioned, never
/// fetched.
pub fn extract_layer(&self, name: &str, progress: &dyn Progress) -> Result<PreparedRootfs> {
validate_layer_name(name)?;
let path = self.layer_entry(name)?.ok_or_else(|| {
Error::Other(anyhow::anyhow!(
"layer {name} is not provisioned: run \
`ecrou provision --spec <spec> --layer {name}` first"
))
})?;
extract_prepared(&path.clone(), path, progress).map_err(Error::Other)
}
/// The cache path an image resolves to, keyed or not — the same
/// resolution `prepare` does (floating aliases resolved), so a
/// pointer written from this path names an entry that exists.
pub fn resolved_entry_path(
&self,
image: &str,
arch: &str,
layer_key: Option<&str>,
) -> Result<PathBuf> {
let source = resolve_source(image, arch)?;
self.entry_path(&source, arch, layer_key)
}
/// Writes the layer pointer: `layers/<name>` holds the cache
/// filename the name resolves to. Consumers boot layers by name —
/// `run` should not have to re-derive the key from the spec, only
/// agree with the provisioning call on what the layer is called.
pub fn write_layer_pointer(&self, name: &str, cache_filename: &str) -> Result<()> {
validate_layer_name(name)?;
let dir = self.dir.join("layers");
std::fs::create_dir_all(&dir)
.with_context(|| format!("Failed to create the layer directory {}", dir.display()))?;
// Atomic write: a concurrent run reading the pointer must
// never see a truncated filename mid-write.
let pointer = dir.join(name);
let partial = crate::utils::partial_path(&pointer);
std::fs::write(&partial, cache_filename)
.with_context(|| format!("Failed to write the layer pointer for {name}"))?;
std::fs::rename(&partial, &pointer)
.with_context(|| format!("Failed to move the layer pointer into place for {name}"))?;
Ok(())
}
/// The cache path a layer pointer names, and whether the layer is
/// really there: a pointer is a name, not a promise — the entry it
/// names can be gone (a pruned cache, a base bump) while the
/// pointer survives.
pub fn layer_entry(&self, name: &str) -> Result<Option<PathBuf>> {
validate_layer_name(name)?;
let pointer = self.dir.join("layers").join(name);
let filename = match std::fs::read_to_string(&pointer) {
Ok(filename) => filename,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(err) => {
return Err(Error::Other(
anyhow::Error::from(err)
.context(format!("reading the layer pointer for {name}")),
))
}
};
let path = self.dir.join(filename.trim());
Ok((path.exists() && sidecar(&path, PROVISIONED_SUFFIX).exists()).then_some(path))
}
}
pub(crate) fn extract_prepared(
source: &Path,
cache_path: PathBuf,
progress: &dyn Progress,
) -> anyhow::Result<PreparedRootfs> {
let temp = tempfile::tempdir().context("Failed to create temporary directory")?;
@@ -300,10 +146,7 @@ pub(crate) fn extract_prepared(
extract_tarball(source, &rootfs, progress)?;
provision_resolver(&rootfs);
Ok(PreparedRootfs {
rootfs: temp,
cache_path,
})
Ok(PreparedRootfs { rootfs: temp })
}
/// Give the guest the host's resolver: the engine proxies guest
@@ -421,10 +264,8 @@ fn generate_cache_filename(source: &ImageSource, arch: &str) -> String {
.and_then(|s| s.to_str())
.unwrap_or("rootfs")
.replace(['/', '\\', ':'], "_");
// Size and mtime key the entry: a provisioning over a
// local archive persists into the cache under this name,
// never over the caller's file, and an edited archive
// lands in a fresh entry.
// Size and mtime key the entry: an edited archive lands
// in a fresh entry name, never a stale one.
let meta = std::fs::metadata(path);
let len = meta.as_ref().map(|m| m.len()).unwrap_or(0);
let mtime = meta
@@ -494,14 +335,6 @@ mod tests {
);
}
#[test]
fn sidecar_appends_suffix() {
assert_eq!(
sidecar(Path::new("/cache/foo.tar.gz"), PROVISIONED_SUFFIX),
PathBuf::from("/cache/foo.tar.gz.provisioned")
);
}
/// Build a minimal rootfs tree with a nested executable and a symlink,
/// pack it, and extract it back through the public extract_tarball path.
@@ -534,79 +367,4 @@ mod tests {
let existing = tempfile::NamedTempFile::new().unwrap();
assert!(!oci_freshness_changed(&pinned, existing.path()));
}
#[test]
fn a_layer_key_splices_into_the_entry_name() {
let cache = RootfsCache::new(std::path::PathBuf::from("/cache"));
let source = crate::distro::parse_image_ref("alpine:3.23", "amd64").expect("parses");
let plain = cache.entry_path(&source, "amd64", None).unwrap();
let keyed = cache.entry_path(&source, "amd64", Some("abc123")).unwrap();
let other = cache.entry_path(&source, "amd64", Some("def456")).unwrap();
eprintln!("plain={plain:?} keyed={keyed:?} other={other:?}");
assert_ne!(plain, keyed, "the key changes the entry");
assert_ne!(keyed, other, "two keys are two entries");
}
#[test]
fn a_path_derivation_never_escapes_the_cache_directory() {
// The splice point validates: even a consumer that only
// derives paths (never writes) gets the named error instead
// of a path outside the cache directory.
let cache = RootfsCache::new("/cache");
let err = cache
.resolved_entry_path("alpine:3.23", "amd64", Some("../x"))
.unwrap_err();
assert!(err.to_string().contains("invalid layer name"), "{err}");
}
#[test]
fn layer_names_cannot_escape_the_cache_directory() {
let dir = tempfile::tempdir().unwrap();
let cache = RootfsCache::new(dir.path().to_path_buf());
for bad in ["../evil", "a/b", "", ".", "..", ".hidden", "a\0b"] {
assert!(
cache.write_layer_pointer(bad, "x.tar.gz").is_err(),
"{bad:?} must be rejected"
);
assert!(cache.layer_entry(bad).is_err(), "{bad:?} must be rejected");
}
for good in ["base", "dev-tools", "ci_2026", "Layer1"] {
assert!(
validate_layer_name(good).is_ok(),
"{good:?} must be accepted"
);
}
}
#[test]
fn a_layer_pointer_resolves_only_while_the_entry_exists() {
let dir = tempfile::tempdir().expect("tempdir");
let cache = RootfsCache::new(dir.path().to_path_buf());
assert!(
cache.layer_entry("nope").expect("reads").is_none(),
"no pointer, no entry"
);
cache
.write_layer_pointer("w", "alpine-3.23.7-x86_64.tar.abc.tar.gz")
.expect("writes");
assert!(
cache.layer_entry("w").expect("reads").is_none(),
"a pointer without its entry resolves to nothing"
);
std::fs::write(
dir.path().join("alpine-3.23.7-x86_64.tar.abc.tar.gz"),
b"tarball",
)
.expect("the entry appears");
std::fs::write(
dir.path()
.join("alpine-3.23.7-x86_64.tar.abc.tar.gz.provisioned"),
b"",
)
.expect("the marker appears");
assert!(
cache.layer_entry("w").expect("reads").is_some(),
"pointer + entry + marker resolve"
);
}
}
+3 -3
View File
@@ -129,9 +129,9 @@ fn extract_multi_layer_oci(
let manifest_path = temp_dir.path().join("layers.manifest");
if !manifest_path.exists() {
// Not our layer-bundle layout: a provisioned rootfs persisted over
// an OCI cache entry (the dispatch above keys on the "oci-" filename
// prefix). The outer archive is a plain rootfs tarball.
// Not our layer-bundle layout: an "oci-" entry whose outer
// archive is a plain rootfs tarball (the dispatch keys on the
// filename prefix, so a non-bundle entry still lands here).
log::debug!("No layers.manifest in bundle; extracting as plain rootfs tarball");
return extract_single_archive(tarball, dest, progress);
}
+2 -4
View File
@@ -13,8 +13,8 @@
//! socket, which is what lets ecrou run inside a strict snap's seccomp
//! allowlist. See SPEC.md.
//!
//! The main entry points are [`rootfs`] (cache-aware rootfs preparation
//! and persistence) and [`vm`] (running a command inside a prepared
//! The main entry points are [`rootfs`] (cache-aware rootfs
//! preparation) and [`vm`] (running a command inside a prepared
//! rootfs). Public functions return [`Result`] carrying the [`Error`]
//! enum, so consumers can match on host-readiness failures
//! ([`Error::KvmUnavailable`], [`Error::ArchNotAvailable`]) versus
@@ -38,11 +38,9 @@ pub mod error;
pub mod extract;
pub mod guest;
pub mod krun;
mod pack;
pub mod progress;
mod registry;
pub mod rootfs;
pub mod spec;
mod supervise;
pub mod utils;
pub mod vm;
-125
View File
@@ -1,125 +0,0 @@
//! Packing a rootfs tree into the compressed tarball a cache entry
//! stores, so a provisioned tree persists and later boots skip the
//! download. The write lands in a unique partial file and renames
//! atomically; compression follows the destination's extension.
use anyhow::{anyhow, Context};
use std::path::Path;
/// Write a tarball of `rootfs` at `dest`, compressed according to dest's
/// extension (.tar.gz, .tar.xz, .tar.zst, plain .tar), atomically via a
/// unique partial file ([`crate::utils::partial_path`]).
pub(crate) fn pack_rootfs(rootfs: &Path, dest: &Path) -> anyhow::Result<()> {
// Packing the tree into a tarball placed inside it would recurse
// forever: the walker keeps seeing the archive grow.
let rootfs_canonical = rootfs
.canonicalize()
.with_context(|| format!("Failed to resolve rootfs directory {}", rootfs.display()))?;
if let Some(parent) = dest.parent() {
if let Ok(parent_canonical) = parent.canonicalize() {
if parent_canonical.starts_with(&rootfs_canonical) {
return Err(anyhow!(
"Cache entry {} must live outside the rootfs",
dest.display()
));
}
}
}
let filename = dest.file_name().and_then(|n| n.to_str()).unwrap_or("");
let partial = crate::utils::partial_path(dest);
let file = std::fs::File::create(&partial)
.with_context(|| format!("Failed to create {}", partial.display()))?;
let result: anyhow::Result<()> = (|| {
if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") {
let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default());
let encoder = write_rootfs_tar(rootfs, encoder)?;
encoder.finish().context("Failed to finalise gzip stream")?;
} else if filename.ends_with(".tar.xz") || filename.ends_with(".txz") {
let encoder = xz2::write::XzEncoder::new(file, 6);
let encoder = write_rootfs_tar(rootfs, encoder)?;
encoder.finish().context("Failed to finalise xz stream")?;
} else if filename.ends_with(".tar.zst") || filename.ends_with(".tar.zstd") {
let encoder = zstd::stream::write::Encoder::new(file, 0)
.context("Failed to create zstd encoder")?;
let encoder = write_rootfs_tar(rootfs, encoder)?;
encoder.finish().context("Failed to finalise zstd stream")?;
} else {
write_rootfs_tar(rootfs, file)?;
}
Ok(())
})();
if result.is_err() {
// Best-effort cleanup; ignore errors (file may not exist if creation failed).
let _ = std::fs::remove_file(&partial);
return result;
}
std::fs::rename(&partial, dest)
.with_context(|| format!("Failed to move packed rootfs to {}", dest.display()))
}
/// Write the rootfs tree into a tar archive streamed to `writer`, returning
/// the writer so the caller can finalise the compression stream.
fn write_rootfs_tar<W: std::io::Write>(rootfs: &Path, writer: W) -> anyhow::Result<W> {
let mut builder = tar::Builder::new(writer);
// Store symlinks as symlinks; following them would duplicate the target's
// contents into the archive (and recurse into symlinked directories).
builder.follow_symlinks(false);
let entries = std::fs::read_dir(rootfs)
.with_context(|| format!("Failed to read {}", rootfs.display()))?;
for entry in entries {
let entry =
entry.with_context(|| format!("Failed to read entry in {}", rootfs.display()))?;
let path = entry.path();
let name = entry.file_name();
// symlink_metadata: never follow symlinks — a top-level symlink to a
// directory must be stored as a link, not recursed into.
let meta = std::fs::symlink_metadata(&path)
.with_context(|| format!("Failed to stat {}", path.display()))?;
if meta.is_dir() {
builder
.append_dir_all(&name, &path)
.with_context(|| format!("Failed to archive {}", path.display()))?;
} else {
builder
.append_path_with_name(&path, &name)
.with_context(|| format!("Failed to archive {}", path.display()))?;
}
}
builder
.into_inner()
.context("Failed to finalise tar archive")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pack_failure_leaves_no_partial_file() {
// A nonexistent rootfs must fail cleanly and clean up its
// partial file, whatever unique name it carried.
let cache = tempfile::tempdir().unwrap();
let dest = cache.path().join("x.tar.gz");
assert!(pack_rootfs(Path::new("/nonexistent-rootfs"), &dest).is_err());
assert!(!dest.exists());
let leftovers: Vec<String> = std::fs::read_dir(cache.path())
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.filter(|name| name.contains("partial"))
.collect();
assert!(
leftovers.is_empty(),
"partial files left behind: {leftovers:?}"
);
}
}
+23 -208
View File
@@ -1,27 +1,17 @@
//! Cache-aware rootfs lifecycle: resolve an image reference, download
//! through the tarball cache, extract into a scratch directory, and
//! optionally persist a provisioned rootfs back into the cache.
//! through the tarball cache, and extract into a scratch directory for
//! [`crate::vm`] to boot.
//!
//! The hot-cell flow is [`RootfsCache::prepare_provisioned`]: provision
//! once (package installs, user setup, …), and every subsequent call —
//! in this or another process sharing the cache — hits the persisted
//! entry and skips both the download and the provisioning step:
//! The run path is [`RootfsCache::prepare`]: a present cache entry is
//! trusted as-is — the registry is never contacted — and the freshness
//! decision belongs to [`RootfsCache::pull`]:
//!
//! ```no_run
//! use ecrou::{NoProgress, PrepareRequest, RootfsCache};
//!
//! # fn main() -> anyhow::Result<()> {
//! let cache = RootfsCache::new(RootfsCache::default_dir()?);
//! let req = PrepareRequest::new("alpine:3.23");
//!
//! let rootfs = cache.prepare_provisioned(&req, &NoProgress, |rootfs| {
//! // Provision by editing the tree host-side. In-rootfs commands
//! // arrive with vm::run, which boots the provisioned rootfs.
//! std::fs::write(rootfs.dir().join("etc/motd"), "provisioned by ecrou\n")?;
//! Ok(())
//! })?;
//!
//! // Amortized runs hit the provisioned entry directly
//! let rootfs = cache.prepare(&PrepareRequest::new("alpine:3.23"), &NoProgress)?;
//! let dir = rootfs.dir().to_path_buf();
//! # let _ = dir; Ok(())
//! # }
@@ -31,11 +21,9 @@ use std::path::{Path, PathBuf};
use anyhow::{anyhow, Context};
use crate::cache::{
extract_prepared, fill_entry, oci_freshness_changed, sidecar, EntryLock, RootfsCache,
PROVISIONED_SUFFIX,
};
use crate::cache::{extract_prepared, oci_freshness_changed, EntryLock, RootfsCache};
use crate::distro::{resolve_source, ImageSource};
use crate::download::download_image;
use crate::error::Error;
use crate::progress::Progress;
use crate::Result;
@@ -63,14 +51,13 @@ impl RootfsCache {
/// pulls by default, because there is nothing to boot otherwise.
pub fn prepare(&self, req: &PrepareRequest, progress: &dyn Progress) -> Result<PreparedRootfs> {
let (source, cache_path, _) = self.ensure_entry(req, progress, false)?;
// A local archive bypasses the cache: the file is the pristine
// source on every run (--no-cache has nothing to say about it),
// and the derived entry name only matters as the place a
// provisioning over it would persist into.
// A local archive bypasses the cache: the file is the source
// on every run (--no-cache has nothing to say about it), and
// the derived entry name only matters for diagnostics.
if let ImageSource::LocalTarball { path } = &source {
return extract_prepared(path, cache_path, progress).map_err(Error::Other);
return extract_prepared(path, progress).map_err(Error::Other);
}
Ok(extract_prepared(&cache_path.clone(), cache_path, progress)?)
Ok(extract_prepared(&cache_path, progress)?)
}
/// Fetch or refresh the image's cache entry without extracting it:
@@ -137,7 +124,7 @@ impl RootfsCache {
format!("Failed to create cache directory {}", self.dir.display())
})?;
if req.no_cache {
fill_entry(&source, &cache_path, &arch, progress)?;
download_image(&source, &cache_path, &arch, progress)?;
downloaded = true;
} else {
// One writer per entry: a concurrent run preparing the
@@ -151,7 +138,7 @@ impl RootfsCache {
let stale = !cache_path.exists()
|| (oci_digest_changed && oci_freshness_changed(&source, &cache_path));
if stale {
fill_entry(&source, &cache_path, &arch, progress)?;
download_image(&source, &cache_path, &arch, progress)?;
downloaded = true;
} else {
log::debug!(
@@ -168,71 +155,7 @@ impl RootfsCache {
}
}
impl RootfsCache {
/// Prepare a provisioned rootfs with cache amortization: when the cache
/// entry is already marked provisioned it is extracted as-is; otherwise
/// the rootfs is prepared from the pristine image, `provision` runs
/// once, and the result is persisted into the cache so subsequent calls
/// (including from other processes sharing this cache) skip both the
/// download and the provisioning step.
///
/// A provisioned entry no longer tracks the upstream image: the
/// pristine base is trusted once cached (`pull` is the refresh
/// operation, and a re-pull does not invalidate provisioning).
/// Re-provision by removing the entry and its `.provisioned`
/// sidecar, or by passing `no_cache: true` (which re-downloads,
/// re-provisions and re-persists).
pub fn prepare_provisioned<F>(
&self,
req: &PrepareRequest,
progress: &dyn Progress,
provision: F,
) -> Result<PreparedRootfs>
where
F: FnOnce(&PreparedRootfs) -> anyhow::Result<()>,
{
let arch = req.resolved_arch();
let source = resolve_source(&req.image, &arch)?;
let cache_path = self.entry_path(&source, &arch, req.layer_key.as_deref())?;
let marker = sidecar(&cache_path, PROVISIONED_SUFFIX);
if !req.no_cache && cache_path.exists() && marker.exists() {
log::debug!("Using provisioned cache entry: {}", cache_path.display());
return Ok(extract_prepared(&cache_path.clone(), cache_path, progress)?);
}
// The miss path prepares the PRISTINE base — a keyed entry
// has no tarball of its own to prepare from, and the base
// must download if missing. The key is this call's business,
// never prepare's.
let mut base_req = req.clone();
base_req.layer_key = None;
let rootfs = self.prepare(&base_req, progress)?;
provision(&rootfs)?;
if req.layer_key.is_some() {
// The prepared scratch is the BASE (a keyed entry has no
// tarball to prepare from): the provisioned tree lands in
// the keyed entry, not over the base.
rootfs.persist_to(&cache_path).with_context(|| {
format!(
"Failed to persist provisioned layer to {}",
cache_path.display()
)
})?;
} else {
rootfs.persist().with_context(|| {
format!(
"Failed to persist provisioned rootfs to {}",
cache_path.display()
)
})?;
}
Ok(rootfs)
}
}
/// Request for [`RootfsCache::prepare`] / [`RootfsCache::pull`] /
/// [`RootfsCache::prepare_provisioned`].
/// Request for [`RootfsCache::prepare`] / [`RootfsCache::pull`].
#[derive(Debug, Clone)]
pub struct PrepareRequest {
/// Image reference: a distro name ("ubuntu", "ubuntu:noble",
@@ -246,12 +169,6 @@ pub struct PrepareRequest {
/// Ignore the cache and download a fresh image.
pub no_cache: bool,
/// A layer key: when set, the cache entry is the keyed layer over
/// the image rather than the image itself — the entry is never
/// downloaded, only provisioned into existence
/// ([`RootfsCache::prepare_provisioned`]).
pub layer_key: Option<String>,
}
impl PrepareRequest {
@@ -260,7 +177,6 @@ impl PrepareRequest {
image: image.into(),
arch: String::new(),
no_cache: false,
layer_key: None,
}
}
@@ -275,12 +191,11 @@ impl PrepareRequest {
}
/// A rootfs extracted into a scratch directory, ready for
/// [`crate::vm::run`] to boot. The directory is removed when this value is
/// dropped, unless [`PreparedRootfs::persist`] ran first.
/// [`crate::vm::run`] to boot. The directory is removed when this
/// value is dropped.
#[derive(Debug)]
pub struct PreparedRootfs {
pub(crate) rootfs: TempDir,
pub(crate) cache_path: PathBuf,
}
impl PreparedRootfs {
@@ -288,39 +203,12 @@ impl PreparedRootfs {
pub fn dir(&self) -> &Path {
self.rootfs.path()
}
/// Persist the current contents of the rootfs into the cache entry it
/// was prepared from, replacing the pristine image tarball, and mark
/// the entry provisioned. Later preparations for the same image and
/// architecture hit this entry directly; note that plain
/// [`RootfsCache::prepare`] also returns the provisioned contents
/// (indistinguishable from a pristine hit) — only
/// [`RootfsCache::prepare_provisioned`] interprets the marker.
pub fn persist(&self) -> Result<()> {
self.persist_to(&self.cache_path)
}
/// Packs the tree into an arbitrary cache entry — how a provisioned
/// layer lands under its own key while the prepared scratch still
/// carries the base's own cache path.
pub fn persist_to(&self, cache_path: &Path) -> Result<()> {
crate::pack::pack_rootfs(self.dir(), cache_path)?;
std::fs::write(sidecar(cache_path, PROVISIONED_SUFFIX), b"").with_context(|| {
format!(
"Failed to write provisioned marker for {}",
cache_path.display()
)
})?;
log::debug!("Persisted rootfs to cache: {}", cache_path.display());
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::progress::NoProgress;
use std::os::unix::fs::PermissionsExt;
#[test]
fn pull_rejects_local_archives() {
@@ -387,83 +275,6 @@ mod tests {
assert_eq!(req.resolved_arch(), "arm64");
}
fn roundtrip(dest_name: &str) {
let src = tempfile::tempdir().unwrap();
std::fs::write(src.path().join("hello.txt"), b"hello").unwrap();
std::fs::create_dir_all(src.path().join("usr/bin")).unwrap();
std::fs::write(src.path().join("usr/bin/tool"), b"#!/bin/sh\n").unwrap();
std::fs::set_permissions(
src.path().join("usr/bin/tool"),
std::fs::Permissions::from_mode(0o755),
)
.unwrap();
std::os::unix::fs::symlink("hello.txt", src.path().join("link.txt")).unwrap();
let cache = tempfile::tempdir().unwrap();
let dest = cache.path().join(dest_name);
crate::pack::pack_rootfs(src.path(), &dest).unwrap();
let out = tempfile::tempdir().unwrap();
crate::extract::extract_tarball(&dest, out.path(), &NoProgress).unwrap();
assert_eq!(
std::fs::read(out.path().join("hello.txt")).unwrap(),
b"hello"
);
let mode = std::fs::metadata(out.path().join("usr/bin/tool"))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o755, "executable bit must survive");
assert_eq!(
std::fs::read_link(out.path().join("link.txt")).unwrap(),
Path::new("hello.txt")
);
}
#[test]
fn persist_roundtrip_gzip() {
roundtrip("rootfs.tar.gz");
}
#[test]
fn persist_roundtrip_xz() {
roundtrip("rootfs.tar.xz");
}
#[test]
fn persist_roundtrip_zstd() {
roundtrip("rootfs.tar.zst");
}
#[test]
fn persist_roundtrip_plain_tar() {
roundtrip("rootfs.tar");
}
#[test]
fn persist_writes_entry_and_provisioned_marker() {
let rootfs_dir = tempfile::tempdir().unwrap();
std::fs::write(rootfs_dir.path().join("file.txt"), b"data").unwrap();
let cache_dir = tempfile::tempdir().unwrap();
let cache_path = cache_dir.path().join("ubuntu-noble-amd64.tar.gz");
let prepared = PreparedRootfs {
rootfs: rootfs_dir,
cache_path: cache_path.clone(),
};
prepared.persist().unwrap();
assert!(cache_path.exists());
assert!(sidecar(&cache_path, PROVISIONED_SUFFIX).exists());
// The persisted entry extracts back to the original contents
let out = tempfile::tempdir().unwrap();
crate::extract::extract_tarball(&cache_path, out.path(), &NoProgress).unwrap();
assert_eq!(std::fs::read(out.path().join("file.txt")).unwrap(), b"data");
}
#[test]
fn a_local_archive_prepares_without_touching_the_cache() {
// A rootfs tree, packed next to itself as ./rootfs.tar.gz, the
@@ -473,7 +284,11 @@ mod tests {
std::fs::create_dir_all(&tree).unwrap();
std::fs::write(tree.join("etc-marker"), b"local").unwrap();
let tarball = work.path().join("rootfs.tar.gz");
crate::pack::pack_rootfs(&tree, &tarball).unwrap();
let file = std::fs::File::create(&tarball).unwrap();
let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default());
let mut tar = tar::Builder::new(encoder);
tar.append_dir_all(".", &tree).unwrap();
tar.into_inner().unwrap().finish().unwrap();
let cache_dir = tempfile::tempdir().unwrap();
let cache = RootfsCache::new(cache_dir.path().to_path_buf());
-147
View File
@@ -1,147 +0,0 @@
//! Provisioning specs: the checked-in description of what a layer
//! carries beyond its base image. A spec is data, not a command —
//! the artifact under review is a diff in git, and the layer it
//! produces is a pure function of the file's content and its base.
use serde::Deserialize;
use std::fmt::Write as _;
/// A parsed provisioning spec file (YAML: base, packages, run).
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct ProvisionSpec {
/// The image to provision over. Optional: without it the caller's
/// positional image is the base.
#[serde(default)]
pub base: Option<String>,
/// Packages installed through whatever package manager the base
/// carries (apt, apk, dnf).
#[serde(default)]
pub packages: Vec<String>,
/// A shell script run after the packages, for everything a
/// package name cannot say.
#[serde(default)]
pub run: Option<String>,
}
impl ProvisionSpec {
/// Parses the spec from raw file bytes. Empty and missing optional
/// fields stay optional; an unparseable file is an error — a typo
/// in the spec must not silently provision a bare base.
pub fn parse(bytes: &[u8]) -> anyhow::Result<Self> {
let spec: ProvisionSpec = serde_yaml::from_slice(bytes)
.map_err(|err| anyhow::anyhow!("the provisioning spec does not parse: {err}"))?;
if spec.base.is_none() && spec.packages.is_empty() && spec.run.is_none() {
anyhow::bail!("the provisioning spec is empty");
}
Ok(spec)
}
/// The layer key: content the cache entries off. Hash of the raw
/// file bytes and the base reference — the bytes, not the parsed
/// form, so the key is stable across ecrou versions; reformatting
/// the file rebuilds the layer, which is harmless and visible.
pub fn key(spec_bytes: &[u8], base_ref: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(spec_bytes);
hasher.update([0]);
hasher.update(base_ref.as_bytes());
let digest = hasher.finalize();
digest[..8].iter().fold(String::new(), |mut out, byte| {
let _ = write!(out, "{byte:02x}");
out
})
}
/// The provisioning script: the packages through the base's
/// package manager, then the raw `run` block. Nothing here knows
/// which distro it lands on ahead of time — the machine itself
/// says, at provision time.
pub fn script(&self) -> String {
let mut script = String::from("set -e\n");
if !self.packages.is_empty() {
let packages = self.packages.join(" ");
let _ = write!(
script,
r#"if command -v apt-get >/dev/null 2>&1; then
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq --no-install-recommends {packages}
elif command -v apk >/dev/null 2>&1; then
apk add --no-cache {packages}
elif command -v dnf >/dev/null 2>&1; then
dnf install -y {packages}
else
echo "no known package manager in the base image" >&2
exit 1
fi
"#
);
}
if let Some(run) = &self.run {
script.push_str(run.trim_end());
script.push('\n');
}
script
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_spec_parses_with_all_parts_optional_but_none_empty() {
let spec = ProvisionSpec::parse(b"packages:\n - jq\n").expect("parses");
assert_eq!(spec.packages, vec!["jq".to_owned()]);
assert!(spec.base.is_none());
let full = ProvisionSpec::parse(
b"base: ghcr.io/example/base:v1\npackages:\n - go\nrun: |\n cargo install just\n",
)
.expect("parses");
assert_eq!(full.base.as_deref(), Some("ghcr.io/example/base:v1"));
assert!(full.run.as_deref().unwrap().contains("cargo install"));
assert!(ProvisionSpec::parse(b"\n").is_err(), "empty is an error");
assert!(
ProvisionSpec::parse(b"packages: [\n").is_err(),
"unparseable yaml is an error"
);
}
#[test]
fn the_key_tracks_the_spec_bytes_and_the_base() {
let a = ProvisionSpec::key(b"packages: [jq]\n", "alpine:3.23");
assert_eq!(a.len(), 16);
assert_eq!(a, ProvisionSpec::key(b"packages: [jq]\n", "alpine:3.23"));
assert_ne!(a, ProvisionSpec::key(b"packages: [jq]\n", "alpine:3.24"));
assert_ne!(a, ProvisionSpec::key(b"packages: [jq] \n", "alpine:3.23"));
}
#[test]
fn the_script_speaks_the_base_package_manager_and_then_runs() {
let spec = ProvisionSpec {
base: None,
packages: vec!["jq".into(), "go".into()],
run: Some("go install m/tool@latest\n".into()),
};
let script = spec.script();
assert!(script.starts_with("set -e"));
assert!(script.contains("apt-get install -y -qq --no-install-recommends jq go"));
assert!(script.contains("apk add --no-cache jq go"));
assert!(script.ends_with("go install m/tool@latest\n"));
// packages only: no pm block would try to install nothing
let bare = ProvisionSpec {
packages: vec![],
run: Some("echo hi".into()),
base: None,
};
let script = bare.script();
assert!(!script.contains("apt-get"));
assert!(script.contains("echo hi"));
}
}
+2 -24
View File
@@ -55,11 +55,6 @@ pub struct RunOptions {
/// How much of the engine's own logging reaches stderr
pub log_level: LogLevel,
pub no_cache: bool,
/// When set, boot the named provisioned layer from the cache
/// instead of resolving an image: no download, no resolution —
/// the layer is whatever `ecrou provision --spec … --layer …`
/// persisted under that name.
pub layer: Option<String>,
/// When set, the payload's stdout and stderr are captured into
/// files in this host directory (`stdout` and `stderr` under it):
/// the run adds the directory as an internal read-write share,
@@ -101,7 +96,6 @@ impl Default for RunOptions {
tty: false,
log_level: LogLevel::Normal,
no_cache: false,
layer: None,
capture: None,
}
}
@@ -109,20 +103,6 @@ impl Default for RunOptions {
/// Run one machine to completion and return the payload's exit code.
pub fn run(options: &RunOptions, progress: &dyn Progress) -> Result<i32> {
// A named layer boots straight from its cache entry: no image
// resolution, no download — the layer is whatever the provision
// call persisted under that name.
if let Some(name) = &options.layer {
let cache = RootfsCache::new(RootfsCache::default_dir()?);
let rootfs = cache.extract_layer(name, progress)?;
// Downstream naming (the hostname, diagnostics) rides the
// layer name: the entry is what booted, not an image.
let options = RunOptions {
image: name.clone(),
..options.clone()
};
return run_prepared(&rootfs, &options);
}
let mut request = PrepareRequest::new(options.image.clone());
request.arch = options.arch.clone();
request.no_cache = options.no_cache;
@@ -133,10 +113,8 @@ pub fn run(options: &RunOptions, progress: &dyn Progress) -> Result<i32> {
run_prepared(&rootfs, options)
}
/// Boot one machine over an already-prepared rootfs. This is the whole
/// boot half of [`run`], and the engine the provisioner drives: it
/// wants a machine over the extracted tree it is about to persist,
/// before that tree becomes the cache entry every later boot sees.
/// Boot one machine over an already-prepared rootfs. This is the
/// boot half of [`run`]: prepare lands the tree, this boots it.
pub fn run_prepared(rootfs: &PreparedRootfs, options: &RunOptions) -> Result<i32> {
if options.image.is_empty() {
return Err(Error::Other(anyhow!("no image given")));