mirror of
https://github.com/vhaudiquet/ecrou.git
synced 2026-11-12 22:22:44 +00:00
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:
1 parent
db2658f385
commit
23fcd713b6
5 files changed
+132
-10
No files matched your search
@@ -242,6 +242,7 @@ pub struct RunOptions {
|
|||||||
pub env: Vec<(String, String)>, // composed envp; host env never inherited
|
pub env: Vec<(String, String)>, // composed envp; host env never inherited
|
||||||
pub hostname: String, // empty = generated ecrou-vm-XXXXXX
|
pub hostname: String, // empty = generated ecrou-vm-XXXXXX
|
||||||
pub log_level: LogLevel, // Quiet | Normal | Verbose
|
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>
|
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
|
return value is the guest exit code. A run
|
||||||
without working KVM is `Error::KvmUnavailable` with the remediation
|
without working KVM is `Error::KvmUnavailable` with the remediation
|
||||||
text. Everything else is `Error::Other` over the anyhow chain, ecr's
|
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
|
shape. There is no long-lived machine; consumers pump the child's
|
||||||
machine; consumers pump the child's stdio themselves when they drive
|
stdio themselves when they drive the CLI.
|
||||||
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
|
## CLI
|
||||||
|
|
||||||
@@ -268,6 +279,7 @@ ecrou probe
|
|||||||
| `--workdir <guest path>` | `/` | Guest cwd |
|
| `--workdir <guest path>` | `/` | Guest cwd |
|
||||||
| `-e, --env <KEY=VALUE>` | none | Guest env; repeatable |
|
| `-e, --env <KEY=VALUE>` | none | Guest env; repeatable |
|
||||||
| `--quiet` | false | Suppress kernel log on the console |
|
| `--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 |
|
| `--no-cache` | false | Fresh download, ignore the cache |
|
||||||
| `-v, --verbose` | false | Diagnostics; raises the libkrun log level |
|
| `-v, --verbose` | false | Diagnostics; raises the libkrun log level |
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ pub struct RunArgs {
|
|||||||
/// Suppress console noise
|
/// Suppress console noise
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub quiet: bool,
|
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
|
/// Ignore the cache and download a fresh image
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub no_cache: bool,
|
pub no_cache: bool,
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ fn cmd_run(args: &RunArgs, cli_verbose: bool) -> std::process::ExitCode {
|
|||||||
LogLevel::Normal
|
LogLevel::Normal
|
||||||
},
|
},
|
||||||
no_cache: args.no_cache,
|
no_cache: args.no_cache,
|
||||||
|
capture: args.capture.clone(),
|
||||||
..RunOptions::default()
|
..RunOptions::default()
|
||||||
};
|
};
|
||||||
for spec in &args.env {
|
for spec in &args.env {
|
||||||
|
|||||||
@@ -18,6 +18,23 @@ pub struct GuestShare {
|
|||||||
/// scratch directory.
|
/// scratch directory.
|
||||||
pub const EXIT_FILE: &str = "/.ecrou-exit";
|
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.
|
/// Shell-quote a string for safe interpolation into the init script.
|
||||||
/// Single quotes wrap everything; an embedded single quote closes,
|
/// Single quotes wrap everything; an embedded single quote closes,
|
||||||
/// escapes, and reopens.
|
/// escapes, and reopens.
|
||||||
@@ -33,6 +50,7 @@ pub fn init_script(
|
|||||||
payload: &[String],
|
payload: &[String],
|
||||||
workdir: &str,
|
workdir: &str,
|
||||||
hostname: &str,
|
hostname: &str,
|
||||||
|
capture: Option<&GuestCapture>,
|
||||||
) -> String {
|
) -> String {
|
||||||
let mut script = String::from(
|
let mut script = String::from(
|
||||||
"#!/bin/sh\n\
|
"#!/bin/sh\n\
|
||||||
@@ -75,10 +93,27 @@ pub fn init_script(
|
|||||||
sh_quote(if workdir.is_empty() { "/" } else { workdir })
|
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 {
|
for arg in payload {
|
||||||
script.push_str(&sh_quote(arg));
|
script.push_str(&sh_quote(arg));
|
||||||
script.push(' ');
|
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("\n\ncode=$?\n");
|
||||||
script.push_str(&format!("echo \"$code\" > {}\n", sh_quote(EXIT_FILE)));
|
script.push_str(&format!("echo \"$code\" > {}\n", sh_quote(EXIT_FILE)));
|
||||||
// When this script exits, the guest init blob reaps it, reports the
|
// When this script exits, the guest init blob reaps it, reports the
|
||||||
@@ -116,7 +151,7 @@ mod tests {
|
|||||||
read_only: true,
|
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 'ws0' '/workspace' 'rw'"));
|
||||||
assert!(script.contains("mount_share 'sk1' '/mnt/skills' 'ro'"));
|
assert!(script.contains("mount_share 'sk1' '/mnt/skills' 'ro'"));
|
||||||
}
|
}
|
||||||
@@ -128,7 +163,7 @@ mod tests {
|
|||||||
"-c".to_string(),
|
"-c".to_string(),
|
||||||
"echo $HOME; rm -rf '/'".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 '\''/'\''' "));
|
assert!(script.contains(r"'/bin/bash' '-c' 'echo $HOME; rm -rf '\''/'\''' "));
|
||||||
// The payload line must appear after the cd and before the code
|
// The payload line must appear after the cd and before the code
|
||||||
// capture, so its exit code is what gets written.
|
// capture, so its exit code is what gets written.
|
||||||
@@ -140,7 +175,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn writes_exit_code_then_exits() {
|
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)));
|
assert!(script.contains(&format!("echo \"$code\" > '{}'", EXIT_FILE)));
|
||||||
// The file write must precede the exit: it is the only
|
// The file write must precede the exit: it is the only
|
||||||
// diagnostic when a boot dies without the init running.
|
// diagnostic when a boot dies without the init running.
|
||||||
@@ -156,14 +191,45 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hostname_lands_in_proc_and_etc() {
|
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' > /proc/sys/kernel/hostname"));
|
||||||
assert!(script.contains("echo 'ecrou-vm-x' > /etc/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]
|
#[test]
|
||||||
fn empty_workdir_falls_back_to_root() {
|
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 '/'"));
|
assert!(script.contains("cd '/'"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+41
-2
@@ -45,6 +45,15 @@ pub struct RunOptions {
|
|||||||
/// How much of the engine's own logging reaches stderr
|
/// How much of the engine's own logging reaches stderr
|
||||||
pub log_level: LogLevel,
|
pub log_level: LogLevel,
|
||||||
pub no_cache: bool,
|
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
|
/// How much of the engine's own logging reaches stderr. The engine
|
||||||
@@ -75,6 +84,7 @@ impl Default for RunOptions {
|
|||||||
hostname: String::new(),
|
hostname: String::new(),
|
||||||
log_level: LogLevel::Normal,
|
log_level: LogLevel::Normal,
|
||||||
no_cache: false,
|
no_cache: false,
|
||||||
|
capture: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,7 +115,21 @@ pub fn run(options: &RunOptions, progress: &dyn Progress) -> Result<i32> {
|
|||||||
options.hostname.clone()
|
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
|
let shares = share_pairs
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(_, gs)| gs.clone())
|
.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
|
// contract (it writes the control file), the share mounter, and the
|
||||||
// hostname applier.
|
// hostname applier.
|
||||||
let init_path = rootfs.dir().join("ecrou-init");
|
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)
|
std::fs::write(&init_path, script)
|
||||||
.with_context(|| format!("writing {}", init_path.display()))?;
|
.with_context(|| format!("writing {}", init_path.display()))?;
|
||||||
set_exec_bit(&init_path)?;
|
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(
|
fn collect_shares(
|
||||||
rootfs: &PreparedRootfs,
|
rootfs: &PreparedRootfs,
|
||||||
options: &RunOptions,
|
options: &RunOptions,
|
||||||
|
capture: Option<Share>,
|
||||||
) -> anyhow::Result<Vec<(Share, GuestShare)>> {
|
) -> anyhow::Result<Vec<(Share, GuestShare)>> {
|
||||||
let mut all = Vec::new();
|
let mut all = Vec::new();
|
||||||
if let Some(ws) = &options.workspace {
|
if let Some(ws) = &options.workspace {
|
||||||
all.push(ws.clone());
|
all.push(ws.clone());
|
||||||
}
|
}
|
||||||
all.extend(options.shares.iter().cloned());
|
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());
|
let mut out = Vec::with_capacity(all.len());
|
||||||
for (i, share) in all.iter().enumerate() {
|
for (i, share) in all.iter().enumerate() {
|
||||||
|
|||||||
Reference in new issue
Block a user