From 6a222d474dc11aa7fe0a07d29afb6f84f4e1ba2e Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Tue, 27 Oct 2026 22:38:49 +0100 Subject: [PATCH] feat(cli): run and probe commands run maps the flags onto RunOptions, promotes the first read-write share to the workspace, inherits stdio, and exits with the guest's code; probe reports what a run would find (KVM, libkrun loadability with the remediation, nested virt, cache writability) and fails when a run would. T6, the no-mount audit, lives here where the binary is built, ignored behind ECROU_BIN for CI. --- crates/ecrou-cli/src/cli.rs | 126 +++++++++++++++++++++ crates/ecrou-cli/src/logger.rs | 47 ++++++++ crates/ecrou-cli/src/main.rs | 185 ++++++++++++++++++++++++++++++- crates/ecrou-cli/src/progress.rs | 91 +++++++++++++++ crates/ecrou/src/guest.rs | 34 +++++- 5 files changed, 476 insertions(+), 7 deletions(-) create mode 100644 crates/ecrou-cli/src/cli.rs create mode 100644 crates/ecrou-cli/src/logger.rs create mode 100644 crates/ecrou-cli/src/progress.rs diff --git a/crates/ecrou-cli/src/cli.rs b/crates/ecrou-cli/src/cli.rs new file mode 100644 index 0000000..33a1024 --- /dev/null +++ b/crates/ecrou-cli/src/cli.rs @@ -0,0 +1,126 @@ +//! Flag definitions and the small parsing helpers they need. + +use clap::{Args, Parser, Subcommand}; + +#[derive(Parser, Debug)] +#[command( + name = "ecrou", + version, + about = "One-shot microVM environments on libkrun", + after_help = "Run 'ecrou run IMAGE -- COMMAND' to boot a machine for one command." +)] +pub struct Cli { + #[command(subcommand)] + pub command: Command, + /// Print diagnostic messages + #[arg(short, long, global = true)] + pub verbose: bool, +} + +#[derive(Subcommand, Debug)] +pub enum Command { + /// Run a command in a fresh microVM from an image + Run(RunArgs), + /// Report what a run would find on this host + Probe, +} + +#[derive(Args, Debug)] +pub struct RunArgs { + /// Distribution name or OCI image reference + pub image: String, + /// Command to run in the machine; the image's default shell when omitted + #[arg(last = true)] + pub command: Vec, + /// Directory to share into the machine as HOST:GUEST[:ro] (repeatable) + #[arg(long = "share")] + pub shares: Vec, + /// Guest memory size + #[arg(short = 'm', long, default_value = "1G")] + pub memory: String, + /// Guest working directory + #[arg(long, default_value = "/")] + pub workdir: String, + /// Guest environment KEY=VALUE (repeatable) + #[arg(short = 'e', long = "env")] + pub env: Vec, + /// Target architecture; the host architecture when omitted + #[arg(short = 'a', long)] + pub arch: Option, + /// Suppress console noise + #[arg(long)] + pub quiet: bool, + /// Ignore the cache and download a fresh image + #[arg(long)] + pub no_cache: bool, +} + +/// Parse one `HOST:GUEST[:ro]` share specification. The guest path must +/// be absolute; the host side is checked for existence at run time. +pub fn parse_share(spec: &str) -> anyhow::Result { + let parts: Vec<&str> = spec.split(':').collect(); + let (host, guest, ro) = match parts.as_slice() { + [h, g] => (*h, *g, false), + [h, g, "ro"] => (*h, *g, true), + [_, _, other] => { + anyhow::bail!("share suffix must be 'ro', got ':{}' in {:?}", other, spec) + } + _ => anyhow::bail!("share must be HOST:GUEST[:ro], got {:?}", spec), + }; + if host.is_empty() || guest.is_empty() { + anyhow::bail!("share must be HOST:GUEST[:ro], got {:?}", spec); + } + if !guest.starts_with('/') { + anyhow::bail!("share guest path must be absolute, got {:?}", guest); + } + Ok(ecrou::Share { + host: host.into(), + guest: guest.to_string(), + read_only: ro, + }) +} + +/// Parse one `KEY=VALUE` environment pair. +pub fn parse_env(spec: &str) -> anyhow::Result<(String, String)> { + let (k, v) = spec + .split_once('=') + .ok_or_else(|| anyhow::anyhow!("environment must be KEY=VALUE, got {:?}", spec))?; + if k.is_empty() { + anyhow::bail!("environment key is empty: {:?}", spec); + } + Ok((k.to_string(), v.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shares_parse_rw_and_ro() { + let s = parse_share("/home/me/work:/workspace").unwrap(); + assert_eq!(s.host, std::path::Path::new("/home/me/work")); + assert_eq!(s.guest, "/workspace"); + assert!(!s.read_only); + + let s = parse_share("/skills:/skills:ro").unwrap(); + assert!(s.read_only); + } + + #[test] + fn shares_reject_malformed_specs() { + assert!(parse_share("/only-host").is_err()); + assert!(parse_share(":/guest").is_err()); + assert!(parse_share("/host:relative").is_err()); + assert!(parse_share("/h:/g:rw").is_err()); + assert!(parse_share("/h:/g:ro:extra").is_err()); + } + + #[test] + fn env_pairs_split_on_the_first_equals() { + let (k, v) = parse_env("KEY=value=with=equals").unwrap(); + assert_eq!(k, "KEY"); + assert_eq!(v, "value=with=equals"); + assert!(parse_env("NOEQUALS").is_err()); + assert!(parse_env("=novalue").is_err()); + } +} diff --git a/crates/ecrou-cli/src/logger.rs b/crates/ecrou-cli/src/logger.rs new file mode 100644 index 0000000..e80964c --- /dev/null +++ b/crates/ecrou-cli/src/logger.rs @@ -0,0 +1,47 @@ +//! stderr logging sink: `Warn`/`Error` records always, `Debug` +//! diagnostics only with -v. + +use log::{Level, LevelFilter, Log, Metadata, Record}; + +struct CliLogger { + max: LevelFilter, +} + +impl Log for CliLogger { + fn enabled(&self, metadata: &Metadata) -> bool { + if metadata.level() > self.max { + return false; + } + match metadata.level() { + Level::Warn | Level::Error => true, + // Dependencies (hyper, rustls, …) share the log facade and are + // far noisier than we are; -v follows only our own diagnostics. + _ => metadata.target().starts_with("ecrou"), + } + } + + fn log(&self, record: &Record) { + if !self.enabled(record.metadata()) { + return; + } + match record.level() { + Level::Error => eprintln!("Error: {}", record.args()), + Level::Warn => eprintln!("Warning: {}", record.args()), + _ => eprintln!("{}", record.args()), + } + } + + fn flush(&self) {} +} + +/// Install the CLI logger: warnings and errors always, `ecrou` debug +/// diagnostics only when `verbose` (-v). +pub fn init(verbose: bool) { + let max = if verbose { + LevelFilter::Debug + } else { + LevelFilter::Warn + }; + log::set_boxed_logger(Box::new(CliLogger { max })).ok(); + log::set_max_level(max); +} diff --git a/crates/ecrou-cli/src/main.rs b/crates/ecrou-cli/src/main.rs index f328e4d..ec9b838 100644 --- a/crates/ecrou-cli/src/main.rs +++ b/crates/ecrou-cli/src/main.rs @@ -1 +1,184 @@ -fn main() {} +//! 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::{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), + Command::Probe => cmd_probe(), + } +} + +fn cmd_run(args: &RunArgs) -> std::process::ExitCode { + let mut options = RunOptions { + image: args.image.clone(), + memory: args.memory.clone(), + workdir: args.workdir.clone(), + quiet: args.quiet, + no_cache: args.no_cache, + ..RunOptions::default() + }; + if let Some(arch) = &args.arch { + options.arch = arch.clone(); + } + 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}" + ); + } + } +} diff --git a/crates/ecrou-cli/src/progress.rs b/crates/ecrou-cli/src/progress.rs new file mode 100644 index 0000000..b448d0a --- /dev/null +++ b/crates/ecrou-cli/src/progress.rs @@ -0,0 +1,91 @@ +//! Terminal rendering of library progress events (indicatif-backed). +//! +//! This is CLI-owned UI: the library only emits [`Progress`] events, +//! and everything drawn on the terminal lives here. indicatif hides +//! all output when stderr is not a terminal. + +use ecrou::progress::{Progress, Unit}; +use indicatif::{ProgressBar, ProgressStyle}; +use std::sync::Mutex; + +/// Renders sequential library operations as bars and spinners on stderr. +pub struct TermProgress { + bar: Mutex>, +} + +impl TermProgress { + pub fn new() -> Self { + TermProgress { + bar: Mutex::new(None), + } + } +} + +impl Default for TermProgress { + fn default() -> Self { + Self::new() + } +} + +impl Drop for TermProgress { + fn drop(&mut self) { + self.finish(); + } +} + +impl Progress for TermProgress { + fn begin(&self, label: &str, unit: Unit, total: Option) { + let mut slot = self.bar.lock().unwrap(); + // The library's `begin` implicitly ends the previous operation. + if let Some(prev) = slot.take() { + prev.finish_and_clear(); + } + + let bar = match (unit, total) { + (Unit::Bytes, Some(total)) => { + let pb = ProgressBar::new(total); + pb.set_style( + ProgressStyle::default_bar() + .template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {msg} {bytes}/{total_bytes} ({eta})") + .unwrap() + .progress_chars("#>-"), + ); + pb.set_message(label.to_string()); + pb + } + (Unit::Bytes, None) => { + let pb = ProgressBar::new_spinner(); + pb.set_style( + ProgressStyle::default_spinner() + .template("{spinner:.green} {msg} ({bytes})") + .unwrap(), + ); + pb.set_message(label.to_string()); + pb + } + (Unit::Files, _) => { + let pb = ProgressBar::new_spinner(); + pb.set_style( + ProgressStyle::default_spinner() + .template("{spinner:.green} {msg} ({pos} files)") + .unwrap(), + ); + pb.set_message(label.to_string()); + pb + } + }; + *slot = Some(bar); + } + + fn advance(&self, n: u64) { + if let Some(bar) = &*self.bar.lock().unwrap() { + bar.inc(n); + } + } + + fn finish(&self) { + if let Some(bar) = self.bar.lock().unwrap().take() { + bar.finish_and_clear(); + } + } +} diff --git a/crates/ecrou/src/guest.rs b/crates/ecrou/src/guest.rs index a40945a..962bcb9 100644 --- a/crates/ecrou/src/guest.rs +++ b/crates/ecrou/src/guest.rs @@ -27,7 +27,12 @@ pub fn sh_quote(s: &str) -> String { /// Render the guest init. The payload runs as a child (never `exec`): /// the init must outlive it to write the exit code and power the /// machine off. -pub fn init_script(shares: &[GuestShare], payload: &[String], workdir: &str) -> String { +pub fn init_script( + shares: &[GuestShare], + payload: &[String], + workdir: &str, + hostname: &str, +) -> String { let mut script = String::from( "#!/bin/sh\n\ # ecrou guest init: mount the shares, run the payload, report its\n\ @@ -54,8 +59,18 @@ pub fn init_script(shares: &[GuestShare], payload: &[String], workdir: &str) -> )); } + // Best effort: some rootfs trees ship their own /etc/hostname. script.push_str(&format!( - "\ncd {} || exit 126\n\n", + "echo {} > /proc/sys/kernel/hostname 2>/dev/null || true\n", + sh_quote(hostname) + )); + script.push_str(&format!( + "echo {} > /etc/hostname 2>/dev/null || true\n\n", + sh_quote(hostname) + )); + + script.push_str(&format!( + "cd {} || exit 126\n\n", sh_quote(if workdir.is_empty() { "/" } else { workdir }) )); @@ -98,7 +113,7 @@ mod tests { read_only: true, }, ]; - let script = init_script(&shares, &["true".to_string()], "/"); + let script = init_script(&shares, &["true".to_string()], "/", "ecrou-vm-x"); assert!(script.contains("mount_share 'ws0' '/workspace' 'rw'")); assert!(script.contains("mount_share 'sk1' '/mnt/skills' 'ro'")); } @@ -110,7 +125,7 @@ mod tests { "-c".to_string(), "echo $HOME; rm -rf '/'".to_string(), ]; - let script = init_script(&[], &payload, "/"); + let script = init_script(&[], &payload, "/", "ecrou-vm-x"); assert!(script.contains(r"'/bin/bash' '-c' 'echo $HOME; rm -rf '\''/'\''' ")); // The payload line must appear after the cd and before the code // capture, so its exit code is what gets written. @@ -122,7 +137,7 @@ mod tests { #[test] fn writes_exit_code_and_powers_off() { - let script = init_script(&[], &["true".to_string()], "/work"); + let script = init_script(&[], &["true".to_string()], "/work", "ecrou-vm-x"); assert!(script.contains(&format!("echo \"$code\" > '{}'", EXIT_FILE))); assert!(script.contains("echo o > /proc/sysrq-trigger")); assert!(script.contains("exit \"$code\"")); @@ -130,9 +145,16 @@ mod tests { assert!(!script.contains("exec ")); } + #[test] + fn hostname_lands_in_proc_and_etc() { + let script = init_script(&[], &["true".to_string()], "/", "ecrou-vm-x"); + assert!(script.contains("echo 'ecrou-vm-x' > /proc/sys/kernel/hostname")); + assert!(script.contains("echo 'ecrou-vm-x' > /etc/hostname")); + } + #[test] fn empty_workdir_falls_back_to_root() { - let script = init_script(&[], &["true".to_string()], ""); + let script = init_script(&[], &["true".to_string()], "", "ecrou-vm-x"); assert!(script.contains("cd '/'")); } }