use std::ffi::OsString;
use std::path::{Path, PathBuf};
use zygo_core::spec::Mount;
use crate::cli::{AgentCommand, Cli, Command};
pub const INSTANCE: &str = "zygo";
pub const EXIT_VM_UNREACHABLE: u8 = 111;
pub const TRANSPORT_RETRIES: u32 = 2;
pub fn transport_backoff(attempt: u32) -> std::time::Duration {
std::time::Duration::from_millis(100 * u64::from(attempt) * u64::from(attempt) + 100)
}
pub fn is_transport_failure(line: &str) -> bool {
line.contains("mux_client_request_session")
|| line.contains("Session open refused by peer")
|| line.contains("mux_client_request_session: session request failed")
}
pub fn is_vm_unreachable(line: &str) -> bool {
(line.contains("ssh: connect to host")
&& (line.contains("Connection refused") || line.contains("Operation timed out")))
|| line.contains("kex_exchange_identification")
|| line.contains("ssh_exchange_identification")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Transport {
Ran,
SessionRefused,
VmUnreachable,
}
pub const UNREACHABLE_RETRIES: u32 = 4;
pub const TEMPLATE_GENERATION: u32 = 2;
pub const TEMPLATE_GENERATION_MARKER: &str = "# zygo-template-generation:";
pub fn template_generation_of(text: &str) -> Option<u32> {
text.lines()
.find_map(|l| l.trim().strip_prefix(TEMPLATE_GENERATION_MARKER))
.and_then(|rest| rest.trim().parse().ok())
}
pub const GUEST_NEUTRAL_DIR: &str = "/";
pub fn runs_on_the_host(command: &Command) -> bool {
if let Command::Api(args) = command {
return args.openapi;
}
matches!(
command,
Command::Completion { .. }
| Command::Doctor { .. }
| Command::Agent(AgentCommand::Test { .. })
)
}
#[derive(Debug, PartialEq, Eq)]
pub struct Unmapped {
pub cwd: PathBuf,
pub home: PathBuf,
pub needed_by: String,
}
impl std::fmt::Display for Unmapped {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"on macOS Zygo runs in a Linux VM, and only {} is shared with it — \
but this command was run from {}, and {}",
self.home.display(),
self.cwd.display(),
self.needed_by
)
}
}
pub fn workdir(cwd: &Path, home: &Path, dependency: Option<String>) -> Result<PathBuf, Unmapped> {
if cwd.starts_with(home) {
return Ok(cwd.to_path_buf());
}
match dependency {
None => Ok(PathBuf::from(GUEST_NEUTRAL_DIR)),
Some(needed_by) => Err(Unmapped {
cwd: cwd.to_path_buf(),
home: home.to_path_buf(),
needed_by,
}),
}
}
pub fn host_paths(command: &Command) -> Vec<(&'static str, PathBuf)> {
let mut out: Vec<(&'static str, PathBuf)> = Vec::new();
let mounts = |out: &mut Vec<(&'static str, PathBuf)>, mounts: &[Mount]| {
out.extend(mounts.iter().map(|m| ("the mount", m.source.clone())));
};
match command {
Command::Run(args) => {
mounts(&mut out, &args.sandbox.mounts);
out.extend(args.spec_file.file.clone().map(|p| ("the spec file", p)));
out.extend(
args.requirements
.clone()
.map(|p| ("the requirements file", p)),
);
out.extend(args.outcome.clone().map(|p| ("the outcome file", p)));
}
Command::Serve(args) => {
out.extend(args.handler.clone().map(|p| ("the handler", p)));
mounts(&mut out, &args.sandbox.mounts);
out.extend(args.spec_file.file.clone().map(|p| ("the spec file", p)));
out.extend(
args.requirements
.clone()
.map(|p| ("the requirements file", p)),
);
}
Command::Exec(args) => {
if let Some(script) = &args.script
&& !script.starts_with("sha256:")
{
out.push(("the script", PathBuf::from(script)));
}
}
Command::Up { file, .. } | Command::Down { file } | Command::Spec { file, .. } => {
out.extend(file.file.clone().map(|p| ("the spec file", p)));
}
Command::Api(args) => {
out.extend(args.spec_file.file.clone().map(|p| ("the spec file", p)));
}
Command::Mcp(args) => {
out.extend(args.workspace.clone().map(|p| ("the workspace", p)));
out.extend(args.spec_file.file.clone().map(|p| ("the spec file", p)));
mounts(&mut out, &args.sandbox.mounts);
}
_ => {}
}
out
}
pub fn discovers_a_spec(command: &Command) -> bool {
match command {
Command::Run(args) => args.spec_file.file.is_none(),
Command::Serve(args) => args.spec_file.file.is_none(),
Command::Api(args) => args.spec_file.file.is_none(),
Command::Mcp(args) => args.spec_file.file.is_none(),
Command::Up { file, .. } | Command::Down { file } | Command::Spec { file, .. } => {
file.file.is_none()
}
_ => false,
}
}
pub fn cwd_dependency(command: &Command, spec_here: Option<&Path>) -> Option<String> {
for (what, path) in host_paths(command) {
if path.is_relative() {
return Some(format!(
"{what} `{}` is relative to that directory",
path.display()
));
}
}
if discovers_a_spec(command)
&& let Some(found) = spec_here
{
return Some(format!(
"`{}` was found by searching upwards from it (pass -f to name it instead)",
found.display()
));
}
None
}
#[cfg(target_os = "macos")]
fn spec_discoverable_here() -> Option<PathBuf> {
let mut dir = std::env::current_dir().ok();
while let Some(d) = dir {
let candidate = d.join("sandbox.toml");
if candidate.is_file() {
return Some(candidate);
}
dir = d.parent().map(Path::to_path_buf);
}
None
}
pub fn unmapped_paths(command: &Command, cwd: &Path, home: &Path) -> Vec<(&'static str, PathBuf)> {
host_paths(command)
.into_iter()
.map(|(what, path)| {
let resolved = if path.is_absolute() {
path
} else {
cwd.join(path)
};
(what, resolved)
})
.filter(|(_, path)| !path.starts_with(home))
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Vm {
Running,
Stopped,
Absent,
}
impl Vm {
pub fn parse(stdout: &str) -> Vm {
match stdout.trim().lines().next().map(str::trim) {
Some("Running") => Vm::Running,
Some("Stopped") | Some("Broken") => Vm::Stopped,
_ => Vm::Absent,
}
}
}
pub fn status_args() -> Vec<OsString> {
["list", "--format", "{{.Status}}", INSTANCE]
.iter()
.map(OsString::from)
.collect()
}
pub const GUEST_BIN: &str = "/usr/local/bin/zygo";
const GUEST_STAGE: &str = "/tmp/zygo.incoming";
pub fn copy_args(source: &Path) -> Vec<OsString> {
vec![
"copy".into(),
source.into(),
format!("{INSTANCE}:{GUEST_STAGE}").into(),
]
}
pub fn install_args() -> Vec<OsString> {
[
"shell",
INSTANCE,
"sudo",
"install",
"-m",
"0755",
GUEST_STAGE,
GUEST_BIN,
]
.iter()
.map(OsString::from)
.collect()
}
pub fn build_stamp(len: u64, modified_secs: u64, instance: u64) -> String {
format!("{len}-{modified_secs}-{instance}")
}
#[cfg(target_os = "macos")]
fn instance_generation() -> u64 {
let Some(home) = std::env::var_os("HOME") else {
return 0;
};
let config = PathBuf::from(home)
.join(".lima")
.join(INSTANCE)
.join("lima.yaml");
std::fs::metadata(config)
.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)
}
pub fn start_args(vm: Vm, template: &Path) -> Vec<OsString> {
let mut args: Vec<OsString> = vec!["start".into(), "--tty=false".into()];
if vm == Vm::Absent {
args.push("--name".into());
args.push(INSTANCE.into());
args.push(template.into());
} else {
args.push(INSTANCE.into());
}
args
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn forward_args<I, S>(workdir: &Path, args: I) -> Vec<OsString>
where
I: IntoIterator<Item = S>,
S: Into<OsString>,
{
forward_args_with_env(workdir, args, &[])
}
pub fn forward_args_with_env<I, S>(
workdir: &Path,
args: I,
env: &[(OsString, OsString)],
) -> Vec<OsString>
where
I: IntoIterator<Item = S>,
S: Into<OsString>,
{
let mut out: Vec<OsString> = vec![
"shell".into(),
"--workdir".into(),
workdir.into(),
INSTANCE.into(),
];
if !env.is_empty() {
out.push("env".into());
for (k, v) in env {
let mut pair = k.clone();
pair.push("=");
pair.push(v);
out.push(pair);
}
}
out.push("zygo".into());
out.extend(args.into_iter().map(Into::into));
out
}
pub fn ssh_config(home: &Path) -> Option<PathBuf> {
let lima = std::env::var_os("LIMA_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| home.join(".lima"));
let config = lima.join(INSTANCE).join("ssh.config");
config.is_file().then_some(config)
}
#[cfg(target_os = "macos")]
fn mux_alive(config: &Path) -> bool {
std::process::Command::new("ssh")
.args(["-F"])
.arg(config)
.args(["-O", "check", &format!("lima-{INSTANCE}")])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
pub fn ssh_args<I, S>(
config: &Path,
workdir: &Path,
args: I,
env: &[(OsString, OsString)],
tty: bool,
) -> Vec<OsString>
where
I: IntoIterator<Item = S>,
S: Into<OsString>,
{
let mut remote = OsString::from("cd ");
remote.push(sh_quote(workdir.as_os_str()));
remote.push(" && ");
if !env.is_empty() {
remote.push("env ");
for (k, v) in env {
let mut pair = k.clone();
pair.push("=");
pair.push(v);
remote.push(sh_quote(&pair));
remote.push(" ");
}
}
remote.push(GUEST_BIN);
for arg in args {
remote.push(" ");
remote.push(sh_quote(&arg.into()));
}
let mut out: Vec<OsString> = vec!["-F".into(), config.into()];
if tty {
out.push("-t".into());
}
out.push(format!("lima-{INSTANCE}").into());
out.push("--".into());
out.push(remote);
out
}
pub fn sh_quote(word: &std::ffi::OsStr) -> OsString {
use std::os::unix::ffi::{OsStrExt, OsStringExt};
let bytes = word.as_bytes();
if !bytes.is_empty()
&& bytes
.iter()
.all(|b| b.is_ascii_alphanumeric() || b"-_./=:@%+,".contains(b))
{
return word.to_os_string();
}
let mut out = Vec::with_capacity(bytes.len() + 2);
out.push(b'\'');
for &b in bytes {
if b == b'\'' {
out.extend_from_slice(b"'\\''");
} else {
out.push(b);
}
}
out.push(b'\'');
OsString::from_vec(out)
}
pub fn is_forwardable_env_name(name: &str) -> bool {
!name.is_empty()
&& !name.starts_with(|c: char| c.is_ascii_digit())
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
pub fn host_checks(
limactl: Option<&Path>,
template: Option<&Path>,
linux_binary: Option<&Path>,
vm: Vm,
generation: Option<u32>,
home: Option<&Path>,
) -> Vec<zygo_core::doctor::Check> {
use zygo_core::doctor::Check;
let mut checks = Vec::new();
checks.push(match limactl {
Some(path) => Check::ok("limactl", format!("at {}", path.display())),
None => Check::failed(
"limactl",
"not installed, so there is no Linux VM to run sandboxes in",
"brew install lima",
),
});
checks.push(match template {
Some(path) => Check::ok("vm template", path.display().to_string()),
None => Check::failed(
"vm template",
"missing; Zygo cannot create the Linux VM without it",
"set ZYGO_LIMA_TEMPLATE to a Lima template, or reinstall Zygo",
),
});
checks.push(match linux_binary {
Some(path) => Check::ok("linux build", path.display().to_string()),
None => Check::degraded(
"linux build",
"none on this Mac; the VM keeps whatever it already has",
"in a checkout: make guest-build (no Docker) or make poc/zygo-linux-musl; otherwise set ZYGO_LINUX_BIN",
),
});
checks.push(match vm {
Vm::Running => Check::ok("vm", format!("instance `{INSTANCE}` is running")),
Vm::Stopped => Check::degraded(
"vm",
format!("instance `{INSTANCE}` is stopped"),
"it starts by itself on the next command",
),
Vm::Absent => Check::degraded(
"vm",
format!("instance `{INSTANCE}` does not exist yet"),
"it is created by the first command that needs it",
),
});
if vm != Vm::Absent {
checks.push(match generation {
Some(have) if have >= TEMPLATE_GENERATION => {
Check::ok("vm template generation", format!("{have}"))
}
have => Check::degraded(
"vm template generation",
format!(
"the VM was created from generation {} of the template; this release ships {TEMPLATE_GENERATION}",
have.map(|g| g.to_string()).unwrap_or_else(|| "0".into())
),
format!(
"`limactl delete {INSTANCE}` — the next command recreates it (about a minute; \
nothing under $HOME is lost) — or apply the change by hand: \
docs/book/22-troubleshooting.md, \"Session open refused by peer\""
),
),
});
}
if let Some(home) = home {
checks.push(Check::ok(
"shared directory",
format!("{} is mounted in the VM at the same path", home.display()),
));
}
if vm != Vm::Running {
checks.push(Check::failed(
"sandboxes",
"nothing can be said about them while the VM is not running",
"run any zygo command to start it, then `zygo doctor` again",
));
}
checks
}
#[cfg(target_os = "macos")]
pub struct VmReport {
pub host: Vec<zygo_core::doctor::Check>,
pub guest: Option<Result<crate::cmd::doctor::JsonReport, String>>,
}
#[cfg(target_os = "macos")]
pub fn describe_vm() -> VmReport {
let limactl = which("limactl");
let vm = limactl
.as_ref()
.and_then(|l| {
std::process::Command::new(l)
.args(status_args())
.output()
.ok()
})
.map(|out| Vm::parse(&String::from_utf8_lossy(&out.stdout)))
.unwrap_or(Vm::Absent);
let generation = instance_template()
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|text| template_generation_of(&text));
let home = std::env::var_os("HOME").map(PathBuf::from);
let tidy = |p: PathBuf| p.canonicalize().unwrap_or(p);
let host = host_checks(
limactl.as_deref(),
template_path().ok().map(tidy).as_deref(),
linux_binary().map(tidy).as_deref(),
vm,
generation,
home.as_deref(),
);
let guest = match (&limactl, vm) {
(Some(limactl), Vm::Running) => Some(
std::process::Command::new(limactl)
.args(["shell", INSTANCE, "zygo", "doctor", "--json"])
.output()
.map_err(|e| e.to_string())
.and_then(|out| {
serde_json::from_slice::<crate::cmd::doctor::JsonReport>(&out.stdout).map_err(
|_| {
let mut text = String::from_utf8_lossy(&out.stdout).into_owned();
text.push_str(&String::from_utf8_lossy(&out.stderr));
text.trim().to_string()
},
)
}),
),
_ => None,
};
VmReport { host, guest }
}
#[cfg(target_os = "macos")]
fn instance_template() -> Option<PathBuf> {
let home = std::env::var_os("HOME")?;
Some(
PathBuf::from(home)
.join(".lima")
.join(INSTANCE)
.join("lima.yaml"),
)
}
#[cfg(target_os = "macos")]
pub fn forward(cli: &Cli) -> anyhow::Result<Option<u8>> {
use anyhow::Context;
if runs_on_the_host(&cli.command) {
return Ok(None);
}
let limactl = which("limactl").ok_or_else(|| {
anyhow::anyhow!(
"macOS has no user namespaces, cgroups or seccomp, so Zygo runs its \
sandboxes in a Linux VM — and `limactl`, which starts it, is not \
installed\n → brew install lima"
)
})?;
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.context("HOME is not set, so there is no shared directory to run in")?;
let cwd = std::env::current_dir().context("this process has no working directory")?;
let dependency = cwd_dependency(&cli.command, spec_discoverable_here().as_deref());
let workdir = workdir(&cwd, &home, dependency).map_err(|e| {
anyhow::anyhow!(
"{e}\n → run it from somewhere under {}, or name every path absolutely",
home.display()
)
})?;
let unmapped = unmapped_paths(&cli.command, &cwd, &home);
if let Some((what, first)) = unmapped.first() {
anyhow::bail!(
"on macOS Zygo runs in a Linux VM, and only {} is shared with it — \
but {what} {} is outside it{}\n \
→ move it under {}, or set TMPDIR there if it is a temporary directory",
home.display(),
first.display(),
match unmapped.len() {
1 => String::new(),
n => format!(" (and {} other{})", n - 1, if n == 2 { "" } else { "s" }),
},
home.display()
);
}
let paths = crate::cmd::paths(cli);
paths.ensure()?;
if needs_nothing_when_the_vm_is_down(&cli.command) && !is_running(&limactl)? {
if cli.json {
crate::output::json(&serde_json::json!({ "stopped": [] }))?;
} else {
println!("nothing is running; the Linux VM is stopped");
}
return Ok(Some(0));
}
if stops_everything(&cli.command) {
eprintln!(
"this stops every sandbox and every warm function on this Mac, and then \
the Linux VM they run in; the next command boots it again (about 16 s)"
);
}
let fast = ssh_config(&home).filter(|config| mux_alive(config));
if fast.is_none() {
ensure_running(&limactl, &paths)?;
}
ensure_guest_binary(&limactl, &paths)?;
let env = environment_to_forward(cli);
let (program, args): (PathBuf, Vec<OsString>) = match &fast {
Some(config) => (
PathBuf::from("ssh"),
ssh_args(
config,
&workdir,
std::env::args_os().skip(1),
&env,
std::io::IsTerminal::is_terminal(&std::io::stdin()),
),
),
None => (
limactl.clone(),
forward_args_with_env(&workdir, std::env::args_os().skip(1), &env),
),
};
let mut attempt = 0;
let mut unreachable = 0;
let status = loop {
let (status, transport) = run_forwarded(&program, &args, cli.verbose > 0)?;
if transport == Transport::Ran {
break status;
}
if transport == Transport::VmUnreachable {
if unreachable < UNREACHABLE_RETRIES {
unreachable += 1;
tracing::debug!(unreachable, "the VM's sshd was not reached; waiting for it");
drop(paths.lock(VM_START_LOCK)?);
ensure_running(&limactl, &paths)?;
std::thread::sleep(std::time::Duration::from_millis(500 << (unreachable - 1)));
continue;
}
crate::output::error(&anyhow::anyhow!(
"could not reach the Linux VM's ssh server\n \
→ tried {} times, waiting for the VM between them\n \
→ `limactl list` says what state it is in; `zygo doctor` checks the rest",
UNREACHABLE_RETRIES + 1
));
return Ok(Some(EXIT_VM_UNREACHABLE));
}
if attempt < TRANSPORT_RETRIES {
attempt += 1;
tracing::debug!(attempt, "the VM refused a session; retrying");
std::thread::sleep(transport_backoff(attempt));
continue;
}
crate::output::error(&anyhow::anyhow!(
"could not open a session to the Linux VM (too many at once)\n \
→ tried {} times; the VM's sshd caps the sessions one connection may carry\n \
→ `zygo doctor` says whether the VM was made from a template that raises the cap",
TRANSPORT_RETRIES + 1
));
return Ok(Some(EXIT_VM_UNREACHABLE));
};
if status.success() && stops_everything(&cli.command) {
stop_vm(&limactl);
}
Ok(Some(exit_status(&status)))
}
#[cfg(target_os = "macos")]
fn run_forwarded(
program: &Path,
args: &[OsString],
verbose: bool,
) -> anyhow::Result<(std::process::ExitStatus, Transport)> {
use anyhow::Context;
use std::io::{Read, Write};
let mut child = std::process::Command::new(program)
.args(args)
.stderr(std::process::Stdio::piped())
.spawn()
.with_context(|| format!("could not run {}", program.display()))?;
FORWARDED_CHILD.store(child.id() as i32, std::sync::atomic::Ordering::SeqCst);
install_signal_relay();
let mut stderr = child.stderr.take().expect("stderr was piped");
let relay = std::thread::spawn(move || {
const LOOK_IN: usize = 8 * 1024;
let mut seen = String::new();
let mut matched = false;
let mut unreachable = false;
let mut out = std::io::stderr();
let mut buf = [0u8; 4096];
loop {
let n = match stderr.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => n,
};
let chunk = &buf[..n];
if seen.len() < LOOK_IN {
seen.push_str(&String::from_utf8_lossy(chunk));
}
let text = String::from_utf8_lossy(chunk);
let is_ssh = is_transport_failure(&text);
let is_down = is_vm_unreachable(&text);
matched |= is_ssh;
unreachable |= is_down;
if (is_ssh || is_down) && !verbose {
continue;
}
let _ = out.write_all(chunk);
let _ = out.flush();
}
if matched || is_transport_failure(&seen) {
Transport::SessionRefused
} else if unreachable || is_vm_unreachable(&seen) {
Transport::VmUnreachable
} else {
Transport::Ran
}
});
let status = child
.wait()
.with_context(|| format!("could not wait for {}", program.display()))?;
FORWARDED_CHILD.store(0, std::sync::atomic::Ordering::SeqCst);
let transport = relay.join().unwrap_or(Transport::Ran);
Ok((
status,
if status.code() == Some(255) {
transport
} else {
Transport::Ran
},
))
}
#[cfg(target_os = "macos")]
static FORWARDED_CHILD: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
#[cfg(target_os = "macos")]
extern "C" fn relay_signal(signum: libc::c_int) {
let pid = FORWARDED_CHILD.load(std::sync::atomic::Ordering::SeqCst);
if pid > 0 {
unsafe { libc::kill(pid, signum) };
}
}
#[cfg(target_os = "macos")]
fn install_signal_relay() {
for signum in [libc::SIGINT, libc::SIGTERM, libc::SIGHUP] {
let handler: extern "C" fn(libc::c_int) = relay_signal;
unsafe { libc::signal(signum, handler as *const () as libc::sighandler_t) };
}
}
#[cfg(target_os = "macos")]
fn environment_to_forward(cli: &Cli) -> Vec<(OsString, OsString)> {
let mut wanted: Vec<String> = Vec::new();
for (key, _) in std::env::vars_os() {
if let Some(name) = key.to_str()
&& name.starts_with("ZYGO_")
{
wanted.push(name.to_string());
}
}
match &cli.command {
crate::cli::Command::Serve(args) => wanted.extend(args.secrets.iter().cloned()),
crate::cli::Command::Up { .. } => {}
_ => {}
}
if matches!(
cli.command,
crate::cli::Command::Up { .. } | crate::cli::Command::Serve(_)
) && let Ok(Some(spec)) = zygo_core::spec::Spec::discover(None)
{
for f in spec.functions.values() {
if let Some(names) = &f.secrets {
wanted.extend(names.iter().cloned());
}
}
if let Some(names) = &spec.defaults.secrets {
wanted.extend(names.iter().cloned());
}
}
wanted.sort();
wanted.dedup();
wanted
.into_iter()
.filter(|name| is_forwardable_env_name(name))
.filter_map(|name| std::env::var_os(&name).map(|value| (OsString::from(name), value)))
.collect()
}
pub fn needs_nothing_when_the_vm_is_down(command: &Command) -> bool {
matches!(command, Command::Stop { .. } | Command::Down { .. })
}
pub fn stops_everything(command: &Command) -> bool {
matches!(command, Command::Stop { all: true, .. })
}
#[cfg(target_os = "macos")]
fn stop_vm(limactl: &Path) {
match std::process::Command::new(limactl)
.args(["stop", INSTANCE])
.output()
{
Ok(out) if out.status.success() => {
tracing::debug!(
lima = %String::from_utf8_lossy(&out.stderr).trim(),
"the Linux VM is stopped; the next command starts it again"
);
eprintln!("the Linux VM is stopped; the next command starts it again");
}
Ok(out) => tracing::warn!(
lima = %String::from_utf8_lossy(&out.stderr).trim(),
"the Linux VM did not stop ({})",
out.status
),
Err(e) => tracing::warn!("could not stop the Linux VM: {e}"),
}
}
#[cfg(not(target_os = "macos"))]
pub fn forward(_cli: &Cli) -> anyhow::Result<Option<u8>> {
Ok(None)
}
#[cfg(target_os = "macos")]
fn ensure_guest_binary(limactl: &Path, paths: &zygo_core::paths::Paths) -> anyhow::Result<()> {
let Some(source) = linux_binary() else {
if guest_has_zygo(limactl) {
return Ok(());
}
anyhow::bail!(
"the VM has no Linux build of Zygo to run, and there is none on \
this Mac to put there\n \
→ in a checkout: make guest-build (compiled in the VM, no Docker) \
or make poc/zygo-linux-musl\n \
→ otherwise: set ZYGO_LINUX_BIN to a Linux `zygo` for this \
machine's architecture"
);
};
let Ok(meta) = std::fs::metadata(&source) else {
return Ok(());
};
let modified = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
let want = build_stamp(meta.len(), modified, instance_generation());
let stamp = stamp_path();
if std::fs::read_to_string(&stamp).is_ok_and(|have| have.trim() == want) {
return Ok(());
}
let _guard = paths.lock(GUEST_BIN_LOCK)?;
if std::fs::read_to_string(&stamp).is_ok_and(|have| have.trim() == want) {
return Ok(());
}
let replacing = stamp.is_file();
tracing::info!(binary = %source.display(), "installing this release's Linux binary in the VM");
for args in [copy_args(&source), install_args()] {
let status = std::process::Command::new(limactl)
.args(args)
.status()
.map_err(|e| anyhow::anyhow!("could not run {}: {e}", limactl.display()))?;
if !status.success() {
anyhow::bail!(
"could not put the Linux build of Zygo into the VM ({status})\n → check it by hand: limactl shell {INSTANCE} -- zygo --version"
);
}
}
if let Some(dir) = stamp.parent() {
let _ = std::fs::create_dir_all(dir);
}
let _ = std::fs::write(&stamp, &want);
if replacing {
let stopped = std::process::Command::new(limactl)
.args(["shell", INSTANCE, GUEST_BIN, "supervisor", "stop"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success());
if stopped {
eprintln!(
"the supervisor in the Linux VM was running the previous release and has \
been stopped; the next `serve` or `up` starts one of this release"
);
}
}
Ok(())
}
#[cfg(target_os = "macos")]
fn guest_has_zygo(limactl: &Path) -> bool {
std::process::Command::new(limactl)
.args(["shell", INSTANCE, "test", "-x", GUEST_BIN])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
#[cfg(target_os = "macos")]
fn linux_binary() -> Option<PathBuf> {
let named = std::env::var_os("ZYGO_LINUX_BIN").map(PathBuf::from);
let installed = std::env::current_exe().ok().and_then(|exe| {
exe.parent().map(|dir| {
dir.join(format!(
"../share/zygo/zygo-linux-{}",
std::env::consts::ARCH
))
})
});
let checkout = PathBuf::from(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../poc/zygo-linux-musl"
));
[named, installed, Some(checkout)]
.into_iter()
.flatten()
.find(|c| c.is_file())
}
#[cfg(target_os = "macos")]
fn stamp_path() -> PathBuf {
let base = std::env::var_os("XDG_CACHE_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))
.unwrap_or_else(std::env::temp_dir);
base.join("zygo").join(format!("vm-{INSTANCE}.stamp"))
}
pub const VM_START_LOCK: &str = "vm-start";
pub const GUEST_BIN_LOCK: &str = "vm-guest-binary";
#[cfg(target_os = "macos")]
fn ensure_running(limactl: &Path, paths: &zygo_core::paths::Paths) -> anyhow::Result<()> {
if is_running(limactl)? {
return Ok(());
}
if paths.is_locked(VM_START_LOCK) {
eprintln!("waiting for the Linux VM another zygo command is starting");
}
let _guard = paths.lock(VM_START_LOCK)?;
let out = std::process::Command::new(limactl)
.args(status_args())
.output()
.map_err(|e| anyhow::anyhow!("could not ask limactl about the VM: {e}"))?;
let vm = Vm::parse(&String::from_utf8_lossy(&out.stdout));
if vm == Vm::Running {
return Ok(());
}
let template = template_path()?;
let first_time = vm == Vm::Absent;
eprintln!(
"starting the Linux VM Zygo runs its sandboxes in{}",
if first_time {
" (the first time takes about a minute)"
} else {
""
}
);
let out = std::process::Command::new(limactl)
.args(start_args(vm, &template))
.output()
.map_err(|e| anyhow::anyhow!("could not start the VM: {e}"))?;
if !out.status.success() {
anyhow::bail!(
"the Linux VM would not start (limactl exited {})\n{}\n \
→ see it for yourself with `limactl start {INSTANCE}`\n \
→ an instance left half-created reports a missing `ha.sock`; \
`limactl delete {INSTANCE}` discards it and the next command \
builds a fresh one",
out.status.code().unwrap_or(-1),
String::from_utf8_lossy(&out.stderr)
.lines()
.rev()
.take(8)
.collect::<Vec<_>>()
.into_iter()
.rev()
.map(|l| format!(" {l}"))
.collect::<Vec<_>>()
.join("\n")
);
}
Ok(())
}
#[cfg(target_os = "macos")]
fn is_running(limactl: &Path) -> anyhow::Result<bool> {
let out = std::process::Command::new(limactl)
.args(status_args())
.output()
.map_err(|e| anyhow::anyhow!("could not ask limactl about the VM: {e}"))?;
Ok(Vm::parse(&String::from_utf8_lossy(&out.stdout)) == Vm::Running)
}
#[cfg(target_os = "macos")]
fn template_path() -> anyhow::Result<PathBuf> {
let named = std::env::var_os("ZYGO_LIMA_TEMPLATE").map(PathBuf::from);
let candidates = named
.into_iter()
.chain(
std::env::current_exe()
.ok()
.and_then(|exe| exe.parent().map(|dir| dir.join("../share/zygo/lima.yaml"))),
)
.chain(std::iter::once(PathBuf::from(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../shim/lima.yaml"
))));
for candidate in candidates {
if candidate.is_file() {
return Ok(candidate);
}
}
anyhow::bail!(
"the VM template is missing; Zygo cannot create the Linux VM without it\n \
→ set ZYGO_LIMA_TEMPLATE to a Lima template, or reinstall Zygo"
)
}
#[cfg(target_os = "macos")]
fn exit_status(status: &std::process::ExitStatus) -> u8 {
use std::os::unix::process::ExitStatusExt;
match (status.code(), status.signal()) {
(Some(code), _) => code as u8,
(None, Some(signal)) => 128u8.saturating_add(signal as u8),
(None, None) => 1,
}
}
#[cfg(target_os = "macos")]
fn which(binary: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
std::env::split_paths(&path)
.map(|dir| dir.join(binary))
.find(|candidate| candidate.is_file())
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
fn command_of(args: &[&str]) -> Command {
Cli::try_parse_from(args).expect("parse").command
}
#[test]
fn the_commands_that_need_no_kernel_stay_here() {
for args in [
&["zygo", "completion", "bash"][..],
&["zygo", "doctor"][..],
&["zygo", "agent", "test", "python3", "--", "agent.py"][..],
] {
assert!(
runs_on_the_host(&command_of(args)),
"{args:?} should not need the VM"
);
}
}
#[test]
fn everything_that_builds_a_sandbox_is_forwarded() {
for args in [
&["zygo", "run", "python:3.12", "true"][..],
&["zygo", "serve", "handler.py", "--name", "f"][..],
&["zygo", "exec", "f", "{}"][..],
&["zygo", "ps"][..],
&["zygo", "up"][..],
&["zygo", "pull", "python:3.12"][..],
&["zygo", "images"][..],
&["zygo", "shell", "f"][..],
] {
assert!(
!runs_on_the_host(&command_of(args)),
"{args:?} needs a Linux kernel and must be forwarded"
);
}
}
#[test]
fn a_directory_under_home_keeps_its_path() {
let home = Path::new("/Users/m");
assert_eq!(
workdir(Path::new("/Users/m/work/fn"), home, None).unwrap(),
PathBuf::from("/Users/m/work/fn")
);
assert_eq!(
workdir(home, home, None).unwrap(),
PathBuf::from("/Users/m")
);
assert_eq!(
workdir(
Path::new("/Users/m/work"),
home,
Some("the mount `./x`".into())
)
.unwrap(),
PathBuf::from("/Users/m/work")
);
}
#[test]
fn a_directory_outside_home_is_refused_only_when_something_depends_on_it() {
let home = Path::new("/Users/m");
assert_eq!(
workdir(Path::new("/tmp/scratch"), home, None).unwrap(),
PathBuf::from(GUEST_NEUTRAL_DIR),
"nothing relative, nothing discovered: forwarded, in a neutral directory"
);
let err = workdir(
Path::new("/tmp/scratch"),
home,
Some("the mount `./x` is relative to that directory".into()),
)
.unwrap_err();
let text = err.to_string();
assert!(text.contains("/tmp/scratch"), "{text}");
assert!(text.contains("/Users/m"), "{text}");
assert!(
text.contains("the mount `./x`"),
"the argument is named: {text}"
);
}
#[test]
fn a_sibling_directory_that_merely_starts_with_home_is_not_home() {
let home = Path::new("/Users/m");
assert!(workdir(Path::new("/Users/martin/work"), home, Some("x".into())).is_err());
}
#[test]
fn only_a_relative_path_or_a_discovered_spec_needs_the_working_directory() {
let none: Option<&Path> = None;
let absolute = command_of(&[
"zygo",
"run",
"--mount",
"/Users/m/x:/data:rw",
"alpine:3",
"true",
]);
assert_eq!(cwd_dependency(&absolute, none), None);
let relative = command_of(&["zygo", "run", "--mount", "./x:/x:rw", "alpine:3", "true"]);
let why = cwd_dependency(&relative, none).expect("refused");
assert!(why.contains("the mount `./x`"), "{why}");
let served = command_of(&["zygo", "serve", "handler.py", "--name", "f"]);
let why = cwd_dependency(&served, none).expect("refused");
assert!(why.contains("the handler `handler.py`"), "{why}");
let served = command_of(&["zygo", "serve", "/Users/m/handler.py", "--name", "f"]);
assert_eq!(cwd_dependency(&served, none), None);
let found = Path::new("/tmp/project/sandbox.toml");
let why = cwd_dependency(&absolute, Some(found)).expect("refused");
assert!(why.contains("/tmp/project/sandbox.toml"), "{why}");
let named = command_of(&["zygo", "run", "-f", "/Users/m/s.toml", "alpine:3", "true"]);
assert_eq!(cwd_dependency(&named, Some(found)), None);
let named = command_of(&["zygo", "run", "-f", "s.toml", "alpine:3", "true"]);
assert!(
cwd_dependency(&named, none)
.unwrap()
.contains("the spec file `s.toml`")
);
for args in [
&["zygo", "shell", "f"][..],
&["zygo", "ps"][..],
&["zygo", "stop", "--all"][..],
&["zygo", "exec", "f", "{}"][..],
&[
"zygo",
"exec",
"--runtime",
"py",
"--script",
"sha256:abc",
"{}",
][..],
] {
assert_eq!(cwd_dependency(&command_of(args), none), None, "{args:?}");
}
let script = command_of(&["zygo", "exec", "--runtime", "py", "--script", "s.py", "{}"]);
assert!(
cwd_dependency(&script, none)
.unwrap()
.contains("the script `s.py`")
);
assert!(cwd_dependency(&command_of(&["zygo", "up"]), Some(found)).is_some());
assert_eq!(
cwd_dependency(
&command_of(&["zygo", "up", "-f", "/Users/m/s.toml"]),
Some(found)
),
None
);
}
#[test]
fn the_transport_signature_is_recognised_and_nothing_else_is() {
for line in [
"mux_client_request_session: session request failed: Session open refused by peer\n",
"Session open refused by peer",
] {
assert!(is_transport_failure(line), "{line:?}");
}
for line in [
"Traceback (most recent call last):\n",
"error: the sandbox exceeded its 30s deadline and was killed\n",
"ssh: connect to host 127.0.0.1 port 60022: Connection refused\n",
"",
] {
assert!(!is_transport_failure(line), "{line:?}");
}
}
#[test]
fn an_unreachable_vm_is_recognised_and_nothing_else_is() {
for line in [
"ssh: connect to host 127.0.0.1 port 55246: Connection refused\r\n",
"kex_exchange_identification: read: Connection reset by peer\n",
] {
assert!(is_vm_unreachable(line), "{line:?}");
}
for line in [
"mux_client_request_session: session request failed: Session open refused by peer\n",
"Connection to 127.0.0.1 closed by remote host.\n",
"ConnectionRefusedError: [Errno 111] Connection refused\n",
"",
] {
assert!(!is_vm_unreachable(line), "{line:?}");
}
}
#[test]
fn the_retry_budget_is_short() {
let total: std::time::Duration = (1..=TRANSPORT_RETRIES).map(transport_backoff).sum();
assert!(transport_backoff(1) < transport_backoff(2));
assert!(total < std::time::Duration::from_secs(1), "{total:?}");
assert_ne!(
EXIT_VM_UNREACHABLE, 125,
"125 is `this host cannot run sandboxes`"
);
assert_ne!(
EXIT_VM_UNREACHABLE, 255,
"255 is what SSH and a program both exit with"
);
}
#[cfg(target_os = "macos")]
#[test]
fn a_refused_session_is_recognised_only_with_exit_255() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let fake = |name: &str, script: &str| {
let path = dir.path().join(name);
std::fs::write(&path, format!("#!/bin/sh\n{script}\n")).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
path
};
let refused = fake(
"refused",
"printf 'mux_client_request_session: session request failed: Session open refused by peer\\n' >&2; exit 255",
);
let program_255 = fake("program", "printf 'Traceback\\n' >&2; exit 255");
let refused_but_ran = fake(
"odd",
"printf 'Session open refused by peer\\n' >&2; exit 3",
);
let fine = fake("fine", "exit 0");
let down = fake(
"down",
"printf 'ssh: connect to host 127.0.0.1 port 55246: Connection refused\\r\\n' >&2; exit 255",
);
let (status, transport) = run_forwarded(&refused, &[], false).unwrap();
assert_eq!(status.code(), Some(255));
assert_eq!(
transport,
Transport::SessionRefused,
"SSH's line and 255: the guest ran nothing"
);
let (status, transport) = run_forwarded(&down, &[], false).unwrap();
assert_eq!(status.code(), Some(255));
assert_eq!(transport, Transport::VmUnreachable, "no sshd to reach");
let (status, transport) = run_forwarded(&program_255, &[], false).unwrap();
assert_eq!(status.code(), Some(255));
assert_eq!(
transport,
Transport::Ran,
"255 from a program is the program's"
);
let (_, transport) = run_forwarded(&refused_but_ran, &[], false).unwrap();
assert_eq!(
transport,
Transport::Ran,
"the line without 255 is not a transport failure"
);
let (status, transport) = run_forwarded(&fine, &[], false).unwrap();
assert!(status.success());
assert_eq!(transport, Transport::Ran);
}
#[test]
fn the_template_declares_this_generation() {
let shipped = include_str!("../../../shim/lima.yaml");
assert_eq!(template_generation_of(shipped), Some(TEMPLATE_GENERATION));
assert_eq!(template_generation_of("images:\n - location: x\n"), None);
assert_eq!(
template_generation_of("# zygo-template-generation: 1\n"),
Some(1)
);
}
#[test]
fn host_checks_name_what_a_deploy_script_needs_to_know() {
use zygo_core::doctor::Status;
let names =
|checks: &[zygo_core::doctor::Check]| checks.iter().map(|c| c.name).collect::<Vec<_>>();
let worst =
|checks: &[zygo_core::doctor::Check]| checks.iter().map(|c| c.status).max().unwrap();
let healthy = host_checks(
Some(Path::new("/opt/homebrew/bin/limactl")),
Some(Path::new("/opt/zygo/lima.yaml")),
Some(Path::new("/opt/zygo/zygo-linux-aarch64")),
Vm::Running,
Some(TEMPLATE_GENERATION),
Some(Path::new("/Users/m")),
);
assert_eq!(worst(&healthy), Status::Ok, "{healthy:?}");
assert!(names(&healthy).contains(&"vm template generation"));
let bare = host_checks(None, None, None, Vm::Absent, None, None);
assert_eq!(worst(&bare), Status::Failed);
assert!(
bare.iter()
.any(|c| c.name == "limactl" && c.remedy.as_deref() == Some("brew install lima"))
);
assert!(
!names(&bare).contains(&"vm template generation"),
"no instance, no generation to compare"
);
let stopped = host_checks(
Some(Path::new("/l")),
Some(Path::new("/t")),
Some(Path::new("/b")),
Vm::Stopped,
Some(TEMPLATE_GENERATION),
None,
);
assert_eq!(worst(&stopped), Status::Failed);
assert!(
stopped
.iter()
.any(|c| c.name == "vm" && c.status == Status::Degraded)
);
assert!(
stopped
.iter()
.any(|c| c.name == "sandboxes" && c.status == Status::Failed)
);
let stale = host_checks(
Some(Path::new("/l")),
Some(Path::new("/t")),
Some(Path::new("/b")),
Vm::Running,
None,
None,
);
let generation = stale
.iter()
.find(|c| c.name == "vm template generation")
.unwrap();
assert_eq!(generation.status, Status::Degraded);
assert!(
generation
.remedy
.as_deref()
.unwrap()
.contains("limactl delete zygo")
);
}
#[test]
fn the_first_start_names_the_instance_and_the_later_ones_do_not() {
let template = Path::new("/opt/zygo/lima.yaml");
let first = start_args(Vm::Absent, template);
assert!(first.iter().any(|a| a == "--name"));
assert!(first.iter().any(|a| a == template));
let later = start_args(Vm::Stopped, template);
assert!(!later.iter().any(|a| a == "--name"));
assert!(!later.iter().any(|a| a == template));
assert!(later.iter().any(|a| a == INSTANCE));
}
#[test]
fn a_start_never_waits_for_an_answer_nobody_is_there_to_give() {
for vm in [Vm::Absent, Vm::Stopped] {
assert!(
start_args(vm, Path::new("t.yaml"))
.iter()
.any(|a| a == "--tty=false"),
"{vm:?} would prompt"
);
}
}
#[test]
fn the_forwarded_command_carries_the_working_directory() {
let args = forward_args(Path::new("/Users/m/fn"), ["serve", "handler.py"]);
let words: Vec<String> = args
.iter()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert_eq!(
words,
[
"shell",
"--workdir",
"/Users/m/fn",
"zygo",
"zygo",
"serve",
"handler.py"
]
);
}
#[test]
fn the_binary_is_staged_before_it_replaces_the_one_in_use() {
let copy: Vec<String> = copy_args(Path::new("/opt/zygo/zygo-linux-aarch64"))
.iter()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert_eq!(copy[0], "copy");
assert_eq!(copy[1], "/opt/zygo/zygo-linux-aarch64");
assert_eq!(copy[2], format!("{INSTANCE}:{GUEST_STAGE}"));
let install: Vec<String> = install_args()
.iter()
.map(|a| a.to_string_lossy().into_owned())
.collect();
let stage = install.iter().position(|a| a == GUEST_STAGE).unwrap();
let target = install.iter().position(|a| a == GUEST_BIN).unwrap();
assert!(stage < target, "{install:?}");
assert!(install.contains(&"0755".to_string()));
}
#[test]
fn stopping_needs_nothing_from_a_stopped_machine() {
for args in [
vec!["zygo", "stop", "--all"],
vec!["zygo", "stop", "f"],
vec!["zygo", "down"],
] {
assert!(
needs_nothing_when_the_vm_is_down(&command_of(&args)),
"{args:?} has nothing to stop in a machine that is not running"
);
}
for args in [
vec!["zygo", "ps"],
vec!["zygo", "run", "alpine:3", "true"],
vec!["zygo", "logs", "f"],
vec!["zygo", "images"],
vec!["zygo", "up"],
] {
assert!(
!needs_nothing_when_the_vm_is_down(&command_of(&args)),
"{args:?} has to reach the VM"
);
}
}
#[test]
fn the_ssh_form_quotes_every_word_and_names_the_binary_absolutely() {
let config = Path::new("/Users/m/.lima/zygo/ssh.config");
let workdir = Path::new("/Users/m/my project");
let env = [(OsString::from("ZYGO_LOG"), OsString::from("debug"))];
let args = ssh_args(
config,
workdir,
[
"run",
"--env",
"GREETING=it's here",
"alpine:3",
"echo",
"hi",
],
&env,
false,
);
assert_eq!(args[0], "-F");
assert_eq!(args[1], config.as_os_str());
assert_eq!(args[2], "lima-zygo");
assert_eq!(args[3], "--");
assert_eq!(
args[4],
"cd '/Users/m/my project' && env ZYGO_LOG=debug /usr/local/bin/zygo run --env \
'GREETING=it'\\''s here' alpine:3 echo hi"
);
assert_eq!(args.len(), 5, "no -t without a terminal");
let with_tty = ssh_args(config, workdir, ["ps"], &[], true);
assert_eq!(with_tty[2], "-t");
assert_eq!(
with_tty[5],
"cd '/Users/m/my project' && /usr/local/bin/zygo ps"
);
}
#[test]
fn a_word_is_left_bare_only_when_a_shell_would_read_it_as_one() {
let q = |s: &str| sh_quote(std::ffi::OsStr::new(s)).into_string().unwrap();
assert_eq!(q("alpine:3"), "alpine:3");
assert_eq!(q("--mem=512M"), "--mem=512M");
assert_eq!(q("/Users/m/x.py"), "/Users/m/x.py");
assert_eq!(q(""), "''");
assert_eq!(q("a b"), "'a b'");
assert_eq!(q("it's"), "'it'\\''s'");
assert_eq!(q("$HOME"), "'$HOME'");
assert_eq!(q("a;b"), "'a;b'");
}
#[test]
fn the_ssh_config_is_lima_s_and_only_when_present() {
let home = tempfile::tempdir().expect("tempdir");
assert_eq!(ssh_config(home.path()), None);
let dir = home.path().join(".lima").join(INSTANCE);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("ssh.config"), "Host lima-zygo\n").unwrap();
assert_eq!(ssh_config(home.path()), Some(dir.join("ssh.config")));
}
#[test]
fn every_path_the_command_names_is_held_to_the_shared_directory() {
let home = Path::new("/Users/m");
let cwd = Path::new("/Users/m/projects/app");
let parse = |args: &[&str]| Cli::try_parse_from(args).expect("parses").command;
let run = parse(&[
"zygo",
"run",
"--outcome",
"/tmp/o.json",
"--requirements",
"./requirements.txt",
"--mount",
"/Users/m/data:/data",
"alpine",
]);
assert_eq!(
unmapped_paths(&run, cwd, home),
[("the outcome file", PathBuf::from("/tmp/o.json"))],
"the outcome file is outside; the relative requirements file and the mount are not"
);
let ok = parse(&["zygo", "run", "--outcome", "/Users/m/o.json", "alpine"]);
assert!(unmapped_paths(&ok, cwd, home).is_empty());
let serve = parse(&["zygo", "serve", "/var/lib/h.py", "--name", "h"]);
assert_eq!(
unmapped_paths(&serve, cwd, home),
[("the handler", PathBuf::from("/var/lib/h.py"))]
);
}
#[test]
fn a_mount_the_vm_cannot_see_is_refused_here() {
let home = Path::new("/Users/m");
let cwd = Path::new("/Users/m/projects/app");
let run = |mounts: &[&str]| {
let mut args = vec!["zygo", "run"];
for m in mounts {
args.extend(["--mount", m]);
}
args.push("alpine");
Cli::try_parse_from(args).expect("parses").command
};
assert!(
unmapped_paths(&run(&["/Users/m/data:/data"]), cwd, home).is_empty(),
"an absolute path under home is shared"
);
assert!(
unmapped_paths(&run(&["./cache:/cache:rw"]), cwd, home).is_empty(),
"a relative path means a place on this side"
);
assert_eq!(
unmapped_paths(&run(&["/var/folders/ab/T/run:/data:rw"]), cwd, home),
[("the mount", PathBuf::from("/var/folders/ab/T/run"))]
);
assert_eq!(
unmapped_paths(
&run(&["/Users/m/ok:/ok", "/tmp/one:/one", "/etc/two:/two"]),
cwd,
home
)
.len(),
2
);
}
#[test]
fn mounts_are_read_from_whichever_command_has_them() {
let mounts = |command: &Command| {
host_paths(command)
.into_iter()
.filter(|(what, _)| *what == "the mount")
.count()
};
let with_mount = command_of(&["zygo", "run", "--mount", "/tmp/x:/x", "alpine:3", "true"]);
assert_eq!(mounts(&with_mount), 1);
let served = command_of(&[
"zygo",
"serve",
"h.py",
"--name",
"f",
"--mount",
"/tmp/x:/x:rw",
]);
assert_eq!(mounts(&served), 1);
assert_eq!(mounts(&command_of(&["zygo", "ps"])), 0);
}
#[test]
fn only_stopping_everything_stops_the_machine_too() {
assert!(stops_everything(&command_of(&["zygo", "stop", "--all"])));
assert!(!stops_everything(&command_of(&["zygo", "stop", "f"])));
assert!(!stops_everything(&command_of(&["zygo", "ps"])));
assert!(!stops_everything(&command_of(&["zygo", "down"])));
}
#[test]
fn a_rebuild_changes_the_stamp_and_an_unchanged_file_does_not() {
const VM: u64 = 1_700_000_500;
assert_eq!(
build_stamp(6_364_736, 1_700_000_000, VM),
build_stamp(6_364_736, 1_700_000_000, VM)
);
assert_ne!(
build_stamp(6_364_736, 1_700_000_000, VM),
build_stamp(6_364_800, 1_700_000_000, VM)
);
assert_ne!(
build_stamp(6_364_736, 1_700_000_000, VM),
build_stamp(6_364_736, 1_700_000_001, VM)
);
}
#[test]
fn recreating_the_vm_changes_the_stamp_even_when_the_binary_has_not() {
let same_binary = (6_364_736, 1_700_000_000);
assert_ne!(
build_stamp(same_binary.0, same_binary.1, 1_700_000_500),
build_stamp(same_binary.0, same_binary.1, 1_700_009_999),
"a recreated VM must not match the stamp written for the old one"
);
}
#[test]
fn variables_are_carried_into_the_vm_as_an_env_prefix() {
let env = vec![
(OsString::from("ZYGO_API_TOKEN"), OsString::from("t0ken")),
(OsString::from("STRIPE_KEY"), OsString::from("sk_live_x")),
];
let args = forward_args_with_env(Path::new("/Users/m/p"), ["exec", "fetch"], &env);
let words: Vec<String> = args
.iter()
.map(|a| a.to_string_lossy().into_owned())
.collect();
let env_at = words
.iter()
.position(|w| w == "env")
.expect("an env prefix");
let binary_at = words
.iter()
.rposition(|w| w == "zygo")
.expect("the inner binary");
assert!(
env_at < binary_at,
"env must come before the command it sets up: {words:?}"
);
assert!(words.contains(&"ZYGO_API_TOKEN=t0ken".to_string()));
assert!(words.contains(&"STRIPE_KEY=sk_live_x".to_string()));
assert_eq!(words.last().map(String::as_str), Some("fetch"));
let plain = forward_args_with_env(Path::new("/Users/m/p"), ["ps"], &[]);
assert!(!plain.iter().any(|a| a == "env"));
}
#[test]
fn only_ordinary_variable_names_are_forwarded() {
for good in ["ZYGO_API_TOKEN", "STRIPE_KEY", "_x", "A1"] {
assert!(
is_forwardable_env_name(good),
"{good} should be forwardable"
);
}
for bad in ["", "1ABC", "A B", "A;rm -rf /", "A=B", "A$X"] {
assert!(
!is_forwardable_env_name(bad),
"{bad:?} should not be forwarded"
);
}
}
#[test]
fn limactl_status_words_map_onto_what_to_do_about_them() {
assert_eq!(Vm::parse("Running\n"), Vm::Running);
assert_eq!(Vm::parse("Stopped\n"), Vm::Stopped);
assert_eq!(Vm::parse("Broken\n"), Vm::Stopped);
assert_eq!(Vm::parse(""), Vm::Absent);
assert_eq!(Vm::parse("\n"), Vm::Absent);
}
}