mirror of
https://github.com/vhaudiquet/ecrou.git
synced 2026-11-12 22:22:44 +00:00
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:
1 parent
719a8209e0
commit
4a0c613ecb
9 files changed
+556
-59
No files matched your search
@@ -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
@@ -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]
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user