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.
This commit is contained in:
Valentin Haudiquet committed 2026-10-27 22:38:49 +01:00
1 parent 348c4dd93c
commit 6a222d474d
5 files changed
+476 -7

No files matched your search

+126
View File
@@ -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<String>,
/// Directory to share into the machine as HOST:GUEST[:ro] (repeatable)
#[arg(long = "share")]
pub shares: Vec<String>,
/// 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<String>,
/// Target architecture; the host architecture when omitted
#[arg(short = 'a', long)]
pub arch: Option<String>,
/// 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<ecrou::Share> {
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());
}
}