feat(cli): ecrou provision bakes a command into a cache entry

prepare_provisioned and persist were sitting there with no consumer:
the machinery for "install once, every later boot carries it" that
one-shot machines cannot do on their own - packages, users, anything
short of a share - and no way to reach it.

ecrou provision IMAGE -- COMMAND is the missing surface. It boots a
machine over a freshly prepared rootfs, runs the command, and
persists the tree into the cache entry, marked provisioned; a marked
entry is extracted as-is on every later call, the command skipped.
A failing command leaves the entry unprovisioned for the next
attempt, and --no-cache re-downloads before re-provisioning.

The engine behind run splits out as vm::run_prepared: the boot half
of run, taking the rootfs the provisioner is about to persist. run
itself is prepare plus that.

One cache fix the provision flow surfaced: a digest refresh replaced
the tarball but left the old .provisioned marker, so a fresh pristine
image would present as provisioned and skip provisioning for good.
The marker now dies with the entry it belonged to.
This commit is contained in:
vhaudiquet committed 2026-10-28 16:20:37 +01:00
1 parent 1fb3f0c076
commit d9f9c79dc4
5 files changed
+117 -9

No files matched your search

+17
View File
@@ -21,6 +21,23 @@ pub struct Cli {
pub enum Command {
/// Run a command in a fresh microVM from an image
Run(RunArgs),
/// Bake a command into an image's cache entry: the command runs in
/// a machine over a freshly prepared rootfs, and the result is
/// persisted, so every later boot of the image carries what the
/// 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
#[arg(long)]
no_cache: bool,
/// The provisioning command. An argument vector after `--` is
/// taken literally; a single quoted line runs through the
/// image's shell.
#[arg(last = true)]
command: Vec<String>,
},
/// Report what a run would find on this host
Probe,
}
+59
View File
@@ -18,10 +18,69 @@ fn main() -> std::process::ExitCode {
logger::init(cli.verbose);
match cli.command {
Command::Run(args) => cmd_run(&args, cli.verbose),
Command::Provision {
image,
no_cache,
command,
} => cmd_provision(&image, no_cache, &command, cli.verbose),
Command::Probe => cmd_probe(),
}
}
fn cmd_provision(
image: &str,
no_cache: bool,
command: &[String],
cli_verbose: bool,
) -> std::process::ExitCode {
use ecrou::rootfs::{PrepareRequest, RootfsCache};
if command.is_empty() {
return fail(&anyhow::anyhow!(
"provision needs 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.
..RunOptions::default()
};
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);
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) => 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(),