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:
vhaudiquet committed 2026-10-29 18:08:30 +01:00
1 parent 719a8209e0
commit 4a0c613ecb
9 files changed
+556 -59

No files matched your search

+48 -15
View File
@@ -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<Share>, // 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<PathBuf>, // payload stdout/stderr into files
}
@@ -273,16 +298,24 @@ ecrou provision [OPTIONS] <IMAGE> [-- 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 <HOST:GUEST[:ro]>` | 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 <HOST:GUEST[:ro]>` | none | Share a directory into the machine; the first rw one is the workspace; repeatable |
| `-m, --memory <size>` | 1G | Guest memory |
| `--workdir <guest path>` | `/` | Guest cwd |
| `-w, --workdir <guest path>` | `/` | Guest cwd |
| `-e, --env <KEY=VALUE>` | none | Guest env; repeatable |
| `--quiet` | false | Suppress kernel log on the console |
| `--capture <DIR>` | none | Payload stdout/stderr into `<DIR>/stdout` and `<DIR>/stderr` |
| `--no-cache` | false | Fresh download, ignore the cache |
| `-v, --verbose` | false | Diagnostics; raises the libkrun log level |
| `--capture <DIR>` | none | Payload stdout/stderr into `<DIR>/stdout` and `<DIR>/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
+79 -21
View File
@@ -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]
+14 -3
View File
@@ -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),
}
+43
View File
@@ -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<ImageSource> {
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")
);
}
}
+7
View File
@@ -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()),
}
}
+146 -6
View File
@@ -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 '/'"));
}
}
+117 -4
View File
@@ -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<PreparedRootfs> {
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<ImageSource> {
}
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"));
+101 -9
View File
@@ -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<i32
if options.image.is_empty() {
return Err(Error::Other(anyhow!("no image given")));
}
// Interactive modes put the console on this process's own stdio;
// a tty run additionally demands a real terminal, before any
// machine exists to fail halfway into it.
let interactive = options.interactive || options.tty;
if options.tty {
let istty = |fd| unsafe { libc::isatty(fd) } == 1;
if !istty(libc::STDIN_FILENO) || !istty(libc::STDOUT_FILENO) {
return Err(Error::Other(anyhow!(
"a tty run needs a terminal on stdin and stdout"
)));
}
}
krun::kvm_probe()?;
let krun = Krun::load()?;
@@ -171,11 +195,20 @@ pub fn run_prepared(rootfs: &PreparedRootfs, options: &RunOptions) -> Result<i32
stdout: guest::CAPTURE_STDOUT.to_string(),
stderr: guest::CAPTURE_STDERR.to_string(),
});
// A tty machine keeps the payload on the console; an interactive
// machine without a terminal has libkrun expose its pipe streams
// as virtio ports, which the init wires onto the payload.
let stdio = if interactive && !options.tty {
guest::GuestStdio::HostPipes
} else {
guest::GuestStdio::Console
};
let script = guest::init_script(
&shares,
&payload,
&options.workdir,
&hostname,
stdio,
capture.as_ref(),
);
std::fs::write(&init_path, script)
@@ -217,13 +250,19 @@ pub fn run_prepared(rootfs: &PreparedRootfs, options: &RunOptions) -> Result<i32
.collect::<Vec<_>>(),
)?;
// 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");
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,11 +286,14 @@ pub fn run_prepared(rootfs: &PreparedRootfs, options: &RunOptions) -> Result<i32
};
unsafe {
libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL);
// 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.
// 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);
@@ -261,9 +303,12 @@ pub fn run_prepared(rootfs: &PreparedRootfs, options: &RunOptions) -> Result<i32
}
libc::signal(libc::SIGTTOU, libc::SIG_IGN);
}
}
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<i32
));
}
// The parent holds the terminal's mode across an interactive
// machine: libkrun restores through the child's exit observers,
// which a SIGKILLed machine never runs. (Only a tty is saved, so a
// piped -i run is a no-op here.)
let tty_restore = TtyRestore::save(interactive);
let result = if interactive {
wait_machine(
child,
&std::sync::atomic::AtomicI32::new(-1),
&exit_file_host,
)
} else {
let reaped = std::sync::atomic::AtomicI32::new(-1);
stream_console(&console_path, child, &reaped)?;
wait_machine(child, &reaped, &exit_file_host)
};
tty_restore.restore();
result
}
/// The host terminal's mode, saved before a tty machine boots and
/// restored after it ends.
struct TtyRestore {
saved: Option<libc::termios>,
}
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
+1 -1
View File
@@ -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