mirror of
https://github.com/vhaudiquet/ecrou.git
synced 2026-11-12 22:22:44 +00:00
feat(cli): docker-style run flags and local rootfs references
The run interface read like a library's. `ecrou run -it alpine` now hands the guest a terminal: with no console output file, libkrun wires the guest console to the process's own stdio and raw-modes the tty. The parent saves and restores the termios around the run, because libkrun's own restore lives in the child's exit observers, which a SIGKILLed machine never runs. A tty run refuses to start without a terminal on stdin and stdout, and --capture refuses to combine with -t: the payload's streams cannot land in files and on the terminal at once. -i without -t keeps the machine's stdin open through pipes: libkrun exposes the non-tty streams as virtio console ports (krun-stdin, krun-stdout, krun-stderr), and the guest init points the payload's stdio at whichever port nodes exist. `echo hi | ecrou run -i alpine -- cat` crosses the machine; a stream the host left on a tty has no port node and stays on the console. -v (--volume HOST:GUEST[:ro]) replaces --share, and -w aliases --workdir. The global -v/--verbose loses its short flag to make room; verbose stays reachable by its long form. A reference starting with ./, ../ or / names a local rootfs archive: read in place on every run, never downloaded, never cached as a pristine entry. Provisioning over one persists into a derived cache entry keyed by size and mtime, so the caller's file is never overwritten and an edited archive lands in a fresh entry.
This commit is contained in:
1 parent
719a8209e0
commit
4a0c613ecb
9 files changed
+569
-72
No files matched your search
+79
-21
@@ -7,13 +7,13 @@ use clap::{Args, Parser, Subcommand};
|
||||
name = "ecrou",
|
||||
version,
|
||||
about = "One-shot microVM environments on libkrun",
|
||||
after_help = "Run 'ecrou run IMAGE -- COMMAND' to boot a machine for one command."
|
||||
after_help = "Examples:\n ecrou run -it alpine a shell in the machine\n ecrou run ./rootfs.tar.gz -- uname -a boot a local rootfs archive\n ecrou run -v $PWD:/workspace ubuntu -- make"
|
||||
)]
|
||||
pub struct Cli {
|
||||
#[command(subcommand)]
|
||||
pub command: Command,
|
||||
/// Print diagnostic messages
|
||||
#[arg(short, long, global = true)]
|
||||
#[arg(long, global = true)]
|
||||
pub verbose: bool,
|
||||
}
|
||||
|
||||
@@ -55,23 +55,32 @@ pub enum Command {
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
pub struct RunArgs {
|
||||
/// Distribution name or OCI image reference; not needed with
|
||||
/// `--layer`, which boots a provisioned cache entry
|
||||
/// Distribution name, OCI image reference, or a local rootfs
|
||||
/// archive (a path: ./rootfs.tar.gz, /tmp/rootfs.tar); not needed
|
||||
/// with `--layer`, which boots a provisioned cache entry
|
||||
#[arg(required_unless_present = "layer")]
|
||||
pub image: Option<String>,
|
||||
/// Keep the machine's stdin open: the host's stdin feeds the
|
||||
/// guest, so shells and prompts read typed input
|
||||
#[arg(short = 'i', long)]
|
||||
pub interactive: bool,
|
||||
/// Run the guest console on the terminal (implies --interactive);
|
||||
/// needs a terminal on stdin and stdout
|
||||
#[arg(short = 't', long)]
|
||||
pub tty: bool,
|
||||
/// 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.
|
||||
#[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>,
|
||||
#[arg(short = 'v', long = "volume")]
|
||||
pub volumes: Vec<String>,
|
||||
/// Guest memory size
|
||||
#[arg(short = 'm', long, default_value = "1G")]
|
||||
pub memory: String,
|
||||
/// Guest working directory
|
||||
#[arg(long, default_value = "/")]
|
||||
#[arg(short = 'w', long, default_value = "/")]
|
||||
pub workdir: String,
|
||||
/// Guest environment KEY=VALUE (repeatable)
|
||||
#[arg(short = 'e', long = "env")]
|
||||
@@ -92,9 +101,9 @@ pub struct RunArgs {
|
||||
pub layer: Option<String>,
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
/// Parse one `HOST:GUEST[:ro]` volume specification. The guest path
|
||||
/// must be absolute; the host side is checked for existence at run time.
|
||||
pub fn parse_volume(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),
|
||||
@@ -102,10 +111,10 @@ pub fn parse_share(spec: &str) -> anyhow::Result<ecrou::Share> {
|
||||
[_, _, other] => {
|
||||
anyhow::bail!("share suffix must be 'ro', got ':{}' in {:?}", other, spec)
|
||||
}
|
||||
_ => anyhow::bail!("share must be HOST:GUEST[:ro], got {:?}", spec),
|
||||
_ => anyhow::bail!("volume must be HOST:GUEST[:ro], got {:?}", spec),
|
||||
};
|
||||
if host.is_empty() || guest.is_empty() {
|
||||
anyhow::bail!("share must be HOST:GUEST[:ro], got {:?}", spec);
|
||||
anyhow::bail!("volume must be HOST:GUEST[:ro], got {:?}", spec);
|
||||
}
|
||||
if !guest.starts_with('/') {
|
||||
anyhow::bail!("share guest path must be absolute, got {:?}", guest);
|
||||
@@ -133,23 +142,72 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shares_parse_rw_and_ro() {
|
||||
let s = parse_share("/home/me/work:/workspace").unwrap();
|
||||
fn volumes_parse_rw_and_ro() {
|
||||
let s = parse_volume("/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();
|
||||
let s = parse_volume("/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());
|
||||
fn volumes_reject_malformed_specs() {
|
||||
assert!(parse_volume("/only-host").is_err());
|
||||
assert!(parse_volume(":/guest").is_err());
|
||||
assert!(parse_volume("/host:relative").is_err());
|
||||
assert!(parse_volume("/h:/g:rw").is_err());
|
||||
assert!(parse_volume("/h:/g:ro:extra").is_err());
|
||||
let err = format!("{:#}", parse_volume("/only-host").unwrap_err());
|
||||
assert!(err.contains("volume"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_combines_into_interactive_and_tty() {
|
||||
let cli =
|
||||
Cli::try_parse_from(["ecrou", "run", "-it", "alpine", "--", "sh"]).expect("-it parses");
|
||||
let Command::Run(args) = cli.command else {
|
||||
panic!("run expected");
|
||||
};
|
||||
assert!(args.interactive);
|
||||
assert!(args.tty);
|
||||
assert_eq!(args.image.as_deref(), Some("alpine"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v_is_the_volume_short_at_the_run_level() {
|
||||
let cli = Cli::try_parse_from([
|
||||
"ecrou",
|
||||
"run",
|
||||
"-v",
|
||||
"/work:/work:ro",
|
||||
"-v",
|
||||
"/data:/data",
|
||||
"alpine",
|
||||
])
|
||||
.expect("-v parses");
|
||||
let Command::Run(args) = cli.command else {
|
||||
panic!("run expected");
|
||||
};
|
||||
assert_eq!(args.volumes.len(), 2);
|
||||
assert!(parse_volume(&args.volumes[0]).unwrap().read_only);
|
||||
// Verbose stays reachable by its long form.
|
||||
let cli = Cli::try_parse_from(["ecrou", "--verbose", "probe"]).expect("--verbose parses");
|
||||
assert!(cli.verbose);
|
||||
assert!(
|
||||
Cli::try_parse_from(["ecrou", "-v", "probe"]).is_err(),
|
||||
"-v belongs to run --volume, not the global verbose"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn w_aliases_workdir() {
|
||||
let cli = Cli::try_parse_from(["ecrou", "run", "-w", "/src", "alpine"]).expect("-w parses");
|
||||
let Command::Run(args) = cli.command else {
|
||||
panic!("run expected");
|
||||
};
|
||||
assert_eq!(args.workdir, "/src");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in new issue
Block a user