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.
This commit is contained in:
vhaudiquet committed 2026-10-28 13:37:33 +01:00
1 parent db2658f385
commit 23fcd713b6
5 files changed
+132 -10

No files matched your search

+15 -3
View File
@@ -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<PathBuf>, // payload stdout/stderr into files
}
pub fn run(options: &RunOptions, progress: &dyn Progress) -> Result<i32>
```
@@ -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
`<dir>/stdout` and `<dir>/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 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 |
+4
View File
@@ -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<std::path::PathBuf>,
/// Ignore the cache and download a fresh image
#[arg(long)]
pub no_cache: bool,
+1
View File
@@ -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 {
+71 -5
View File
@@ -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 '/'"));
}
}
+41 -2
View File
@@ -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<std::path::PathBuf>,
}
/// 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<i32> {
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<i32> {
// 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<i32> {
fn collect_shares(
rootfs: &PreparedRootfs,
options: &RunOptions,
capture: Option<Share>,
) -> anyhow::Result<Vec<(Share, GuestShare)>> {
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() {