diff --git a/SPEC.md b/SPEC.md index 4278e3f..03a0901 100644 --- a/SPEC.md +++ b/SPEC.md @@ -132,8 +132,9 @@ of the image (a `PreparedRootfs`, TempDir-backed): it exists only for the run and dies with it. Persistence crosses a workspace share or it does not happen. -**Workspace shares.** `--share HOST:GUEST[:ro]`, one virtiofs share per -entry (`krun_add_virtiofs`, `krun_set_root` keeps the rootfs itself). +**Workspace shares.** `-v HOST:GUEST[:ro]` (`--volume`), one virtiofs +share per entry (`krun_add_virtiofs`, `krun_set_root` keeps the rootfs +itself). The host side is plain fs; the guest init mounts each share at its guest path. There is no implicit share: an empty sandbox is the default, and the consumer chooses what crosses. This is deliberate; a @@ -174,15 +175,36 @@ machine with it. No orphan survives. This is stronger than `docker exec`'s cancel, which kills only the local client; the tests keep it true (T4). -**Console.** Guest output reaches real stdout through a console file -the parent streams while the machine runs, with a final drain after -the reap. The direct stdio wiring does not survive contact with -reality: with a tty on fd 0 libkrun's terminal setup stops a +**Console.** Three wirings, chosen by the run's flags. + +Headless (the default): guest output reaches real stdout through a +console file the parent streams while the machine runs, with a final +drain after the reap. The direct stdio wiring does not survive contact +with reality: with a tty on fd 0 libkrun's terminal setup stops a background caller with SIGTTOU, and without one its console bytes go to the log sink, not the terminal. The child therefore unwires fd 0 to -/dev/null (v1 carries no guest input; a Ctrl-C to the process group is -still forwarded into the guest). There is no QEMU monitor to mux; -`--verbose` maps to `krun_set_log_level`. +/dev/null (no guest input; a Ctrl-C to the process group is still +forwarded into the guest). + +`-t`: the machine owns the terminal. With no console output file set, +libkrun wires the guest console to the process's own stdin and stdout, +puts the tty in raw mode, and restores it through its exit observers. +The parent additionally saves and restores the termios around the run: +the observers do not run in a SIGKILLed machine. Input is the typed +keyboard; Ctrl-C reaches the guest as a byte (raw mode has no ISIG) +and the guest's line discipline signals its own foreground job. The +run refuses to start without a terminal on stdin and stdout, and +`--capture` is refused with `-t` (the payload's streams cannot land in +files and on the terminal at once). + +`-i` without `-t`: pipes. libkrun still owns the process stdio, and +its non-tty streams surface in the guest as virtio console ports +(`krun-stdin`, `krun-stdout`, `krun-stderr` under `/dev/virtio-ports`); +the guest init re-points the payload's stdio onto whichever port nodes +exist, so `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. There is no QEMU monitor to mux; `--verbose` maps to +`krun_set_log_level`. **Networking.** TSI, libkrun's default: the guest has no interface, and its socket calls are proxied on the host's stack by the VMM process. @@ -232,7 +254,8 @@ absent kernel entries only. ```rust pub struct Share { pub host: PathBuf, pub guest: String, pub read_only: bool } pub struct RunOptions { - pub image: String, // distro ref or OCI image ref + pub image: String, // distro ref, OCI image ref, or local + // rootfs archive path (./x.tar.gz) pub arch: String, // empty = host arch pub memory: String, // default "1G" pub workspace: Option, // the one rw share consumers name @@ -241,6 +264,8 @@ pub struct RunOptions { pub workdir: String, // guest cwd; empty = / pub env: Vec<(String, String)>, // composed envp; host env never inherited pub hostname: String, // empty = generated ecrou-vm-XXXXXX + pub interactive: bool, // host stdin feeds the guest + pub tty: bool, // console on the terminal (implies interactive) pub log_level: LogLevel, // Quiet | Normal | Verbose pub capture: Option, // payload stdout/stderr into files } @@ -273,16 +298,24 @@ ecrou provision [OPTIONS] [-- COMMAND...] ecrou probe ``` +The image is a distro name (`alpine`, `ubuntu:noble`), an OCI +reference (`ghcr.io/org/image:tag`), or a local rootfs archive named +with a path (`./rootfs.tar.gz`, `/tmp/rootfs.tar`): a path reference is +read in place on every run, never downloaded and never cached as a +pristine entry. + | Flag | Default | Description | |------|---------|-------------| -| `--share ` | none | Workspace share; repeatable | +| `-i, --interactive` | false | Keep the machine's stdin open: the host's stdin feeds the guest | +| `-t, --tty` | false | Console on the terminal: raw mode, the guest sees a tty (implies `-i`; needs a terminal on stdin and stdout) | +| `-v, --volume ` | none | Share a directory into the machine; the first rw one is the workspace; repeatable | | `-m, --memory ` | 1G | Guest memory | -| `--workdir ` | `/` | Guest cwd | +| `-w, --workdir ` | `/` | Guest cwd | | `-e, --env ` | none | Guest env; repeatable | | `--quiet` | false | Suppress kernel log on the console | -| `--capture ` | none | Payload stdout/stderr into `/stdout` and `/stderr` | -| `--no-cache` | false | Fresh download, ignore the cache | -| `-v, --verbose` | false | Diagnostics; raises the libkrun log level | +| `--capture ` | none | Payload stdout/stderr into `/stdout` and `/stderr`; incompatible with `-t` | +| `--no-cache` | false | Fresh download, ignore the cache (a local archive is always read fresh) | +| `--verbose` | false | Diagnostics; raises the libkrun log level | `ecrou provision IMAGE -- COMMAND` bakes a command into the image's cache entry: the command runs in a machine over a freshly prepared diff --git a/crates/ecrou-cli/src/cli.rs b/crates/ecrou-cli/src/cli.rs index 571003d..77aeb78 100644 --- a/crates/ecrou-cli/src/cli.rs +++ b/crates/ecrou-cli/src/cli.rs @@ -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, + /// 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, /// Directory to share into the machine as HOST:GUEST[:ro] (repeatable) - #[arg(long = "share")] - pub shares: Vec, + #[arg(short = 'v', long = "volume")] + pub volumes: Vec, /// 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, } -/// 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 { +/// 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 { 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 { [_, _, 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] diff --git a/crates/ecrou-cli/src/main.rs b/crates/ecrou-cli/src/main.rs index 268d070..4338b73 100644 --- a/crates/ecrou-cli/src/main.rs +++ b/crates/ecrou-cli/src/main.rs @@ -10,7 +10,7 @@ use clap::Parser; use ecrou::vm::RunOptions; use ecrou::{LogLevel, NoProgress, Progress}; -use cli::{parse_env, parse_share, Cli, Command, RunArgs}; +use cli::{parse_env, parse_volume, Cli, Command, RunArgs}; use progress::TermProgress; fn main() -> std::process::ExitCode { @@ -157,6 +157,15 @@ fn cmd_provision( } fn cmd_run(args: &RunArgs, cli_verbose: bool) -> std::process::ExitCode { + // A tty drives the payload's output through the terminal while the + // capture share points the same streams at files: one of them has + // to lose, so the run refuses instead of picking. + if args.tty && args.capture.is_some() { + return fail(&anyhow::anyhow!( + "--capture and --tty cannot be combined: the payload's output \ + cannot land in the capture files and on the terminal" + )); + } let mut options = RunOptions { // Without a layer the image is required by clap; with one the // field is dead — the layer entry carries the rootfs. @@ -171,6 +180,8 @@ fn cmd_run(args: &RunArgs, cli_verbose: bool) -> std::process::ExitCode { } else { LogLevel::Normal }, + interactive: args.interactive, + tty: args.tty, no_cache: args.no_cache, layer: args.layer.clone(), capture: args.capture.clone(), @@ -183,8 +194,8 @@ fn cmd_run(args: &RunArgs, cli_verbose: bool) -> std::process::ExitCode { } } let mut shares = Vec::new(); - for spec in &args.shares { - match parse_share(spec) { + for spec in &args.volumes { + match parse_volume(spec) { Ok(share) => shares.push(share), Err(err) => return fail(&err), } diff --git a/crates/ecrou/src/distro.rs b/crates/ecrou/src/distro.rs index 4678ed4..addfba7 100644 --- a/crates/ecrou/src/distro.rs +++ b/crates/ecrou/src/distro.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use anyhow::{anyhow, Context}; use crate::Result; @@ -24,6 +26,10 @@ pub enum ImageSource { tag: String, architecture: String, }, + /// A rootfs archive on the host filesystem, named with a path + /// ("./rootfs.tar.gz", "/tmp/rootfs.tar"): read directly at prepare + /// time, never downloaded and never cached as a pristine entry. + LocalTarball { path: PathBuf }, } impl Distro { @@ -53,6 +59,16 @@ pub fn parse_image_ref(input: &str, arch: &str) -> Result { return Ok(parse_oci_ref(rest, arch)?); } + // A reference that names a path stays a path: "./x", "../x" and + // "/x" can never be a distro name or an OCI reference, and the + // shell hands unexpanded "~" over only when quoted, which is + // deliberate. Everything else with a '/' is a registry ref. + if input.starts_with("./") || input.starts_with("../") || input.starts_with('/') { + return Ok(ImageSource::LocalTarball { + path: PathBuf::from(input), + }); + } + // Check for registry prefix (contains /) if input.contains('/') { return Ok(parse_oci_ref(input, arch)?); @@ -531,4 +547,31 @@ mod tests { url ); } + + #[test] + fn a_dotted_relative_reference_names_a_local_tarball() { + let local = |p: &str| ImageSource::LocalTarball { + path: std::path::PathBuf::from(p), + }; + let src = parse_image_ref("./rootfs.tar.gz", "amd64").expect("parses"); + assert_eq!(src, local("./rootfs.tar.gz")); + assert_eq!( + parse_image_ref("../images/rootfs.tar", "amd64").expect("parses"), + local("../images/rootfs.tar") + ); + assert_eq!( + parse_image_ref("/tmp/rootfs.tar.zst", "amd64").expect("parses"), + local("/tmp/rootfs.tar.zst") + ); + } + + #[test] + fn path_like_registry_references_are_never_local_files() { + // A leading path component that is a hostname stays OCI: only + // "./", "../" and "/" introduce a local archive. + assert_eq!( + parse("mydir/image"), + oci("docker.io", "mydir/image", "latest") + ); + } } diff --git a/crates/ecrou/src/download.rs b/crates/ecrou/src/download.rs index c643596..795ab71 100644 --- a/crates/ecrou/src/download.rs +++ b/crates/ecrou/src/download.rs @@ -199,6 +199,13 @@ pub fn download_image( progress, ))?) } + // A local archive is read in place by the prepare step; there + // is nothing to fetch. + ImageSource::LocalTarball { path } => Err(anyhow!( + "a local rootfs archive is never downloaded: {}", + path.display() + ) + .into()), } } diff --git a/crates/ecrou/src/guest.rs b/crates/ecrou/src/guest.rs index e4b207b..8feef35 100644 --- a/crates/ecrou/src/guest.rs +++ b/crates/ecrou/src/guest.rs @@ -35,6 +35,27 @@ pub struct GuestCapture { pub stderr: String, } +/// How the payload's stdio reaches the host. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GuestStdio { + /// The guest console. The default: a tty machine gives the payload + /// a terminal, the headless machine points it at the streamed + /// console file. + Console, + /// Host pipes. An interactive machine without a terminal: libkrun + /// exposes the run's non-tty stdio streams as virtio console ports + /// (`krun-stdin`, `krun-stdout`, `krun-stderr`), and the init wires + /// whichever port nodes exist onto the payload, leaving any tty + /// stream on the console. + HostPipes, +} + +/// The virtio port names libkrun gives the implicit console's pipe +/// streams (its builder, `autoconfigure_console_ports`). +pub const PORT_STDIN: &str = "/dev/virtio-ports/krun-stdin"; +pub const PORT_STDOUT: &str = "/dev/virtio-ports/krun-stdout"; +pub const PORT_STDERR: &str = "/dev/virtio-ports/krun-stderr"; + /// Shell-quote a string for safe interpolation into the init script. /// Single quotes wrap everything; an embedded single quote closes, /// escapes, and reopens. @@ -50,6 +71,7 @@ pub fn init_script( payload: &[String], workdir: &str, hostname: &str, + stdio: GuestStdio, capture: Option<&GuestCapture>, ) -> String { let mut script = String::from( @@ -93,6 +115,24 @@ pub fn init_script( sh_quote(if workdir.is_empty() { "/" } else { workdir }) )); + // Host pipes: re-point the init shell's own stdio, per stream, at + // whichever port nodes libkrun created. A stream the host left on + // a tty has no port node and stays on the console. When /dev is + // empty (no devtmpfs automount) the port nodes cannot exist, so + // mount it first; best effort. + if stdio == GuestStdio::HostPipes { + script.push_str( + "if [ ! -e /dev/null ]; then mount -t devtmpfs devtmpfs /dev 2>/dev/null || true; fi\n", + ); + for (node, redirect) in [(PORT_STDIN, "0<"), (PORT_STDOUT, "1>"), (PORT_STDERR, "2>")] { + script.push_str(&format!( + "if [ -c '{}' ]; then exec {}'{}'; fi\n", + node, redirect, node + )); + } + script.push('\n'); + } + // Captured runs wrap the payload in a redirection group: the // streams cross the capture share as files, and the group's exit // status - the payload's - is what `code=$?` reads below. The @@ -151,7 +191,14 @@ mod tests { read_only: true, }, ]; - let script = init_script(&shares, &["true".to_string()], "/", "ecrou-vm-x", None); + let script = init_script( + &shares, + &["true".to_string()], + "/", + "ecrou-vm-x", + GuestStdio::Console, + None, + ); assert!(script.contains("mount_share 'ws0' '/workspace' 'rw'")); assert!(script.contains("mount_share 'sk1' '/mnt/skills' 'ro'")); } @@ -163,7 +210,7 @@ mod tests { "-c".to_string(), "echo $HOME; rm -rf '/'".to_string(), ]; - let script = init_script(&[], &payload, "/", "ecrou-vm-x", None); + let script = init_script(&[], &payload, "/", "ecrou-vm-x", GuestStdio::Console, None); 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. @@ -175,7 +222,14 @@ mod tests { #[test] fn writes_exit_code_then_exits() { - let script = init_script(&[], &["true".to_string()], "/work", "ecrou-vm-x", None); + let script = init_script( + &[], + &["true".to_string()], + "/work", + "ecrou-vm-x", + GuestStdio::Console, + None, + ); assert!(script.contains(&format!("echo \"$code\" > '{}'", EXIT_FILE))); // The file write must precede the exit: it is the only // diagnostic when a boot dies without the init running. @@ -191,7 +245,14 @@ mod tests { #[test] fn hostname_lands_in_proc_and_etc() { - let script = init_script(&[], &["true".to_string()], "/", "ecrou-vm-x", None); + let script = init_script( + &[], + &["true".to_string()], + "/", + "ecrou-vm-x", + GuestStdio::Console, + None, + ); assert!(script.contains("echo 'ecrou-vm-x' > /proc/sys/kernel/hostname")); assert!(script.contains("echo 'ecrou-vm-x' > /etc/hostname")); } @@ -207,6 +268,7 @@ mod tests { &["true".to_string()], "/work", "ecrou-vm-x", + GuestStdio::Console, Some(&cap), ); // The payload line is a redirection group: the group's exit @@ -217,19 +279,97 @@ mod tests { assert!(payload_at < capture_at); } + #[test] + fn host_pipes_wire_each_stream_that_has_a_port() { + let script = init_script( + &[], + &["cat".to_string()], + "/", + "ecrou-vm-x", + GuestStdio::HostPipes, + None, + ); + assert!(script.contains( + "if [ -c '/dev/virtio-ports/krun-stdin' ]; then exec 0<'/dev/virtio-ports/krun-stdin'; fi" + )); + assert!(script.contains( + "if [ -c '/dev/virtio-ports/krun-stdout' ]; then exec 1>'/dev/virtio-ports/krun-stdout'; fi" + )); + assert!(script.contains( + "if [ -c '/dev/virtio-ports/krun-stderr' ]; then exec 2>'/dev/virtio-ports/krun-stderr'; fi" + )); + // A tty stream has no port node; the console fallback is the + // absence of the redirect, not a conditional mount of anything. + assert!(!script.contains("exec 0<'/dev/console'")); + // The wiring precedes the payload, whose fds it is. + let wire_at = script.find("exec 0<").unwrap(); + let payload_at = script.find("'cat'").unwrap(); + assert!(wire_at < payload_at); + } + + #[test] + fn the_console_stdio_adds_no_wiring() { + let script = init_script( + &[], + &["true".to_string()], + "/", + "ecrou-vm-x", + GuestStdio::Console, + None, + ); + assert!(!script.contains("virtio-ports")); + assert!(!script.contains("devtmpfs")); + } + + #[test] + fn capture_survives_the_pipe_wiring() { + let cap = GuestCapture { + stdout: CAPTURE_STDOUT.into(), + stderr: CAPTURE_STDERR.into(), + }; + let script = init_script( + &[], + &["true".to_string()], + "/", + "x", + GuestStdio::HostPipes, + Some(&cap), + ); + // The redirection group overrides the shell-level exec for + // stdout and stderr; stdin stays on the port. + assert!(script.contains("{ 'true' ; } > '/.ecrou-io/stdout' 2> '/.ecrou-io/stderr' ")); + let wire_at = script.find("exec 1>").unwrap(); + let group_at = script.find("{ 'true'").unwrap(); + assert!(wire_at < group_at); + } + #[test] fn capture_paths_are_quoted_like_any_operand() { let cap = GuestCapture { stdout: "/.ecrou-io/stdout".into(), stderr: "/.ecrou-io/stderr".into(), }; - let script = init_script(&[], &["echo it's".to_string()], "/", "x", Some(&cap)); + let script = init_script( + &[], + &["echo it's".to_string()], + "/", + "x", + GuestStdio::Console, + Some(&cap), + ); assert!(script.contains("echo it'\\''s")); } #[test] fn empty_workdir_falls_back_to_root() { - let script = init_script(&[], &["true".to_string()], "", "ecrou-vm-x", None); + let script = init_script( + &[], + &["true".to_string()], + "", + "ecrou-vm-x", + GuestStdio::Console, + None, + ); assert!(script.contains("cd '/'")); } } diff --git a/crates/ecrou/src/rootfs.rs b/crates/ecrou/src/rootfs.rs index 03dd60d..0e4897b 100644 --- a/crates/ecrou/src/rootfs.rs +++ b/crates/ecrou/src/rootfs.rs @@ -112,7 +112,7 @@ impl RootfsCache { let arch = std::env::consts::ARCH .replace("x86_64", "amd64") .replace("aarch64", "arm64"); - extract_prepared(path, arch, progress).map_err(Error::Other) + extract_prepared(&path.clone(), path, arch, progress).map_err(Error::Other) } /// The cache path an image resolves to, keyed or not — the same @@ -168,6 +168,23 @@ impl RootfsCache { pub fn prepare(&self, req: &PrepareRequest, progress: &dyn Progress) -> Result { let arch = req.resolved_arch(); let source = resolve_source(&req.image, &arch)?; + + // A local archive bypasses the cache: the file is the pristine + // source on every run (--no-cache has nothing to say about it), + // and the derived entry name only matters as the place a + // provisioning over it would persist into. + if let ImageSource::LocalTarball { path } = &source { + if !path.is_file() { + return Err(Error::Other(anyhow!( + "local rootfs archive not found: {} (a reference naming a path \ + must be a rootfs tarball: ./rootfs.tar.gz)", + path.display() + ))); + } + let entry = self.tarball_path(&source, &arch); + return extract_prepared(path, entry, arch, progress).map_err(Error::Other); + } + let cache_path = self.tarball_path(&source, &arch); // OCI images with a floating tag (":latest") need a freshness check: @@ -228,7 +245,12 @@ impl RootfsCache { log::debug!("Using cached tarball: {}", cache_path.display()); } - Ok(extract_prepared(cache_path, arch, progress)?) + Ok(extract_prepared( + &cache_path.clone(), + cache_path, + arch, + progress, + )?) } /// Prepare a provisioned rootfs with cache amortization: when the cache @@ -259,7 +281,12 @@ impl RootfsCache { if !req.no_cache && cache_path.exists() && marker.exists() { log::debug!("Using provisioned cache entry: {}", cache_path.display()); - return Ok(extract_prepared(cache_path, arch, progress)?); + return Ok(extract_prepared( + &cache_path.clone(), + cache_path, + arch, + progress, + )?); } // The miss path prepares the PRISTINE base — a keyed entry @@ -406,6 +433,7 @@ fn resolve_source(image: &str, arch: &str) -> anyhow::Result { } fn extract_prepared( + source: &Path, cache_path: PathBuf, arch: String, progress: &dyn Progress, @@ -414,7 +442,7 @@ fn extract_prepared( let rootfs = temp.path().to_path_buf(); log::debug!("Extracting to: {}", rootfs.display()); - extract_tarball(&cache_path, &rootfs, progress)?; + extract_tarball(source, &rootfs, progress)?; provision_resolver(&rootfs); Ok(PreparedRootfs { @@ -620,6 +648,26 @@ fn generate_cache_filename(source: &ImageSource, arch: &str) -> String { safe_registry, safe_repo, tag, architecture ) } + ImageSource::LocalTarball { path } => { + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("rootfs") + .replace(['/', '\\', ':'], "_"); + // Size and mtime key the entry: a provisioning over a + // local archive persists into the cache under this name, + // never over the caller's file, and an edited archive + // lands in a fresh entry. + let meta = std::fs::metadata(path); + let len = meta.as_ref().map(|m| m.len()).unwrap_or(0); + let mtime = meta + .ok() + .and_then(|m| m.modified().ok()) + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + .unwrap_or(0); + format!("local-{}-{:x}-{:x}.tar.gz", stem, len, mtime) + } } } @@ -808,6 +856,71 @@ mod tests { assert!(!dest.with_extension("partial").exists()); } + #[test] + fn a_local_archive_prepares_without_touching_the_cache() { + // A rootfs tree, packed next to itself as ./rootfs.tar.gz, the + // way a caller names a local archive. + let work = tempfile::tempdir().unwrap(); + let tree = work.path().join("tree"); + std::fs::create_dir_all(&tree).unwrap(); + std::fs::write(tree.join("etc-marker"), b"local").unwrap(); + let tarball = work.path().join("rootfs.tar.gz"); + pack_rootfs(&tree, &tarball).unwrap(); + + let cache_dir = tempfile::tempdir().unwrap(); + let cache = RootfsCache::new(cache_dir.path().to_path_buf()); + // An absolute spelling: "./" would resolve against this test + // process's working directory, not the tempdir. + let req = PrepareRequest::new(tarball.to_str().unwrap()); + let rootfs = cache.prepare(&req, &NoProgress).expect("prepares"); + assert_eq!( + std::fs::read(rootfs.dir().join("etc-marker")).unwrap(), + b"local" + ); + // Nothing was downloaded or stashed: the file is the source. + assert!(cache_dir.path().read_dir().unwrap().next().is_none()); + } + + #[test] + fn a_missing_local_archive_is_a_named_error() { + let cache = RootfsCache::new(std::env::temp_dir().join("ecrou-test-absent")); + let req = PrepareRequest::new("./definitely-not-here.tar.gz"); + let err = format!("{:#}", cache.prepare(&req, &NoProgress).unwrap_err()); + assert!(err.contains("definitely-not-here.tar.gz"), "{err}"); + assert!(err.contains("local rootfs archive"), "{err}"); + } + + #[test] + fn a_local_archive_keys_its_derived_entry_by_size_and_mtime() { + let source = crate::distro::parse_image_ref("./rootfs.tar.gz", "amd64").expect("parses"); + let cache = RootfsCache::new(std::path::PathBuf::from("/cache")); + let name = cache.tarball_path(&source, "amd64"); + let name = name.file_name().unwrap().to_str().unwrap(); + assert!(name.starts_with("local-rootfs.tar-"), "{name}"); + assert!(name.ends_with(".tar.gz"), "{name}"); + // A zero-length archive keys differently from a one-byte one: + // an edited file never lands in a stale derived entry. + let empty = tempfile::NamedTempFile::new().unwrap(); + let source = crate::distro::parse_image_ref(empty.path().to_str().unwrap(), "amd64") + .expect("parses"); + let empty_name = cache + .tarball_path(&source, "amd64") + .file_name() + .unwrap() + .to_str() + .unwrap() + .to_string(); + std::fs::write(empty.path(), b"x").unwrap(); + let grown = cache + .tarball_path(&source, "amd64") + .file_name() + .unwrap() + .to_str() + .unwrap() + .to_string(); + assert_ne!(empty_name, grown); + } + #[test] fn a_layer_key_splices_into_the_entry_name() { let cache = RootfsCache::new(std::path::PathBuf::from("/cache")); diff --git a/crates/ecrou/src/vm.rs b/crates/ecrou/src/vm.rs index 87eb72a..588d322 100644 --- a/crates/ecrou/src/vm.rs +++ b/crates/ecrou/src/vm.rs @@ -42,6 +42,15 @@ pub struct RunOptions { pub workdir: String, pub env: Vec<(String, String)>, pub hostname: String, + /// Keep the machine's stdin open: the host's stdin feeds the guest + /// console, so a shell reads typed input and a payload reads a + /// pipe. `tty` implies it. + pub interactive: bool, + /// Run the guest console on the host terminal: raw mode, live + /// cursor, the guest sees a tty. Needs a terminal on the host's + /// stdin and stdout; without one, run returns an error before any + /// machine exists. + pub tty: bool, /// How much of the engine's own logging reaches stderr pub log_level: LogLevel, pub no_cache: bool, @@ -87,6 +96,8 @@ impl Default for RunOptions { workdir: "/".to_string(), env: Vec::new(), hostname: String::new(), + interactive: false, + tty: false, log_level: LogLevel::Normal, no_cache: false, layer: None, @@ -129,6 +140,19 @@ pub fn run_prepared(rootfs: &PreparedRootfs, options: &RunOptions) -> Result Result Result>(), )?; - // The console goes through a file we stream to real stdout: without - // a tty, libkrun would route guest output through its logger, and - // with one, its terminal setup stops a background process with - // SIGTTOU. A killed parent kills the machine through PDEATHSIG. + // Headless, the console goes through a file we stream to real + // stdout: without a tty, libkrun would route guest output through + // its logger, and with one, its terminal setup stops a background + // process with SIGTTOU. Interactive modes skip the file: with no + // console output set, libkrun wires the guest console to this + // process's own stdin and stdout (raw mode and its restore are + // libkrun's on a tty; pipe streams surface as virtio ports the + // init wires). A killed parent kills the machine through PDEATHSIG. let console_path = rootfs.dir().join(".ecrou-console"); - std::fs::File::create(&console_path) - .with_context(|| format!("creating {}", console_path.display()))?; + if !interactive { + std::fs::File::create(&console_path) + .with_context(|| format!("creating {}", console_path.display()))?; + } // Fork with no other threads alive: the child only touches libkrun // configuration before entering the VM loop, which never returns (a @@ -247,22 +286,28 @@ pub fn run_prepared(rootfs: &PreparedRootfs, options: &RunOptions) -> Result= 0 { - libc::dup2(null, libc::STDIN_FILENO); - if null != libc::STDIN_FILENO { - libc::close(null); + // Headless only: unwire the console input (with a tty on + // fd 0 libkrun puts that terminal into raw mode, which + // fights whoever owns it, and stops a background caller + // with SIGTTOU). /dev/null keeps the console on its plain + // path and still forwards a Ctrl-C sent to the process + // group into the guest. An interactive machine wants the + // opposite: fd 0 is its console input by design. + if !interactive { + let null = libc::open(c"/dev/null".as_ptr(), libc::O_RDWR); + if null >= 0 { + libc::dup2(null, libc::STDIN_FILENO); + if null != libc::STDIN_FILENO { + libc::close(null); + } } + libc::signal(libc::SIGTTOU, libc::SIG_IGN); } - libc::signal(libc::SIGTTOU, libc::SIG_IGN); } - if let Err(err) = krun.set_console_output(ctx, &console_path) { - bail(format!("{err:#}"), 125); + if !interactive { + if let Err(err) = krun.set_console_output(ctx, &console_path) { + bail(format!("{err:#}"), 125); + } } if let Err(err) = krun.start_enter(ctx) { bail(format!("{err:#}"), 125); @@ -276,9 +321,56 @@ pub fn run_prepared(rootfs: &PreparedRootfs, options: &RunOptions) -> Result, +} + +impl TtyRestore { + fn save(wanted: bool) -> Self { + let saved = if wanted && unsafe { libc::isatty(libc::STDIN_FILENO) } == 1 { + // Safety: tcgetattr on our own stdin; the storage is valid + // and the call fills the whole struct or fails. + let mut term: libc::termios = unsafe { std::mem::zeroed() }; + if unsafe { libc::tcgetattr(libc::STDIN_FILENO, &mut term) } == 0 { + Some(term) + } else { + None + } + } else { + None + }; + TtyRestore { saved } + } + + fn restore(&self) { + if let Some(term) = &self.saved { + // Safety: the termios value came from tcgetattr on the + // same fd; TCSADRAIN flushes output before the switch. + unsafe { libc::tcsetattr(libc::STDIN_FILENO, libc::TCSADRAIN, term) }; + } + } } /// Relay the console file to real stdout while the machine runs, so diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index c27abc9..b828695 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -6,7 +6,7 @@ description: | ecrou boots disposable Linux environments as one-shot microVMs on libkrun: it resolves an image reference, boots one VM per command with live stdio, propagates the exit code, and throws the machine - away. Persistence crosses an explicit `--share` or it does not + away. Persistence crosses an explicit `-v/--volume` share or it does not happen. ## Confinement