mirror of
https://github.com/vhaudiquet/ecrou.git
synced 2026-11-12 22:22:44 +00:00
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:
1 parent
e632fa2125
commit
0ad07c182f
10 files changed
+56
-946
No files matched your search
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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());
|
||||
|
||||
@@ -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
@@ -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")));
|
||||
|
||||
Reference in new issue
Block a user