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
+569 -72

No files matched your search

+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"));
+114 -22
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");
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<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.
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);
// 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<i32
));
}
let reaped = std::sync::atomic::AtomicI32::new(-1);
stream_console(&console_path, child, &reaped)?;
wait_machine(child, &reaped, &exit_file_host)
// 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