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
|
the run and dies with it. Persistence crosses a workspace share or it
|
||||||
does not happen.
|
does not happen.
|
||||||
|
|
||||||
**Workspace shares.** `--share HOST:GUEST[:ro]`, one virtiofs share per
|
**Workspace shares.** `-v HOST:GUEST[:ro]` (`--volume`), one virtiofs
|
||||||
entry (`krun_add_virtiofs`, `krun_set_root` keeps the rootfs itself).
|
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
|
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
|
guest path. There is no implicit share: an empty sandbox is the
|
||||||
default, and the consumer chooses what crosses. This is deliberate; a
|
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
|
exec`'s cancel, which kills only the local client; the tests keep it
|
||||||
true (T4).
|
true (T4).
|
||||||
|
|
||||||
**Console.** Guest output reaches real stdout through a console file
|
**Console.** Three wirings, chosen by the run's flags.
|
||||||
the parent streams while the machine runs, with a final drain after
|
|
||||||
the reap. The direct stdio wiring does not survive contact with
|
Headless (the default): guest output reaches real stdout through a
|
||||||
reality: with a tty on fd 0 libkrun's terminal setup stops 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
|
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
|
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
|
/dev/null (no guest input; a Ctrl-C to the process group is still
|
||||||
still forwarded into the guest). There is no QEMU monitor to mux;
|
forwarded into the guest).
|
||||||
`--verbose` maps to `krun_set_log_level`.
|
|
||||||
|
`-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
|
**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.
|
its socket calls are proxied on the host's stack by the VMM process.
|
||||||
@@ -232,7 +254,8 @@ absent kernel entries only.
|
|||||||
```rust
|
```rust
|
||||||
pub struct Share { pub host: PathBuf, pub guest: String, pub read_only: bool }
|
pub struct Share { pub host: PathBuf, pub guest: String, pub read_only: bool }
|
||||||
pub struct RunOptions {
|
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 arch: String, // empty = host arch
|
||||||
pub memory: String, // default "1G"
|
pub memory: String, // default "1G"
|
||||||
pub workspace: Option<Share>, // the one rw share consumers name
|
pub workspace: Option<Share>, // the one rw share consumers name
|
||||||
@@ -241,6 +264,8 @@ pub struct RunOptions {
|
|||||||
pub workdir: String, // guest cwd; empty = /
|
pub workdir: String, // guest cwd; empty = /
|
||||||
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 interactive: bool, // host stdin feeds the guest
|
||||||
|
pub tty: bool, // console on the terminal (implies interactive)
|
||||||
pub log_level: LogLevel, // Quiet | Normal | Verbose
|
pub log_level: LogLevel, // Quiet | Normal | Verbose
|
||||||
pub capture: Option<PathBuf>, // payload stdout/stderr into files
|
pub capture: Option<PathBuf>, // payload stdout/stderr into files
|
||||||
}
|
}
|
||||||
@@ -273,16 +298,24 @@ ecrou provision [OPTIONS] <IMAGE> [-- COMMAND...]
|
|||||||
ecrou probe
|
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 |
|
| 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 |
|
| `-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 |
|
| `-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` |
|
| `--capture <DIR>` | none | Payload stdout/stderr into `<DIR>/stdout` and `<DIR>/stderr`; incompatible with `-t` |
|
||||||
| `--no-cache` | false | Fresh download, ignore the cache |
|
| `--no-cache` | false | Fresh download, ignore the cache (a local archive is always read fresh) |
|
||||||
| `-v, --verbose` | false | Diagnostics; raises the libkrun log level |
|
| `--verbose` | false | Diagnostics; raises the libkrun log level |
|
||||||
|
|
||||||
`ecrou provision IMAGE -- COMMAND` bakes a command into the image's
|
`ecrou provision IMAGE -- COMMAND` bakes a command into the image's
|
||||||
cache entry: the command runs in a machine over a freshly prepared
|
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",
|
name = "ecrou",
|
||||||
version,
|
version,
|
||||||
about = "One-shot microVM environments on libkrun",
|
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 {
|
pub struct Cli {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
pub command: Command,
|
pub command: Command,
|
||||||
/// Print diagnostic messages
|
/// Print diagnostic messages
|
||||||
#[arg(short, long, global = true)]
|
#[arg(long, global = true)]
|
||||||
pub verbose: bool,
|
pub verbose: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,23 +55,32 @@ pub enum Command {
|
|||||||
|
|
||||||
#[derive(Args, Debug)]
|
#[derive(Args, Debug)]
|
||||||
pub struct RunArgs {
|
pub struct RunArgs {
|
||||||
/// Distribution name or OCI image reference; not needed with
|
/// Distribution name, OCI image reference, or a local rootfs
|
||||||
/// `--layer`, which boots a provisioned cache entry
|
/// archive (a path: ./rootfs.tar.gz, /tmp/rootfs.tar); not needed
|
||||||
|
/// with `--layer`, which boots a provisioned cache entry
|
||||||
#[arg(required_unless_present = "layer")]
|
#[arg(required_unless_present = "layer")]
|
||||||
pub image: Option<String>,
|
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
|
/// Command to run in the machine; the image's default shell when
|
||||||
/// omitted. An argument vector after `--` is taken literally; a
|
/// omitted. An argument vector after `--` is taken literally; a
|
||||||
/// single quoted line runs through the image's shell.
|
/// single quoted line runs through the image's shell.
|
||||||
#[arg(last = true)]
|
#[arg(last = true)]
|
||||||
pub command: Vec<String>,
|
pub command: Vec<String>,
|
||||||
/// Directory to share into the machine as HOST:GUEST[:ro] (repeatable)
|
/// Directory to share into the machine as HOST:GUEST[:ro] (repeatable)
|
||||||
#[arg(long = "share")]
|
#[arg(short = 'v', long = "volume")]
|
||||||
pub shares: Vec<String>,
|
pub volumes: Vec<String>,
|
||||||
/// Guest memory size
|
/// Guest memory size
|
||||||
#[arg(short = 'm', long, default_value = "1G")]
|
#[arg(short = 'm', long, default_value = "1G")]
|
||||||
pub memory: String,
|
pub memory: String,
|
||||||
/// Guest working directory
|
/// Guest working directory
|
||||||
#[arg(long, default_value = "/")]
|
#[arg(short = 'w', long, default_value = "/")]
|
||||||
pub workdir: String,
|
pub workdir: String,
|
||||||
/// Guest environment KEY=VALUE (repeatable)
|
/// Guest environment KEY=VALUE (repeatable)
|
||||||
#[arg(short = 'e', long = "env")]
|
#[arg(short = 'e', long = "env")]
|
||||||
@@ -92,9 +101,9 @@ pub struct RunArgs {
|
|||||||
pub layer: Option<String>,
|
pub layer: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse one `HOST:GUEST[:ro]` share specification. The guest path must
|
/// Parse one `HOST:GUEST[:ro]` volume specification. The guest path
|
||||||
/// be absolute; the host side is checked for existence at run time.
|
/// must be absolute; the host side is checked for existence at run time.
|
||||||
pub fn parse_share(spec: &str) -> anyhow::Result<ecrou::Share> {
|
pub fn parse_volume(spec: &str) -> anyhow::Result<ecrou::Share> {
|
||||||
let parts: Vec<&str> = spec.split(':').collect();
|
let parts: Vec<&str> = spec.split(':').collect();
|
||||||
let (host, guest, ro) = match parts.as_slice() {
|
let (host, guest, ro) = match parts.as_slice() {
|
||||||
[h, g] => (*h, *g, false),
|
[h, g] => (*h, *g, false),
|
||||||
@@ -102,10 +111,10 @@ pub fn parse_share(spec: &str) -> anyhow::Result<ecrou::Share> {
|
|||||||
[_, _, other] => {
|
[_, _, other] => {
|
||||||
anyhow::bail!("share suffix must be 'ro', got ':{}' in {:?}", other, spec)
|
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() {
|
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('/') {
|
if !guest.starts_with('/') {
|
||||||
anyhow::bail!("share guest path must be absolute, got {:?}", guest);
|
anyhow::bail!("share guest path must be absolute, got {:?}", guest);
|
||||||
@@ -133,23 +142,72 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn shares_parse_rw_and_ro() {
|
fn volumes_parse_rw_and_ro() {
|
||||||
let s = parse_share("/home/me/work:/workspace").unwrap();
|
let s = parse_volume("/home/me/work:/workspace").unwrap();
|
||||||
assert_eq!(s.host, std::path::Path::new("/home/me/work"));
|
assert_eq!(s.host, std::path::Path::new("/home/me/work"));
|
||||||
assert_eq!(s.guest, "/workspace");
|
assert_eq!(s.guest, "/workspace");
|
||||||
assert!(!s.read_only);
|
assert!(!s.read_only);
|
||||||
|
|
||||||
let s = parse_share("/skills:/skills:ro").unwrap();
|
let s = parse_volume("/skills:/skills:ro").unwrap();
|
||||||
assert!(s.read_only);
|
assert!(s.read_only);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn shares_reject_malformed_specs() {
|
fn volumes_reject_malformed_specs() {
|
||||||
assert!(parse_share("/only-host").is_err());
|
assert!(parse_volume("/only-host").is_err());
|
||||||
assert!(parse_share(":/guest").is_err());
|
assert!(parse_volume(":/guest").is_err());
|
||||||
assert!(parse_share("/host:relative").is_err());
|
assert!(parse_volume("/host:relative").is_err());
|
||||||
assert!(parse_share("/h:/g:rw").is_err());
|
assert!(parse_volume("/h:/g:rw").is_err());
|
||||||
assert!(parse_share("/h:/g:ro:extra").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]
|
#[test]
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use clap::Parser;
|
|||||||
use ecrou::vm::RunOptions;
|
use ecrou::vm::RunOptions;
|
||||||
use ecrou::{LogLevel, NoProgress, Progress};
|
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;
|
use progress::TermProgress;
|
||||||
|
|
||||||
fn main() -> std::process::ExitCode {
|
fn main() -> std::process::ExitCode {
|
||||||
@@ -157,6 +157,15 @@ fn cmd_provision(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_run(args: &RunArgs, cli_verbose: bool) -> std::process::ExitCode {
|
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 {
|
let mut options = RunOptions {
|
||||||
// Without a layer the image is required by clap; with one the
|
// Without a layer the image is required by clap; with one the
|
||||||
// field is dead — the layer entry carries the rootfs.
|
// 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 {
|
} else {
|
||||||
LogLevel::Normal
|
LogLevel::Normal
|
||||||
},
|
},
|
||||||
|
interactive: args.interactive,
|
||||||
|
tty: args.tty,
|
||||||
no_cache: args.no_cache,
|
no_cache: args.no_cache,
|
||||||
layer: args.layer.clone(),
|
layer: args.layer.clone(),
|
||||||
capture: args.capture.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();
|
let mut shares = Vec::new();
|
||||||
for spec in &args.shares {
|
for spec in &args.volumes {
|
||||||
match parse_share(spec) {
|
match parse_volume(spec) {
|
||||||
Ok(share) => shares.push(share),
|
Ok(share) => shares.push(share),
|
||||||
Err(err) => return fail(&err),
|
Err(err) => return fail(&err),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use anyhow::{anyhow, Context};
|
use anyhow::{anyhow, Context};
|
||||||
|
|
||||||
use crate::Result;
|
use crate::Result;
|
||||||
@@ -24,6 +26,10 @@ pub enum ImageSource {
|
|||||||
tag: String,
|
tag: String,
|
||||||
architecture: 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 {
|
impl Distro {
|
||||||
@@ -53,6 +59,16 @@ pub fn parse_image_ref(input: &str, arch: &str) -> Result<ImageSource> {
|
|||||||
return Ok(parse_oci_ref(rest, arch)?);
|
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 /)
|
// Check for registry prefix (contains /)
|
||||||
if input.contains('/') {
|
if input.contains('/') {
|
||||||
return Ok(parse_oci_ref(input, arch)?);
|
return Ok(parse_oci_ref(input, arch)?);
|
||||||
@@ -531,4 +547,31 @@ mod tests {
|
|||||||
url
|
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,
|
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,
|
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.
|
/// 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.
|
||||||
@@ -50,6 +71,7 @@ pub fn init_script(
|
|||||||
payload: &[String],
|
payload: &[String],
|
||||||
workdir: &str,
|
workdir: &str,
|
||||||
hostname: &str,
|
hostname: &str,
|
||||||
|
stdio: GuestStdio,
|
||||||
capture: Option<&GuestCapture>,
|
capture: Option<&GuestCapture>,
|
||||||
) -> String {
|
) -> String {
|
||||||
let mut script = String::from(
|
let mut script = String::from(
|
||||||
@@ -93,6 +115,24 @@ pub fn init_script(
|
|||||||
sh_quote(if workdir.is_empty() { "/" } else { workdir })
|
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
|
// Captured runs wrap the payload in a redirection group: the
|
||||||
// streams cross the capture share as files, and the group's exit
|
// streams cross the capture share as files, and the group's exit
|
||||||
// status - the payload's - is what `code=$?` reads below. The
|
// status - the payload's - is what `code=$?` reads below. The
|
||||||
@@ -151,7 +191,14 @@ mod tests {
|
|||||||
read_only: true,
|
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 'ws0' '/workspace' 'rw'"));
|
||||||
assert!(script.contains("mount_share 'sk1' '/mnt/skills' 'ro'"));
|
assert!(script.contains("mount_share 'sk1' '/mnt/skills' 'ro'"));
|
||||||
}
|
}
|
||||||
@@ -163,7 +210,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", None);
|
let script = init_script(&[], &payload, "/", "ecrou-vm-x", GuestStdio::Console, 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.
|
||||||
@@ -175,7 +222,14 @@ 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", None);
|
let script = init_script(
|
||||||
|
&[],
|
||||||
|
&["true".to_string()],
|
||||||
|
"/work",
|
||||||
|
"ecrou-vm-x",
|
||||||
|
GuestStdio::Console,
|
||||||
|
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.
|
||||||
@@ -191,7 +245,14 @@ 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", 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' > /proc/sys/kernel/hostname"));
|
||||||
assert!(script.contains("echo 'ecrou-vm-x' > /etc/hostname"));
|
assert!(script.contains("echo 'ecrou-vm-x' > /etc/hostname"));
|
||||||
}
|
}
|
||||||
@@ -207,6 +268,7 @@ mod tests {
|
|||||||
&["true".to_string()],
|
&["true".to_string()],
|
||||||
"/work",
|
"/work",
|
||||||
"ecrou-vm-x",
|
"ecrou-vm-x",
|
||||||
|
GuestStdio::Console,
|
||||||
Some(&cap),
|
Some(&cap),
|
||||||
);
|
);
|
||||||
// The payload line is a redirection group: the group's exit
|
// The payload line is a redirection group: the group's exit
|
||||||
@@ -217,19 +279,97 @@ mod tests {
|
|||||||
assert!(payload_at < capture_at);
|
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]
|
#[test]
|
||||||
fn capture_paths_are_quoted_like_any_operand() {
|
fn capture_paths_are_quoted_like_any_operand() {
|
||||||
let cap = GuestCapture {
|
let cap = GuestCapture {
|
||||||
stdout: "/.ecrou-io/stdout".into(),
|
stdout: "/.ecrou-io/stdout".into(),
|
||||||
stderr: "/.ecrou-io/stderr".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"));
|
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", None);
|
let script = init_script(
|
||||||
|
&[],
|
||||||
|
&["true".to_string()],
|
||||||
|
"",
|
||||||
|
"ecrou-vm-x",
|
||||||
|
GuestStdio::Console,
|
||||||
|
None,
|
||||||
|
);
|
||||||
assert!(script.contains("cd '/'"));
|
assert!(script.contains("cd '/'"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+117
-4
@@ -112,7 +112,7 @@ impl RootfsCache {
|
|||||||
let arch = std::env::consts::ARCH
|
let arch = std::env::consts::ARCH
|
||||||
.replace("x86_64", "amd64")
|
.replace("x86_64", "amd64")
|
||||||
.replace("aarch64", "arm64");
|
.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
|
/// 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> {
|
pub fn prepare(&self, req: &PrepareRequest, progress: &dyn Progress) -> Result<PreparedRootfs> {
|
||||||
let arch = req.resolved_arch();
|
let arch = req.resolved_arch();
|
||||||
let source = resolve_source(&req.image, &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);
|
let cache_path = self.tarball_path(&source, &arch);
|
||||||
|
|
||||||
// OCI images with a floating tag (":latest") need a freshness check:
|
// 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());
|
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
|
/// 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() {
|
if !req.no_cache && cache_path.exists() && marker.exists() {
|
||||||
log::debug!("Using provisioned cache entry: {}", cache_path.display());
|
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
|
// 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(
|
fn extract_prepared(
|
||||||
|
source: &Path,
|
||||||
cache_path: PathBuf,
|
cache_path: PathBuf,
|
||||||
arch: String,
|
arch: String,
|
||||||
progress: &dyn Progress,
|
progress: &dyn Progress,
|
||||||
@@ -414,7 +442,7 @@ fn extract_prepared(
|
|||||||
let rootfs = temp.path().to_path_buf();
|
let rootfs = temp.path().to_path_buf();
|
||||||
|
|
||||||
log::debug!("Extracting to: {}", rootfs.display());
|
log::debug!("Extracting to: {}", rootfs.display());
|
||||||
extract_tarball(&cache_path, &rootfs, progress)?;
|
extract_tarball(source, &rootfs, progress)?;
|
||||||
provision_resolver(&rootfs);
|
provision_resolver(&rootfs);
|
||||||
|
|
||||||
Ok(PreparedRootfs {
|
Ok(PreparedRootfs {
|
||||||
@@ -620,6 +648,26 @@ fn generate_cache_filename(source: &ImageSource, arch: &str) -> String {
|
|||||||
safe_registry, safe_repo, tag, architecture
|
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());
|
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]
|
#[test]
|
||||||
fn a_layer_key_splices_into_the_entry_name() {
|
fn a_layer_key_splices_into_the_entry_name() {
|
||||||
let cache = RootfsCache::new(std::path::PathBuf::from("/cache"));
|
let cache = RootfsCache::new(std::path::PathBuf::from("/cache"));
|
||||||
|
|||||||
+101
-9
@@ -42,6 +42,15 @@ pub struct RunOptions {
|
|||||||
pub workdir: String,
|
pub workdir: String,
|
||||||
pub env: Vec<(String, String)>,
|
pub env: Vec<(String, String)>,
|
||||||
pub hostname: 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
|
/// 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,
|
||||||
@@ -87,6 +96,8 @@ impl Default for RunOptions {
|
|||||||
workdir: "/".to_string(),
|
workdir: "/".to_string(),
|
||||||
env: Vec::new(),
|
env: Vec::new(),
|
||||||
hostname: String::new(),
|
hostname: String::new(),
|
||||||
|
interactive: false,
|
||||||
|
tty: false,
|
||||||
log_level: LogLevel::Normal,
|
log_level: LogLevel::Normal,
|
||||||
no_cache: false,
|
no_cache: false,
|
||||||
layer: None,
|
layer: None,
|
||||||
@@ -129,6 +140,19 @@ pub fn run_prepared(rootfs: &PreparedRootfs, options: &RunOptions) -> Result<i32
|
|||||||
if options.image.is_empty() {
|
if options.image.is_empty() {
|
||||||
return Err(Error::Other(anyhow!("no image given")));
|
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()?;
|
krun::kvm_probe()?;
|
||||||
let krun = Krun::load()?;
|
let krun = Krun::load()?;
|
||||||
|
|
||||||
@@ -171,11 +195,20 @@ pub fn run_prepared(rootfs: &PreparedRootfs, options: &RunOptions) -> Result<i32
|
|||||||
stdout: guest::CAPTURE_STDOUT.to_string(),
|
stdout: guest::CAPTURE_STDOUT.to_string(),
|
||||||
stderr: guest::CAPTURE_STDERR.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(
|
let script = guest::init_script(
|
||||||
&shares,
|
&shares,
|
||||||
&payload,
|
&payload,
|
||||||
&options.workdir,
|
&options.workdir,
|
||||||
&hostname,
|
&hostname,
|
||||||
|
stdio,
|
||||||
capture.as_ref(),
|
capture.as_ref(),
|
||||||
);
|
);
|
||||||
std::fs::write(&init_path, script)
|
std::fs::write(&init_path, script)
|
||||||
@@ -217,13 +250,19 @@ pub fn run_prepared(rootfs: &PreparedRootfs, options: &RunOptions) -> Result<i32
|
|||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// The console goes through a file we stream to real stdout: without
|
// Headless, the console goes through a file we stream to real
|
||||||
// a tty, libkrun would route guest output through its logger, and
|
// stdout: without a tty, libkrun would route guest output through
|
||||||
// with one, its terminal setup stops a background process with
|
// its logger, and with one, its terminal setup stops a background
|
||||||
// SIGTTOU. A killed parent kills the machine through PDEATHSIG.
|
// 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");
|
let console_path = rootfs.dir().join(".ecrou-console");
|
||||||
|
if !interactive {
|
||||||
std::fs::File::create(&console_path)
|
std::fs::File::create(&console_path)
|
||||||
.with_context(|| format!("creating {}", console_path.display()))?;
|
.with_context(|| format!("creating {}", console_path.display()))?;
|
||||||
|
}
|
||||||
|
|
||||||
// Fork with no other threads alive: the child only touches libkrun
|
// Fork with no other threads alive: the child only touches libkrun
|
||||||
// configuration before entering the VM loop, which never returns (a
|
// 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 {
|
unsafe {
|
||||||
libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL);
|
libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL);
|
||||||
// Unwire the console input: with a tty on fd 0 libkrun puts
|
// Headless only: unwire the console input (with a tty on
|
||||||
// that terminal into raw mode, which fights whoever owns it
|
// fd 0 libkrun puts that terminal into raw mode, which
|
||||||
// (and stops a background caller with SIGTTOU). /dev/null
|
// fights whoever owns it, and stops a background caller
|
||||||
// keeps the console on its plain path and still forwards a
|
// with SIGTTOU). /dev/null keeps the console on its plain
|
||||||
// Ctrl-C sent to the process group into the guest.
|
// 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);
|
let null = libc::open(c"/dev/null".as_ptr(), libc::O_RDWR);
|
||||||
if null >= 0 {
|
if null >= 0 {
|
||||||
libc::dup2(null, libc::STDIN_FILENO);
|
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);
|
libc::signal(libc::SIGTTOU, libc::SIG_IGN);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if !interactive {
|
||||||
if let Err(err) = krun.set_console_output(ctx, &console_path) {
|
if let Err(err) = krun.set_console_output(ctx, &console_path) {
|
||||||
bail(format!("{err:#}"), 125);
|
bail(format!("{err:#}"), 125);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if let Err(err) = krun.start_enter(ctx) {
|
if let Err(err) = krun.start_enter(ctx) {
|
||||||
bail(format!("{err:#}"), 125);
|
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);
|
let reaped = std::sync::atomic::AtomicI32::new(-1);
|
||||||
stream_console(&console_path, child, &reaped)?;
|
stream_console(&console_path, child, &reaped)?;
|
||||||
wait_machine(child, &reaped, &exit_file_host)
|
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
|
/// 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
|
ecrou boots disposable Linux environments as one-shot microVMs on
|
||||||
libkrun: it resolves an image reference, boots one VM per command
|
libkrun: it resolves an image reference, boots one VM per command
|
||||||
with live stdio, propagates the exit code, and throws the machine
|
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.
|
happen.
|
||||||
|
|
||||||
## Confinement
|
## Confinement
|
||||||
|
|||||||
Reference in new issue
Block a user