use std::os::fd::AsRawFd;
use anyhow::Context;
use zygo_core::backend;
use zygo_core::image::{PullProgress, Reference, RegistryClient, Store};
use zygo_core::sandbox::{RootfsView, SandboxConfig};
use zygo_core::spec::Spec;
use crate::cli::{Cli, PullPolicy, RunArgs};
use crate::output::{self, Style};
use crate::tty;
pub fn run(cli: &Cli, args: &RunArgs) -> anyhow::Result<u8> {
let phase = std::cell::Cell::new(Phase::Plan);
let result = run_in_phases(cli, args, &phase);
if let (Err(e), Some(path)) = (&result, &args.outcome)
&& phase.get() != Phase::Run
{
let code = e
.chain()
.find_map(|cause| cause.downcast_ref::<zygo_core::Error>())
.map(|e| e.exit_code())
.unwrap_or(1);
let _ = Outcome::never_started(phase.get(), code).write(path);
}
result
}
fn run_in_phases(cli: &Cli, args: &RunArgs, phase: &std::cell::Cell<Phase>) -> anyhow::Result<u8> {
let planning = std::time::Instant::now();
let overrides = args.to_layer()?;
let options = zygo_core::spec::ResolveOptions {
one_shot: true,
..args.sandbox.resolve_options()
};
let spec = Spec::discover(args.spec_file.path())?.unwrap_or_default();
let resolved = spec.resolve(None, &overrides, &options)?;
let say = |message: &str| {
if !args.quiet {
eprintln!("{message}");
}
};
let warn = |message: &str| {
if !args.quiet {
output::warn(message);
}
};
for w in &resolved.warnings {
warn(w);
}
let paths = super::paths(cli);
paths.ensure()?;
let store = Store::new(paths);
let reference: Reference = match resolved.image.parse() {
Ok(r) => r,
Err(e) => match mount_pair(&resolved.image) {
Some((host, guest)) => anyhow::bail!(
"`{}` is a mount, not an image\n \
→ `-v` means verbose in Zygo, not volume; \
use: zygo run --mount {host}:{guest} <image> …",
resolved.image
),
None => {
return Err(anyhow::Error::new(e))
.with_context(|| format!("cannot use image `{}`", resolved.image));
}
},
};
let entry = match (store.get(&reference), args.pull) {
(Some(e), PullPolicy::Missing | PullPolicy::Never) => e,
(None, PullPolicy::Never) => {
anyhow::bail!(
"`{}` is not in the store, and --pull never was given\n \
→ pull it first: zygo pull {}",
resolved.image,
resolved.image
);
}
(_, _) => {
let client = RegistryClient::new(store.clone())?;
let style = Style::stdout();
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
runtime.block_on(client.pull(&reference, |event| {
if let PullProgress::Resolving { reference } = event {
say(&format!("{} {reference}", style.dim("pulling")));
}
}))?
}
};
#[cfg(target_os = "linux")]
if !args.dry_run
&& !args.tty
&& resolved.isolation == zygo_core::spec::Isolation::Ns
&& let Ok(client) = zygo_core::supervisor::client::Client::connect(store.paths())
{
return run_through_supervisor(
cli,
args,
client,
&Plan {
spec: &spec,
layer: &overrides,
options: &options,
resolved: &resolved,
started: planning,
},
phase,
);
}
let entry = if resolved.system.is_empty() {
entry
} else {
let derived = zygo_core::derive::ensure(&store, &entry, &resolved.system)?;
if derived.built {
say(&format!(
"{} {}",
Style::stdout().dim("installed"),
derived.versions.join(" ")
));
}
derived.image
};
let mut resolved = resolved;
let venv = match &resolved.requirements {
Some(requirements) => {
anyhow::ensure!(
requirements.is_file(),
"`{}` does not exist\n → --requirements takes a path to a \
requirements file, resolved against the working directory",
requirements.display()
);
let venv = zygo_core::venv::ensure(&store, &entry, requirements)?;
if venv.built {
say(&format!(
"{} {}",
Style::stdout().dim("built venv"),
venv.dir.display()
));
}
resolved.mounts.push(venv.mount());
Some(venv)
}
None => None,
};
let entry = if args.dry_run {
entry
} else {
let compiled = zygo_core::bytecode::ensure(&store, &entry)?;
if compiled.built {
say(&format!(
"{} {}",
Style::stdout().dim("compiled bytecode for"),
entry.reference
));
}
compiled.image
};
let net = zygo_core::net::setup(store.paths(), &resolved.name, &resolved)?;
if let Some(mount) = net.mount.clone() {
resolved.mounts.push(mount);
}
for w in &net.warnings {
warn(w);
}
let resolved = resolved;
let host = zygo_core::doctor::cached(store.paths());
let overlay_supported = !matches!(
resolved.isolation,
zygo_core::spec::Isolation::Gvisor | zygo_core::spec::Isolation::Vm
) && host
.checks
.iter()
.any(|c| c.name == "overlayfs (userns)" && c.status == zygo_core::doctor::Status::Ok);
let mount_points = zygo_core::sandbox::mount::required_mount_points(&resolved.mounts);
let view = store.rootfs_view(&entry.layers, overlay_supported, &mount_points)?;
let newroot = store
.paths()
.tmp()
.join(format!("root-{}", zygo_core::process_token()));
std::fs::create_dir_all(&newroot)?;
let newroot = Scratch(newroot);
let newroot = &newroot.0;
let image_config: zygo_core::image::ImageConfig =
serde_json::from_slice(&store.read_blob(&entry.config)?)
.context("the image's config in the store is not valid JSON")?;
let argv = if resolved.cmd.is_empty() {
let argv = image_config.default_argv();
anyhow::ensure!(
!argv.is_empty(),
"`{}` declares no entrypoint or cmd\n → give a command: zygo run {} <command>",
resolved.image,
resolved.image
);
argv
} else {
resolved.cmd.clone()
};
let mut env = image_config.env_pairs();
if venv.is_some() {
let image_path = env
.iter()
.find(|(k, _)| k == "PATH")
.map(|(_, v)| v.clone())
.unwrap_or_default();
env.retain(|(k, _)| k != "PATH" && k != "VIRTUAL_ENV");
env.extend(zygo_core::venv::Venv::env_over(&image_path));
}
let mut config = SandboxConfig::from_resolved(&resolved, &view, newroot, argv, &env);
config.allow_resolved = net.allowed;
config.pasta_pid_file = net.pid_file;
if args.dry_run {
let seccomp_source = spec.seccomp_source(None, &overrides);
return print_plan(cli, &resolved, seccomp_source, &host, &view, &config);
}
let mut terminal = None;
let mut raw_mode = None;
if args.tty {
let pty = tty::open()?;
tty::copy_window_size(std::io::stdin().as_raw_fd(), pty.slave.as_raw_fd());
raw_mode = tty::RawMode::enable(std::io::stdin().as_raw_fd())?;
config.stdio = Some(pty.slave.as_raw_fd());
terminal = Some(pty);
}
let backend = backend::for_isolation(resolved.isolation, store.paths())?;
let plan_ms = planning.elapsed().as_secs_f64() * 1000.0;
let starting = std::time::Instant::now();
phase.set(Phase::Start);
let mut sandbox = backend.start(&config)?;
phase.set(Phase::Run);
let start_ms = starting.elapsed().as_secs_f64() * 1000.0;
let relay = terminal.map(|pty| {
drop(pty.slave);
tty::relay(pty.master)
});
forward_signals(sandbox.pid());
let started = std::time::Instant::now();
let waited = sandbox.wait();
let kernel = sandbox.outcome();
if let Some(handle) = relay {
let _ = handle.join();
}
drop(raw_mode);
let outcome = Outcome {
exit_code: match &waited {
Ok(code) => (*code).clamp(0, 255),
Err(e) => e.exit_code(),
},
timed_out: waited
.as_ref()
.err()
.is_some_and(zygo_core::Error::timed_out),
oom_killed: kernel.oom_kills > 0,
peak_rss_kb: kernel.peak_rss_kb,
wall_ms: started.elapsed().as_secs_f64() * 1000.0,
plan_ms,
start_ms,
started: true,
phase: Phase::Run,
};
if let Some(path) = &args.outcome {
outcome.write(path)?;
}
if cli.verbose > 0 && !args.quiet {
eprintln!(
"timing: plan {plan_ms:.1} ms, start {start_ms:.1} ms, run {:.1} ms",
outcome.wall_ms
);
}
let code = waited?;
Ok(code.clamp(0, 255) as u8)
}
#[cfg(target_os = "linux")]
struct Plan<'a> {
spec: &'a Spec,
layer: &'a zygo_core::spec::Layer,
options: &'a zygo_core::spec::ResolveOptions,
resolved: &'a zygo_core::spec::ResolvedFn,
started: std::time::Instant,
}
#[cfg(target_os = "linux")]
fn run_through_supervisor(
cli: &Cli,
args: &RunArgs,
mut client: zygo_core::supervisor::client::Client,
plan: &Plan<'_>,
phase: &std::cell::Cell<Phase>,
) -> anyhow::Result<u8> {
use zygo_core::supervisor::protocol::{Request, Response};
let Plan {
spec,
layer,
options,
resolved,
started: planning,
} = *plan;
let request = Request::Run {
spec: Some(Box::new(spec.clone())),
layer: Box::new(layer.clone()),
base_dir: std::path::absolute(spec.base_dir())?,
allow_host_net: options.allow_host_net,
allow_private_net: options.allow_private_net,
allow_unlimited: options.allow_unlimited,
tty: false,
ignored_signals: ignored_signals(),
};
let plan_ms = planning.elapsed().as_secs_f64() * 1000.0;
let starting = std::time::Instant::now();
phase.set(Phase::Start);
let pid = client.run_start(&request, [0, 1, 2])?;
phase.set(Phase::Run);
let start_ms = starting.elapsed().as_secs_f64() * 1000.0;
forward_signals(pid);
let deadline = resolved.limits.timeout.get();
let budget = (!deadline.is_zero()).then(|| deadline + std::time::Duration::from_secs(30));
let ran = client.run_wait(budget)?;
let Response::Ran {
exit_code,
timed_out,
oom_killed,
peak_rss_kb,
wall_ms,
} = ran
else {
anyhow::bail!("the supervisor answered {ran:?} rather than RAN");
};
let outcome = Outcome {
exit_code,
timed_out,
oom_killed,
peak_rss_kb,
wall_ms,
plan_ms,
start_ms,
started: true,
phase: Phase::Run,
};
if let Some(path) = &args.outcome {
outcome.write(path)?;
}
if cli.verbose > 0 && !args.quiet {
eprintln!(
"timing: plan {plan_ms:.1} ms, start {start_ms:.1} ms, run {wall_ms:.1} ms \
(through the supervisor)"
);
}
if timed_out && !args.quiet {
eprintln!(
"error: the sandbox exceeded its {:?} deadline and was killed",
resolved.limits.timeout.get()
);
}
Ok(exit_code.clamp(0, 255) as u8)
}
#[cfg(target_os = "linux")]
fn ignored_signals() -> u64 {
let mut mask = 0u64;
for signal in 1..=64 {
let mut action: libc::sigaction = unsafe { std::mem::zeroed() };
let queried = unsafe { libc::sigaction(signal, std::ptr::null(), &mut action) } == 0;
if queried && action.sa_sigaction == libc::SIG_IGN {
mask |= 1u64 << (signal - 1);
}
}
mask
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "lowercase")]
enum Phase {
Plan,
Start,
Run,
}
#[derive(serde::Serialize)]
struct Outcome {
exit_code: i32,
timed_out: bool,
oom_killed: bool,
peak_rss_kb: u64,
wall_ms: f64,
plan_ms: f64,
start_ms: f64,
started: bool,
phase: Phase,
}
impl Outcome {
fn never_started(phase: Phase, exit_code: i32) -> Outcome {
Outcome {
exit_code,
timed_out: false,
oom_killed: false,
peak_rss_kb: 0,
wall_ms: 0.0,
plan_ms: 0.0,
start_ms: 0.0,
started: false,
phase,
}
}
fn write(&self, path: &std::path::Path) -> anyhow::Result<()> {
let temporary = path.with_extension("outcome-tmp");
std::fs::write(&temporary, serde_json::to_vec(self)?)
.with_context(|| format!("cannot write {}", temporary.display()))?;
std::fs::rename(&temporary, path)
.with_context(|| format!("cannot write {}", path.display()))?;
Ok(())
}
}
struct Scratch(std::path::PathBuf);
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir(&self.0);
}
}
#[cfg(unix)]
fn forward_signals(pid: u32) {
use std::sync::atomic::{AtomicU32, Ordering};
static TARGET: AtomicU32 = AtomicU32::new(0);
static DELIVERED: AtomicU32 = AtomicU32::new(0);
TARGET.store(pid, Ordering::SeqCst);
extern "C" fn relay(signal: i32) {
let pid = TARGET.load(Ordering::SeqCst) as libc::pid_t;
if pid == 0 {
return;
}
unsafe {
if DELIVERED.fetch_add(1, Ordering::SeqCst) > 0 {
libc::kill(pid, libc::SIGKILL);
return;
}
libc::kill(pid, signal);
libc::kill(-pid, signal);
}
}
for signal in [libc::SIGINT, libc::SIGTERM, libc::SIGHUP] {
let previous = unsafe { libc::signal(signal, relay as *const () as libc::sighandler_t) };
if previous == libc::SIG_IGN {
unsafe { libc::signal(signal, libc::SIG_IGN) };
}
}
}
#[cfg(not(unix))]
fn forward_signals(_pid: u32) {}
fn mount_pair(s: &str) -> Option<(&str, &str)> {
let (host, guest) = s.split_once(':')?;
let host_is_a_path = host.starts_with('/')
|| host.starts_with('.')
|| host.starts_with('~')
|| host.contains('/');
(guest.starts_with('/') && host_is_a_path).then_some((host, guest))
}
fn allowed_syscalls(profile: zygo_core::spec::SeccompProfile) -> Option<usize> {
#[cfg(target_os = "linux")]
{
Some(zygo_core::backend::ns::seccomp::allowed_names(profile).len())
}
#[cfg(not(target_os = "linux"))]
{
let _ = profile;
None
}
}
fn landlock_line(host: &zygo_core::doctor::Report) -> (bool, String) {
match host.checks.iter().find(|c| c.name == "landlock") {
Some(c) if c.status == zygo_core::doctor::Status::Ok => (true, c.detail.clone()),
Some(c) => (false, format!("{}; seccomp still applies", c.detail)),
None => (false, "not probed on this host".to_string()),
}
}
fn print_plan(
cli: &Cli,
resolved: &zygo_core::spec::ResolvedFn,
seccomp_source: zygo_core::spec::SeccompSource,
host: &zygo_core::doctor::Report,
view: &RootfsView,
config: &SandboxConfig,
) -> anyhow::Result<u8> {
let (landlock_on, landlock) = landlock_line(host);
if cli.json {
let mut value = super::spec::to_json(resolved);
value["seccomp"]["source"] = serde_json::json!(seccomp_source);
value["seccomp"]["allowed_syscalls"] =
serde_json::json!(allowed_syscalls(resolved.seccomp));
value["landlock"] = serde_json::json!({
"enabled": landlock_on,
"detail": landlock,
});
value["argv"] = serde_json::json!(config.argv);
value["rootfs"] = match view {
RootfsView::Overlay { lower } => serde_json::json!({
"kind": "overlay",
"lower": lower.iter().map(|p| p.display().to_string()).collect::<Vec<_>>(),
}),
RootfsView::Flat { dir } => serde_json::json!({
"kind": "flat",
"dir": dir.display().to_string(),
}),
};
value["mounts_plan"] = serde_json::json!(config.mounts.describe());
value["cgroup"] = serde_json::json!(
resolved
.limits
.cgroup_writes()
.iter()
.map(|w| format!("{} = {}", w.file, w.value))
.collect::<Vec<_>>()
);
output::json(&value)?;
return Ok(0);
}
let style = Style::stdout();
println!("{}", style.bold("argv"));
println!(" {}", config.argv.join(" "));
println!("{}", style.bold("rootfs"));
match view {
RootfsView::Overlay { lower } => {
println!(" overlay, read-only, {} layers", lower.len());
for dir in lower {
println!(" {}", style.dim(&dir.display().to_string()));
}
}
RootfsView::Flat { dir } => println!(" flattened: {}", dir.display()),
}
println!("{}", style.bold("mounts"));
for line in config.mounts.describe() {
println!(" {line}");
}
println!("{}", style.bold("network"));
println!(" {}", resolved.network);
for rule in &resolved.allow {
println!(" {} {rule}", style.dim("allow"));
}
println!("{}", style.bold("seccomp"));
println!(
" {} {}{}",
resolved.seccomp,
style.dim(&format!("(from {seccomp_source})")),
match allowed_syscalls(resolved.seccomp) {
Some(n) => format!(", {n} syscalls allowed"),
None => String::new(),
}
);
println!("{}", style.bold("landlock"));
println!(" {landlock}");
println!("{}", style.bold("cgroup"));
for w in resolved.limits.cgroup_writes() {
println!(" {} = {}", w.file, w.value);
}
println!(
" {} {} ({})",
style.dim("timeout"),
resolved.limits.timeout,
style.dim("supervisor timer + cgroup.kill")
);
println!("{}", style.bold("writable paths"));
for p in config.mounts.writable_targets() {
println!(" {}", p.display());
}
Ok(0)
}
#[cfg(test)]
mod tests {
use super::{Outcome, Phase, mount_pair};
#[test]
fn an_outcome_that_never_started_says_so_and_where() {
let never = serde_json::to_value(Outcome::never_started(Phase::Start, 125)).unwrap();
assert_eq!(never["started"], false);
assert_eq!(never["phase"], "start");
assert_eq!(never["exit_code"], 125);
assert_eq!(never["timed_out"], false);
assert_eq!(serde_json::to_value(Phase::Plan).unwrap(), "plan");
assert_eq!(serde_json::to_value(Phase::Run).unwrap(), "run");
}
#[test]
fn a_volume_pair_is_told_apart_from_an_image_reference() {
for s in [
"/Users/m/proj:/src",
"/Users/m/proj:/src:ro",
".:/app",
"./data:/data",
"~/proj:/src",
"sub/dir:/mnt",
] {
assert!(mount_pair(s).is_some(), "`{s}` is a mount");
}
for s in [
"alpine:3",
"python:3.12-slim",
"localhost:5000/team/app",
"localhost:5000/team/app:v2",
"ghcr.io/o/r@sha256:abc",
"alpine",
] {
assert!(mount_pair(s).is_none(), "`{s}` is an image");
}
}
#[test]
fn the_pair_comes_back_split_for_the_suggestion() {
assert_eq!(mount_pair("/p:/src"), Some(("/p", "/src")));
assert_eq!(mount_pair("/p:/src:ro"), Some(("/p", "/src:ro")));
}
}