feat(cli): provision layers from a spec file

A provision command baked by hand - `ecrou provision alpine -- apk
add ...` - cannot be reviewed or shared: the artifact worth keeping is
a diff in git, not a shell history line. A spec file is that diff.
`ecrou provision --spec image.yaml` parses base, packages and a run
block, derives the provisioning script from the base's own package
manager (apt, apk, dnf, discovered at provision time on the running
machine), and bakes the result into a cache entry keyed by the spec's
content hash: the same file never provisions twice, an edited file
never reuses the old layer, and the key hashes the raw bytes so the
layer is stable across ecrou versions.

`--layer NAME` names the entry, and `ecrou run --layer NAME` boots it
directly - no image resolution, no download; the layer is whatever
the provision call persisted under that name. A keyed entry has no
tarball of its own, so the base downloads only on the first
provisioning and every later run extracts the finished layer.
This commit is contained in:
vhaudiquet committed 2026-10-29 18:06:50 +01:00
1 parent dad5f61833
commit 878ce29b8d
7 files changed
+455 -31

No files matched your search

+22 -5
View File
@@ -27,9 +27,20 @@ pub enum Command {
/// command installed. Cached entries are reused as-is; --no-cache
/// re-downloads and re-provisions.
Provision {
/// Distribution name or OCI image reference
image: String,
/// Ignore the cache and download a fresh image
/// Distribution name or OCI image reference; the spec's `base`
/// when omitted and the spec carries one
image: Option<String>,
/// A provisioning spec file (YAML: base, packages, run) — the
/// layer it describes is keyed by the file's content, so the
/// same spec never provisions twice and a changed spec never
/// reuses an old layer
#[arg(long)]
spec: Option<std::path::PathBuf>,
/// Name the provisioned layer, so runs can boot it by name
/// (`run --layer NAME`) without re-deriving anything
#[arg(long)]
layer: Option<String>,
/// Ignore the cache: re-download the base and re-provision
#[arg(long)]
no_cache: bool,
/// The provisioning command. An argument vector after `--` is
@@ -44,8 +55,10 @@ pub enum Command {
#[derive(Args, Debug)]
pub struct RunArgs {
/// Distribution name or OCI image reference
pub image: String,
/// Distribution name or OCI image reference; not needed with
/// `--layer`, which boots a provisioned cache entry
#[arg(required_unless_present = "layer")]
pub image: Option<String>,
/// Command to run in the machine; the image's default shell when
/// omitted. An argument vector after `--` is taken literally; a
/// single quoted line runs through the image's shell.
@@ -73,6 +86,10 @@ pub struct RunArgs {
/// Ignore the cache and download a fresh image
#[arg(long)]
pub no_cache: bool,
/// Boot a named provisioned layer (see `provision --layer`)
/// instead of resolving an image
#[arg(long)]
pub layer: Option<String>,
}
/// Parse one `HOST:GUEST[:ro]` share specification. The guest path must
+91 -13
View File
@@ -20,34 +20,43 @@ fn main() -> std::process::ExitCode {
Command::Run(args) => cmd_run(&args, cli.verbose),
Command::Provision {
image,
spec,
layer,
no_cache,
command,
} => cmd_provision(&image, no_cache, &command, cli.verbose),
} => cmd_provision(image, spec, layer, no_cache, &command, cli.verbose),
Command::Probe => cmd_probe(),
}
}
fn cmd_provision(
image: &str,
image: Option<String>,
spec: Option<std::path::PathBuf>,
layer: Option<String>,
no_cache: bool,
command: &[String],
cli_verbose: bool,
) -> std::process::ExitCode {
use ecrou::rootfs::{PrepareRequest, RootfsCache};
use ecrou::spec::ProvisionSpec;
if command.is_empty() {
// 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 a command: ecrou provision <IMAGE> -- <COMMAND...>"
"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 {
image: image.to_owned(),
command: command.to_owned(),
log_level: level,
// The machine exists to change the rootfs, not to cross a
// share: the workspace would ride the persist into the cache.
@@ -56,16 +65,61 @@ fn cmd_provision(
options.workdir = "/".to_owned();
let progress: &dyn Progress = &TermProgress::new();
let request = PrepareRequest {
image: image.to_owned(),
arch: String::new(),
no_cache,
};
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 {
@@ -76,14 +130,37 @@ fn cmd_provision(
Ok(())
});
match outcome {
Ok(_rootfs) => std::process::ExitCode::SUCCESS,
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 {
let mut options = RunOptions {
image: args.image.clone(),
// 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(),
command: args.command.clone(),
memory: args.memory.clone(),
workdir: args.workdir.clone(),
@@ -95,6 +172,7 @@ fn cmd_run(args: &RunArgs, cli_verbose: bool) -> std::process::ExitCode {
LogLevel::Normal
},
no_cache: args.no_cache,
layer: args.layer.clone(),
capture: args.capture.clone(),
..RunOptions::default()
};