Files
ecrou/crates/ecrou-cli/src/main.rs
T
vhaudiquet d9f9c79dc4 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.
2026-10-28 16:20:37 +01:00

249 lines
7.6 KiB
Rust

//! The ecrou CLI: flags onto the library API, exit codes out. All
//! terminal output lives here; the library stays silent behind `log`
//! and the progress sink.
mod cli;
mod logger;
mod progress;
use clap::Parser;
use ecrou::vm::RunOptions;
use ecrou::{LogLevel, NoProgress, Progress};
use cli::{parse_env, parse_share, Cli, Command, RunArgs};
use progress::TermProgress;
fn main() -> std::process::ExitCode {
let cli = Cli::parse();
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(),
command: args.command.clone(),
memory: args.memory.clone(),
workdir: args.workdir.clone(),
log_level: if args.quiet {
LogLevel::Quiet
} else if cli_verbose {
LogLevel::Verbose
} else {
LogLevel::Normal
},
no_cache: args.no_cache,
capture: args.capture.clone(),
..RunOptions::default()
};
for spec in &args.env {
match parse_env(spec) {
Ok((k, v)) => options.env.push((k, v)),
Err(err) => return fail(&err),
}
}
let mut shares = Vec::new();
for spec in &args.shares {
match parse_share(spec) {
Ok(share) => shares.push(share),
Err(err) => return fail(&err),
}
}
// The first read-write share is the workspace (its guest path rides
// into the guest as ECROU_WORKSPACE); everything else is a plain
// share.
if let Some(pos) = shares.iter().position(|s| !s.read_only) {
options.workspace = Some(shares.remove(pos));
}
options.shares = shares;
let progress: &dyn Progress = if args.quiet {
&NoProgress
} else {
&TermProgress::new()
};
match ecrou::vm::run(&options, progress) {
Ok(code) => std::process::ExitCode::from(code.clamp(0, 255) as u8),
Err(err) => fail(&anyhow::Error::new(err)),
}
}
fn cmd_probe() -> std::process::ExitCode {
use ecrou::krun;
let mut ok = true;
print!("kvm: ");
match krun::kvm_probe() {
Ok(()) => println!("ok"),
Err(err) => {
ok = false;
println!("unavailable\n {:#}", err);
}
}
print!("libkrun: ");
match krun::Krun::load() {
Ok(krun) => {
println!("ok");
print!("nested virt: ");
match krun.check_nested_virt() {
Ok(true) => println!("available"),
Ok(false) => println!("not available (only matters for nested runs)"),
Err(err) => println!("unknown ({err:#})"),
}
}
Err(err) => {
ok = false;
println!("not loadable\n {:#}", err);
}
}
print!("cache: ");
match ecrou::rootfs::RootfsCache::default_dir() {
Ok(dir) => {
// The run flow creates the cache on demand; probe mirrors it.
if let Err(err) = std::fs::create_dir_all(&dir) {
ok = false;
println!("{} not creatable ({})", dir.display(), err);
return probe_done(ok);
}
let probe = dir.join(".ecrou-probe");
match std::fs::write(&probe, b"") {
Ok(()) => {
let _ = std::fs::remove_file(&probe);
println!("{} (writable)", dir.display());
}
Err(err) => {
ok = false;
println!("{} not writable ({})", dir.display(), err);
}
}
}
Err(err) => {
ok = false;
println!("unavailable ({err:#})");
}
}
probe_done(ok)
}
fn probe_done(ok: bool) -> std::process::ExitCode {
if ok {
std::process::ExitCode::SUCCESS
} else {
std::process::ExitCode::FAILURE
}
}
fn fail(err: &anyhow::Error) -> std::process::ExitCode {
eprintln!("Error: {err:#}");
std::process::ExitCode::FAILURE
}
/// T6, the founding rule as a test: strace a full run and fail on any
/// denied syscall. Needs the built binary, libkrun, and strace, so it
/// runs in CI (`cargo test -- --ignored`).
#[cfg(test)]
mod tests {
#[test]
#[ignore = "needs the ecrou binary, libkrun, and strace; runs in CI"]
fn t6_no_mount_or_namespace_syscalls() {
let denied = "mount,umount2,unshare,setns,pivot_root,open_tree,move_mount,fsopen,fsmount";
// CARGO_BIN_EXE_* exists only for integration tests, which the
// conventions forbid; CI points ECROU_BIN at the built binary.
let exe = std::env::var("ECROU_BIN")
.expect("T6 needs ECROU_BIN pointing at the built ecrou binary");
let trace_path = std::env::temp_dir().join("ecrou-t6.strace");
let out = std::process::Command::new("strace")
.args([
"-f",
"-e",
&format!("trace={denied}"),
"-o",
trace_path.to_str().unwrap(),
&exe,
"run",
"alpine:3.23",
"--",
"true",
])
.status()
.expect("strace run");
assert!(out.success(), "the traced run failed; see the strace log");
let trace = std::fs::read_to_string(&trace_path).unwrap();
let _ = std::fs::remove_file(&trace_path);
for line in trace.lines() {
let denied_call = denied.split(',').any(|syscall| line.contains(syscall));
let failed = line.contains("= -1 EPERM") || line.contains("= -1 ENOSYS");
assert!(
!(denied_call && failed),
"denied syscall in the trace: {line}"
);
}
}
}