From 23fcd713b6c514057d87f0705818842cb3206586 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Wed, 28 Oct 2026 13:37:33 +0100 Subject: [PATCH] feat(vm): capture the payload's output into a host directory The console is one stream, kernel and guest mixed, so a consumer that needs the payload's stdout and stderr apart - rhema's sandbox seam reports them as separate fields - could not get it. With capture set, the run adds the directory as an internal rw share (guest path /.ecrou-io, reserved) and the init wraps the payload in a redirection group pointing at it: the streams land host-side as DIR/stdout and DIR/stderr, written live, owned by the host user, and there when run() returns. The console stays on stdout for diagnostics; the CLI exposes the flag as --capture. --- SPEC.md | 18 +++++++-- crates/ecrou-cli/src/cli.rs | 4 ++ crates/ecrou-cli/src/main.rs | 1 + crates/ecrou/src/guest.rs | 76 +++++++++++++++++++++++++++++++++--- crates/ecrou/src/vm.rs | 43 +++++++++++++++++++- 5 files changed, 132 insertions(+), 10 deletions(-) diff --git a/SPEC.md b/SPEC.md index b29fb0c..5b9c8a3 100644 --- a/SPEC.md +++ b/SPEC.md @@ -242,6 +242,7 @@ pub struct RunOptions { pub env: Vec<(String, String)>, // composed envp; host env never inherited pub hostname: String, // empty = generated ecrou-vm-XXXXXX pub log_level: LogLevel, // Quiet | Normal | Verbose + pub capture: Option, // payload stdout/stderr into files } pub fn run(options: &RunOptions, progress: &dyn Progress) -> Result ``` @@ -250,9 +251,19 @@ Guest output reaches real stdout through the streamed console; the return value is the guest exit code. A run without working KVM is `Error::KvmUnavailable` with the remediation text. Everything else is `Error::Other` over the anyhow chain, ecr's -shape. The v1 API has no captured-output variant and no long-lived -machine; consumers pump the child's stdio themselves when they drive -the CLI. +shape. There is no long-lived machine; consumers pump the child's +stdio themselves when they drive the CLI. + +**Capture.** The console is one stream, kernel and guest mixed, and a +consumer that needs the payload's stdout and stderr apart - rhema's +sandbox seam reports them as separate fields - cannot read it. With +`capture` set, the run adds the directory as an internal rw share +(guest path `/.ecrou-io`, reserved), and the init wraps the payload in +a redirection group pointing at it: the two streams land host-side as +`/stdout` and `/stderr`, written live, owned by the host +user, and simply there when `run` returns. The console stays on stdout +for diagnostics, which is what makes capture and a nulled stdout +composable. A boot failure leaves the files absent. ## CLI @@ -268,6 +279,7 @@ ecrou probe | `--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 | diff --git a/crates/ecrou-cli/src/cli.rs b/crates/ecrou-cli/src/cli.rs index 685feb9..232fabd 100644 --- a/crates/ecrou-cli/src/cli.rs +++ b/crates/ecrou-cli/src/cli.rs @@ -49,6 +49,10 @@ pub struct RunArgs { /// Suppress console noise #[arg(long)] pub quiet: bool, + /// Capture the payload's stdout and stderr into files in this + /// directory: DIR/stdout and DIR/stderr. Created when missing. + #[arg(long)] + pub capture: Option, /// Ignore the cache and download a fresh image #[arg(long)] pub no_cache: bool, diff --git a/crates/ecrou-cli/src/main.rs b/crates/ecrou-cli/src/main.rs index bf75252..2aeed23 100644 --- a/crates/ecrou-cli/src/main.rs +++ b/crates/ecrou-cli/src/main.rs @@ -36,6 +36,7 @@ fn cmd_run(args: &RunArgs, cli_verbose: bool) -> std::process::ExitCode { LogLevel::Normal }, no_cache: args.no_cache, + capture: args.capture.clone(), ..RunOptions::default() }; for spec in &args.env { diff --git a/crates/ecrou/src/guest.rs b/crates/ecrou/src/guest.rs index 9c08d9a..e4b207b 100644 --- a/crates/ecrou/src/guest.rs +++ b/crates/ecrou/src/guest.rs @@ -18,6 +18,23 @@ pub struct GuestShare { /// scratch directory. pub const EXIT_FILE: &str = "/.ecrou-exit"; +/// The guest path the capture share mounts at, when the run captures +/// the payload's output. Reserved: no caller share may take it. +pub const CAPTURE_DIR: &str = "/.ecrou-io"; +/// The payload's stdout, host-side under the capture directory. +pub const CAPTURE_STDOUT: &str = "/.ecrou-io/stdout"; +/// The payload's stderr, host-side under the capture directory. +pub const CAPTURE_STDERR: &str = "/.ecrou-io/stderr"; + +/// Where the init points the payload's stdout and stderr when the run +/// captures them: guest paths under [`CAPTURE_DIR`], which the caller +/// reads host-side as files in its capture directory. +#[derive(Debug, Clone, PartialEq)] +pub struct GuestCapture { + pub stdout: String, + pub stderr: String, +} + /// Shell-quote a string for safe interpolation into the init script. /// Single quotes wrap everything; an embedded single quote closes, /// escapes, and reopens. @@ -33,6 +50,7 @@ pub fn init_script( payload: &[String], workdir: &str, hostname: &str, + capture: Option<&GuestCapture>, ) -> String { let mut script = String::from( "#!/bin/sh\n\ @@ -75,10 +93,27 @@ pub fn init_script( sh_quote(if workdir.is_empty() { "/" } else { workdir }) )); + // 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 + // console stays free for kernel and init diagnostics. + if capture.is_some() { + script.push_str("{ "); + } for arg in payload { script.push_str(&sh_quote(arg)); script.push(' '); } + if let Some(cap) = capture { + script.push_str( + format!( + "; }} > {} 2> {} ", + sh_quote(&cap.stdout), + sh_quote(&cap.stderr) + ) + .as_str(), + ); + } script.push_str("\n\ncode=$?\n"); script.push_str(&format!("echo \"$code\" > {}\n", sh_quote(EXIT_FILE))); // When this script exits, the guest init blob reaps it, reports the @@ -116,7 +151,7 @@ mod tests { read_only: true, }, ]; - let script = init_script(&shares, &["true".to_string()], "/", "ecrou-vm-x"); + let script = init_script(&shares, &["true".to_string()], "/", "ecrou-vm-x", None); assert!(script.contains("mount_share 'ws0' '/workspace' 'rw'")); assert!(script.contains("mount_share 'sk1' '/mnt/skills' 'ro'")); } @@ -128,7 +163,7 @@ mod tests { "-c".to_string(), "echo $HOME; rm -rf '/'".to_string(), ]; - let script = init_script(&[], &payload, "/", "ecrou-vm-x"); + let script = init_script(&[], &payload, "/", "ecrou-vm-x", 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. @@ -140,7 +175,7 @@ mod tests { #[test] fn writes_exit_code_then_exits() { - let script = init_script(&[], &["true".to_string()], "/work", "ecrou-vm-x"); + let script = init_script(&[], &["true".to_string()], "/work", "ecrou-vm-x", 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. @@ -156,14 +191,45 @@ mod tests { #[test] fn hostname_lands_in_proc_and_etc() { - let script = init_script(&[], &["true".to_string()], "/", "ecrou-vm-x"); + let script = init_script(&[], &["true".to_string()], "/", "ecrou-vm-x", None); assert!(script.contains("echo 'ecrou-vm-x' > /proc/sys/kernel/hostname")); assert!(script.contains("echo 'ecrou-vm-x' > /etc/hostname")); } + #[test] + fn capture_wraps_the_payload_and_points_at_the_reserved_share() { + let cap = GuestCapture { + stdout: CAPTURE_STDOUT.into(), + stderr: CAPTURE_STDERR.into(), + }; + let script = init_script( + &[], + &["true".to_string()], + "/work", + "ecrou-vm-x", + Some(&cap), + ); + // The payload line is a redirection group: the group's exit + // status, which `code=$?` reads, is the payload's. + assert!(script.contains("{ 'true' ; } > '/.ecrou-io/stdout' 2> '/.ecrou-io/stderr' ")); + let payload_at = script.find("{ 'true'").unwrap(); + let capture_at = script.find("code=$?").unwrap(); + assert!(payload_at < capture_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)); + assert!(script.contains("echo it'\\''s")); + } + #[test] fn empty_workdir_falls_back_to_root() { - let script = init_script(&[], &["true".to_string()], "", "ecrou-vm-x"); + let script = init_script(&[], &["true".to_string()], "", "ecrou-vm-x", None); assert!(script.contains("cd '/'")); } } diff --git a/crates/ecrou/src/vm.rs b/crates/ecrou/src/vm.rs index 236e6df..c3284ac 100644 --- a/crates/ecrou/src/vm.rs +++ b/crates/ecrou/src/vm.rs @@ -45,6 +45,15 @@ pub struct RunOptions { /// How much of the engine's own logging reaches stderr pub log_level: LogLevel, pub no_cache: bool, + /// When set, the payload's stdout and stderr are captured into + /// files in this host directory (`stdout` and `stderr` under it): + /// the run adds the directory as an internal read-write share, + /// mounted in the guest at [`crate::guest::CAPTURE_DIR`], and the + /// init points the payload's streams at it. The console stays on + /// stdout for diagnostics; the files land host-side as the guest + /// writes them and are simply there when `run` returns. A boot + /// failure leaves them absent. + pub capture: Option, } /// How much of the engine's own logging reaches stderr. The engine @@ -75,6 +84,7 @@ impl Default for RunOptions { hostname: String::new(), log_level: LogLevel::Normal, no_cache: false, + capture: None, } } } @@ -105,7 +115,21 @@ pub fn run(options: &RunOptions, progress: &dyn Progress) -> Result { options.hostname.clone() }; - let share_pairs = collect_shares(&rootfs, options).map_err(Error::Other)?; + let capture_share = options + .capture + .as_ref() + .map(|dir| { + std::fs::create_dir_all(dir) + .with_context(|| format!("creating the capture directory {}", dir.display()))?; + Ok(Share { + host: dir.clone(), + guest: guest::CAPTURE_DIR.to_string(), + read_only: false, + }) + }) + .transpose() + .map_err(Error::Other)?; + let share_pairs = collect_shares(&rootfs, options, capture_share).map_err(Error::Other)?; let shares = share_pairs .iter() .map(|(_, gs)| gs.clone()) @@ -117,7 +141,17 @@ pub fn run(options: &RunOptions, progress: &dyn Progress) -> Result { // contract (it writes the control file), the share mounter, and the // hostname applier. let init_path = rootfs.dir().join("ecrou-init"); - let script = guest::init_script(&shares, &payload, &options.workdir, &hostname); + let capture = options.capture.as_ref().map(|_| guest::GuestCapture { + stdout: guest::CAPTURE_STDOUT.to_string(), + stderr: guest::CAPTURE_STDERR.to_string(), + }); + let script = guest::init_script( + &shares, + &payload, + &options.workdir, + &hostname, + capture.as_ref(), + ); std::fs::write(&init_path, script) .with_context(|| format!("writing {}", init_path.display()))?; set_exec_bit(&init_path)?; @@ -344,12 +378,17 @@ fn decode_status(status: libc::c_int, exit_file_host: &Path) -> Result { fn collect_shares( rootfs: &PreparedRootfs, options: &RunOptions, + capture: Option, ) -> anyhow::Result> { let mut all = Vec::new(); if let Some(ws) = &options.workspace { all.push(ws.clone()); } all.extend(options.shares.iter().cloned()); + // The capture share rides last, after every caller share. + if let Some(cap) = capture { + all.push(cap); + } let mut out = Vec::with_capacity(all.len()); for (i, share) in all.iter().enumerate() {