mirror of
https://github.com/vhaudiquet/ecrou.git
synced 2026-11-12 22:22:44 +00:00
The run path shells out to mkfs.ext4 and debugfs from e2fsprogs; a host without them fails at the first pull, so probe names them up front like it names KVM and the engine.
300 lines
10 KiB
Rust
300 lines
10 KiB
Rust
//! The ecrou CLI: flags onto the library API, exit codes out. All
|
|
//! terminal output lives here; the library stays silent behind `log`
|
|
//! and the progress sink.
|
|
|
|
mod cli;
|
|
mod logger;
|
|
mod progress;
|
|
|
|
use clap::Parser;
|
|
use ecrou::cache::RootfsCache;
|
|
use ecrou::rootfs::PrepareRequest;
|
|
use ecrou::vm::RunOptions;
|
|
use ecrou::{LogLevel, NoProgress, Progress};
|
|
|
|
use cli::{parse_env, parse_volume, Cli, Command, RunArgs};
|
|
use progress::TermProgress;
|
|
|
|
fn main() -> std::process::ExitCode {
|
|
let cli = Cli::parse();
|
|
logger::init(cli.verbose);
|
|
match cli.command {
|
|
Command::Run(args) => cmd_run(&args, cli.verbose),
|
|
Command::Pull { image, no_cache } => cmd_pull(&image, no_cache, cli.verbose),
|
|
Command::Probe => cmd_probe(),
|
|
}
|
|
}
|
|
|
|
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 {
|
|
image: args.image.clone(),
|
|
command: args.command.clone(),
|
|
memory: args.memory.clone(),
|
|
workdir: args.workdir.clone(),
|
|
log_level: if args.quiet {
|
|
LogLevel::Quiet
|
|
} else if cli_verbose {
|
|
LogLevel::Verbose
|
|
} else {
|
|
LogLevel::Normal
|
|
},
|
|
interactive: args.interactive,
|
|
tty: args.tty,
|
|
no_cache: args.no_cache,
|
|
capture: args.capture.clone(),
|
|
..RunOptions::default()
|
|
};
|
|
for spec in &args.env {
|
|
match parse_env(spec) {
|
|
Ok((k, v)) => options.env.push((k, v)),
|
|
Err(err) => return fail(&err),
|
|
}
|
|
}
|
|
let mut shares = Vec::new();
|
|
for spec in &args.volumes {
|
|
match parse_volume(spec) {
|
|
Ok(share) => shares.push(share),
|
|
Err(err) => return fail(&err),
|
|
}
|
|
}
|
|
// The first read-write share is the workspace (its guest path rides
|
|
// into the guest as ECROU_WORKSPACE); everything else is a plain
|
|
// share.
|
|
if let Some(pos) = shares.iter().position(|s| !s.read_only) {
|
|
options.workspace = Some(shares.remove(pos));
|
|
}
|
|
options.shares = shares;
|
|
|
|
let progress: &dyn Progress = if args.quiet {
|
|
&NoProgress
|
|
} else {
|
|
&TermProgress::new()
|
|
};
|
|
match ecrou::vm::run(&options, progress) {
|
|
Ok(code) => std::process::ExitCode::from(code.clamp(0, 255) as u8),
|
|
Err(err) => fail(&anyhow::Error::new(err)),
|
|
}
|
|
}
|
|
|
|
/// Pull an image into the cache without booting it.
|
|
fn cmd_pull(image: &str, no_cache: bool, _cli_verbose: bool) -> std::process::ExitCode {
|
|
let progress: &dyn Progress = &TermProgress::new();
|
|
let dir = match RootfsCache::default_dir() {
|
|
Ok(dir) => dir,
|
|
Err(err) => return fail(&anyhow::Error::new(err)),
|
|
};
|
|
let cache = RootfsCache::new(dir);
|
|
|
|
let mut request = PrepareRequest::new(image.to_owned());
|
|
request.no_cache = no_cache;
|
|
|
|
match cache.pull(&request, progress) {
|
|
Ok(pulled) => {
|
|
if pulled.downloaded {
|
|
println!("pulled {image}");
|
|
} else {
|
|
println!("{image} is up to date");
|
|
}
|
|
println!(" {}", pulled.cache_path.display());
|
|
std::process::ExitCode::SUCCESS
|
|
}
|
|
Err(err) => fail(&anyhow::Error::new(err)),
|
|
}
|
|
}
|
|
|
|
fn cmd_probe() -> std::process::ExitCode {
|
|
use ecrou::krun;
|
|
|
|
let mut ok = true;
|
|
|
|
print!("kvm: ");
|
|
match krun::kvm_probe() {
|
|
Ok(()) => println!("ok"),
|
|
Err(err) => {
|
|
ok = false;
|
|
println!("unavailable\n {:#}", err);
|
|
}
|
|
}
|
|
|
|
print!("libkrun: ");
|
|
match krun::Krun::load() {
|
|
Ok(krun) => {
|
|
println!("ok");
|
|
print!("nested virt: ");
|
|
match krun.check_nested_virt() {
|
|
Ok(true) => println!("available"),
|
|
Ok(false) => println!("not available (only matters for nested runs)"),
|
|
Err(err) => println!("unknown ({err:#})"),
|
|
}
|
|
}
|
|
Err(err) => {
|
|
ok = false;
|
|
println!("not loadable\n {:#}", err);
|
|
}
|
|
}
|
|
|
|
print!("e2fsprogs: ");
|
|
let mut tools_ok = true;
|
|
for tool in ["mkfs.ext4", "debugfs"] {
|
|
match std::process::Command::new(tool).arg("-V").output() {
|
|
Ok(_) => {}
|
|
Err(err) => {
|
|
ok = false;
|
|
tools_ok = false;
|
|
println!("missing {tool} ({err}); the rootfs image cannot be built");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if tools_ok {
|
|
println!("ok");
|
|
}
|
|
|
|
print!("cache: ");
|
|
match ecrou::cache::RootfsCache::default_dir() {
|
|
Ok(dir) => {
|
|
// The run flow creates the cache on demand; probe mirrors it.
|
|
if let Err(err) = std::fs::create_dir_all(&dir) {
|
|
ok = false;
|
|
println!("{} not creatable ({})", dir.display(), err);
|
|
return probe_done(ok);
|
|
}
|
|
let probe = dir.join(".ecrou-probe");
|
|
match std::fs::write(&probe, b"") {
|
|
Ok(()) => {
|
|
let _ = std::fs::remove_file(&probe);
|
|
println!("{} (writable)", dir.display());
|
|
}
|
|
Err(err) => {
|
|
ok = false;
|
|
println!("{} not writable ({})", dir.display(), err);
|
|
}
|
|
}
|
|
}
|
|
Err(err) => {
|
|
ok = false;
|
|
println!("unavailable ({err:#})");
|
|
}
|
|
}
|
|
|
|
probe_done(ok)
|
|
}
|
|
|
|
fn probe_done(ok: bool) -> std::process::ExitCode {
|
|
if ok {
|
|
std::process::ExitCode::SUCCESS
|
|
} else {
|
|
std::process::ExitCode::FAILURE
|
|
}
|
|
}
|
|
|
|
fn fail(err: &anyhow::Error) -> std::process::ExitCode {
|
|
eprintln!("Error: {err:#}");
|
|
std::process::ExitCode::FAILURE
|
|
}
|
|
|
|
/// T6, the founding rule as a test: strace a full run and fail on any
|
|
/// denied syscall. Needs the built binary, libkrun, and strace, so it
|
|
/// runs in CI (`cargo test -- --ignored`).
|
|
#[cfg(test)]
|
|
mod tests {
|
|
#[test]
|
|
fn traced_syscall_parses_strace_line_shapes() {
|
|
assert_eq!(
|
|
traced_syscall("1234 mount(\"/x\", \"/y\", 0) = 0"),
|
|
Some("mount")
|
|
);
|
|
assert_eq!(
|
|
traced_syscall("[pid 1234] umount2(\"/x\", 0) = 0"),
|
|
Some("umount2")
|
|
);
|
|
assert_eq!(
|
|
traced_syscall("1234 mount(\"/x\" <unfinished ...>"),
|
|
Some("mount")
|
|
);
|
|
// A resumed split half carries no call of its own: its '('
|
|
// was on the unfinished line above, which already named the
|
|
// syscall for the audit.
|
|
assert_eq!(
|
|
traced_syscall("[pid 1234] <... mount resumed>: \"/x\") = 0"),
|
|
None
|
|
);
|
|
assert_eq!(traced_syscall("strace: Process 1234 attached"), None);
|
|
assert_eq!(traced_syscall("++++ exited with 0 ++++"), None);
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "needs the ecrou binary, libkrun, and strace; runs in CI"]
|
|
fn t6_no_mount_or_namespace_syscalls() {
|
|
let denied = "mount,umount2,unshare,setns,pivot_root,open_tree,move_mount,fsopen,fsmount";
|
|
// CARGO_BIN_EXE_* exists only for integration tests, which the
|
|
// conventions forbid; CI points ECROU_BIN at the built binary.
|
|
let exe = std::env::var("ECROU_BIN")
|
|
.expect("T6 needs ECROU_BIN pointing at the built ecrou binary");
|
|
let trace_path = std::env::temp_dir().join("ecrou-t6.strace");
|
|
let out = std::process::Command::new("strace")
|
|
.args([
|
|
"-f",
|
|
"-e",
|
|
&format!("trace={denied}"),
|
|
"-o",
|
|
trace_path.to_str().unwrap(),
|
|
&exe,
|
|
"run",
|
|
"alpine:3.23",
|
|
"--",
|
|
"true",
|
|
])
|
|
.status()
|
|
.expect("strace run");
|
|
assert!(out.success(), "the traced run failed; see the strace log");
|
|
let trace = std::fs::read_to_string(&trace_path).unwrap();
|
|
let _ = std::fs::remove_file(&trace_path);
|
|
// The SPEC's bar is zero hits, not zero failures: a mount that
|
|
// succeeded is as much a violation as one the snap's seccomp
|
|
// allowlist would have denied. The trace contains only the
|
|
// traced syscalls, so an exact name at the head of a line is a
|
|
// hit; exact comparison also keeps `mount` from matching
|
|
// `umount2`. Resumed-split lines (`<... mount resumed>`) carry
|
|
// the name behind a `<... ` prefix.
|
|
let denied: Vec<&str> = denied.split(',').collect();
|
|
for line in trace.lines() {
|
|
let head = traced_syscall(line);
|
|
if let Some(head) = head {
|
|
if denied.contains(&head) {
|
|
panic!("denied syscall in the trace: {line}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The syscall name a strace output line traces, or None for
|
|
/// chatter. Line shapes covered: `1234 mount(…)`,
|
|
/// `[pid 1234] umount2(…)`, unfinished split halves, timestamps,
|
|
/// and lines without a call (`++++ exited with 0 ++++`). The call
|
|
/// is the last identifier-like token before the parenthesis; a
|
|
/// pid or a timestamp never qualifies (digits alone or
|
|
/// punctuation). A resumed split half has no parenthesis — the
|
|
/// unfinished half that names the call always precedes it, so
|
|
/// None loses the audit nothing.
|
|
fn traced_syscall(line: &str) -> Option<&str> {
|
|
let open = line.find('(')?;
|
|
let tokens: Vec<&str> = line[..open].split_whitespace().collect();
|
|
let is_call = |t: &str| {
|
|
!t.is_empty()
|
|
&& t.chars()
|
|
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
|
|
};
|
|
tokens.iter().rev().find(|t| is_call(t)).copied()
|
|
}
|
|
}
|