use std::path::Path;
use anyhow::Context;
use zygo_core::spec::{Layer, ResolveOptions, ResolvedFn, Spec};
use crate::cli::{Cli, SpecCommand};
use crate::output::{self, Style};
pub fn run(cli: &Cli, file: Option<&Path>, command: &SpecCommand) -> anyhow::Result<u8> {
let spec = load(file)?;
match command {
SpecCommand::Validate => validate(cli, &spec),
SpecCommand::Explain { name } => explain(cli, &spec, name.as_deref()),
}
}
fn load(file: Option<&Path>) -> anyhow::Result<Spec> {
Spec::discover(file)?.context(
"no sandbox.toml found here or in any parent directory\n \
→ create one, or pass -f <path>",
)
}
fn validate(cli: &Cli, spec: &Spec) -> anyhow::Result<u8> {
let style = Style::stdout();
let names: Vec<String> = spec.function_names().map(str::to_string).collect();
let pools: Vec<String> = spec.runtimes.keys().cloned().collect();
let mut warnings = Vec::new();
for name in &names {
let resolved = spec.resolve(Some(name), &Layer::default(), &ResolveOptions::default())?;
warnings.extend(resolved.warnings);
}
for name in &pools {
let resolved = spec.resolve_runtime(name, &Layer::default(), &pool_options())?;
warnings.extend(resolved.warnings);
}
let warnings = group_warnings(&warnings);
if cli.json {
output::json(&serde_json::json!({
"file": spec.source.as_ref().map(|p| p.display().to_string()),
"functions": names,
"runtimes": pools,
"warnings": warnings,
"ok": true,
}))?;
return Ok(0);
}
for w in &warnings {
output::warn(w);
}
let file = spec
.source
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "spec".into());
let mut parts = Vec::new();
if !names.is_empty() {
parts.push(format!(
"{} function{} ({})",
names.len(),
if names.len() == 1 { "" } else { "s" },
names.join(", ")
));
}
if !pools.is_empty() {
parts.push(format!(
"{} pool{} ({})",
pools.len(),
if pools.len() == 1 { "" } else { "s" },
pools.join(", ")
));
}
if parts.is_empty() {
println!(
"{} valid, but no functions or pools are declared",
style.yellow("!")
);
} else {
println!("{} {file} valid — {}", style.green("✓"), parts.join(", "));
}
Ok(0)
}
fn pool_options() -> ResolveOptions {
ResolveOptions {
pool: true,
..Default::default()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Explained {
Run,
Function,
Pool,
}
fn kind_of(spec: &Spec, name: Option<&str>) -> Result<Explained, zygo_core::spec::SpecError> {
let Some(n) = name else {
return Ok(Explained::Run);
};
if spec.functions.contains_key(n) {
Ok(Explained::Function)
} else if spec.runtimes.contains_key(n) {
Ok(Explained::Pool)
} else {
let known: Vec<&str> = spec
.function_names()
.chain(spec.runtimes.keys().map(String::as_str))
.collect();
Err(zygo_core::spec::SpecError::unknown_function(
n,
spec.source.as_deref(),
&known,
))
}
}
fn explain(cli: &Cli, spec: &Spec, name: Option<&str>) -> anyhow::Result<u8> {
let kind = kind_of(spec, name)?;
let resolved = match kind {
Explained::Pool => {
spec.resolve_runtime(name.unwrap_or_default(), &Layer::default(), &pool_options())?
}
_ => {
let opts = ResolveOptions {
one_shot: name.is_none(),
..Default::default()
};
spec.resolve(name, &Layer::default(), &opts)?
}
};
let seccomp_source = match kind {
Explained::Pool => spec.seccomp_source_runtime(name.unwrap_or_default(), &Layer::default()),
_ => spec.seccomp_source(name, &Layer::default()),
};
if cli.json {
let mut value = to_json(&resolved);
value["seccomp"]["source"] = serde_json::json!(seccomp_source);
value["kind"] = serde_json::json!(match kind {
Explained::Run => "run",
Explained::Function => "fn",
Explained::Pool => "runtime",
});
if kind == Explained::Pool {
value["min_warm"] = serde_json::json!(resolved.min_warm);
value["max_warm"] = serde_json::json!(resolved.max_warm);
}
output::json(&value)?;
return Ok(0);
}
for w in &resolved.warnings {
output::warn(w);
}
let style = Style::stdout();
let heading = match kind {
Explained::Pool => format!("runtime {}", resolved.name),
_ => format!("fn {}", resolved.name),
};
println!("{}", style.bold(&heading));
let mut rows: Vec<Vec<String>> = vec![
row("image", &resolved.image),
row("isolation", &resolved.isolation.to_string()),
row(
"seccomp",
&format!("{} (from {seccomp_source})", resolved.seccomp),
),
row(
"runtime",
&resolved
.runtime
.as_ref()
.map(|r| r.to_string())
.unwrap_or_else(|| "warm-exec".into()),
),
];
if let Some(entry) = &resolved.entry {
rows.push(row("entry", &entry.display().to_string()));
rows.push(row("mode", &resolved.mode.to_string()));
}
if !resolved.cmd.is_empty() {
rows.push(row("cmd", &resolved.cmd.join(" ")));
} else if resolved.entry.is_none() {
rows.push(row("cmd", "the image's entrypoint and cmd"));
}
let l = &resolved.limits;
rows.extend([
row("mem", &format!("{} (high {})", l.mem, l.mem_high)),
row("cpu", &format!("{} cores", l.cpu)),
row("pids", &l.pids.to_string()),
row("timeout", &l.timeout.to_string()),
row("scratch", &l.scratch.to_string()),
row("nofile", &l.nofile.to_string()),
row("network", &resolved.network.to_string()),
]);
if !resolved.allow.is_empty() {
rows.push(row(
"allow",
&resolved
.allow
.iter()
.map(|a| a.to_string())
.collect::<Vec<_>>()
.join(", "),
));
}
for m in &resolved.mounts {
rows.push(row("mount", &m.to_string()));
}
for (k, v) in &resolved.env {
rows.push(row("env", &format!("{k}={v}")));
}
if !resolved.secrets.is_empty() {
rows.push(row("secrets", &resolved.secrets.join(", ")));
}
rows.extend([
row("concurrency", &resolved.concurrency.to_string()),
row("idle_timeout", &resolved.idle_timeout.to_string()),
row("cold_after", &resolved.cold_after.to_string()),
]);
if kind == Explained::Pool {
rows.push(row("min_warm", &resolved.min_warm.to_string()));
rows.push(row("max_warm", &resolved.max_warm.to_string()));
}
print!("{}", output::table(&["field", "value"], &rows));
Ok(0)
}
fn row(field: &str, value: &str) -> Vec<String> {
vec![field.to_string(), value.to_string()]
}
fn group_warnings(warnings: &[String]) -> Vec<String> {
let mut order: Vec<String> = Vec::new();
let mut groups: std::collections::HashMap<String, (String, Vec<String>)> =
std::collections::HashMap::new();
for w in warnings {
let Some((prefix, message)) = w.split_once(": ") else {
order.push(w.clone());
groups.insert(w.clone(), (w.clone(), Vec::new()));
continue;
};
let field = prefix
.strip_prefix("fn.")
.or_else(|| prefix.strip_prefix("runtime."))
.and_then(|rest| rest.split_once('.'))
.map(|(name, field)| (name.to_string(), field.to_string()));
let Some((name, field)) = field else {
order.push(w.clone());
groups.insert(w.clone(), (w.clone(), Vec::new()));
continue;
};
let key = format!("{field}: {message}");
let entry = groups.entry(key.clone()).or_insert_with(|| {
order.push(key.clone());
(format!("{field}: {message}"), Vec::new())
});
entry.1.push(name);
}
order
.into_iter()
.filter_map(|k| groups.remove(&k))
.map(|(text, names)| match names.len() {
0 => text,
1 => format!("fn.{}.{text}", names[0]),
_ => format!("{text} [{}]", names.join(", ")),
})
.collect()
}
pub fn to_json(r: &ResolvedFn) -> serde_json::Value {
serde_json::json!({
"name": r.name,
"image": r.image,
"entry": r.entry.as_ref().map(|p| p.display().to_string()),
"cmd": r.cmd,
"mode": r.mode.to_string(),
"runtime": r.runtime.as_ref().map(|x| x.to_string()),
"isolation": r.isolation.to_string(),
"seccomp": { "profile": r.seccomp.to_string() },
"workdir": r.workdir.display().to_string(),
"user": r.user,
"limits": {
"mem": r.limits.mem.to_string(),
"mem_high": r.limits.mem_high.to_string(),
"swap": r.limits.swap.to_string(),
"cpu": r.limits.cpu.cores(),
"pids": r.limits.pids,
"timeout": r.limits.timeout.to_string(),
"scratch": r.limits.scratch.to_string(),
"nofile": r.limits.nofile,
"io_read": r.limits.io_read.map(|b| b.to_string()),
"io_write": r.limits.io_write.map(|b| b.to_string()),
"connections": r.limits.connections,
"bandwidth": r.limits.bandwidth.map(|b| b.to_string()),
},
"network": r.network.to_string(),
"allow": r.allow.iter().map(|a| a.to_string()).collect::<Vec<_>>(),
"mounts": r.mounts.iter().map(|m| m.to_string()).collect::<Vec<_>>(),
"env": r.env,
"secrets": r.secrets,
"concurrency": r.concurrency,
"idle_timeout": r.idle_timeout.to_string(),
"cold_after": r.cold_after.to_string(),
"warnings": r.warnings,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn warnings_shared_by_several_functions_collapse_to_one_line() {
let out = group_warnings(&[
"fn.a.io_read: no disk I/O limit".into(),
"fn.b.io_read: no disk I/O limit".into(),
"fn.c.io_read: no disk I/O limit".into(),
"fn.b.scratch: scratch is over half of mem".into(),
]);
assert_eq!(out.len(), 2, "{out:?}");
assert_eq!(out[0], "io_read: no disk I/O limit [a, b, c]");
assert_eq!(out[1], "fn.b.scratch: scratch is over half of mem");
}
#[test]
fn a_pool_warning_names_its_own_table_and_groups_with_the_rest() {
let spec = Spec::parse("[runtime.py312]\nagent=\"python\"\n", None).unwrap();
let pool = spec
.resolve_runtime("py312", &Layer::default(), &pool_options())
.unwrap();
assert!(
pool.warnings
.iter()
.all(|w| w.starts_with("runtime.py312.")),
"{:?}",
pool.warnings
);
let out = group_warnings(&[
"fn.a.io_read: no disk I/O limit".into(),
"runtime.py312.io_read: no disk I/O limit".into(),
]);
assert_eq!(out, ["io_read: no disk I/O limit [a, py312]"]);
}
#[test]
fn grouping_preserves_warnings_it_cannot_parse() {
let out = group_warnings(&["something unstructured".into()]);
assert_eq!(out, ["something unstructured"]);
}
#[test]
fn explain_knows_a_pool_from_a_function_and_names_both_when_lost() {
let spec = Spec::parse(
"[fn.resize]\nimage=\"alpine\"\ncmd=[\"/bin/true\"]\n\n[runtime.py312]\nagent=\"python\"\nmin_warm=2\n",
None,
)
.unwrap();
assert_eq!(kind_of(&spec, None).unwrap(), Explained::Run);
assert_eq!(kind_of(&spec, Some("resize")).unwrap(), Explained::Function);
assert_eq!(kind_of(&spec, Some("py312")).unwrap(), Explained::Pool);
let err = kind_of(&spec, Some("py31")).unwrap_err().to_string();
assert!(err.contains("did you mean `py312`"), "{err}");
let pool = spec
.resolve_runtime("py312", &Layer::default(), &pool_options())
.unwrap();
assert_eq!(pool.min_warm, 2);
assert_eq!(pool.seccomp.to_string(), "strict");
}
#[test]
fn json_output_reports_every_resolved_limit() {
let spec = Spec::parse(
"[fn.f]\nimage=\"alpine\"\ncmd=[\"/bin/true\"]\nmem=\"512M\"\n",
None,
)
.unwrap();
let resolved = spec
.resolve(Some("f"), &Layer::default(), &ResolveOptions::default())
.unwrap();
let v = to_json(&resolved);
assert_eq!(v["limits"]["mem"], "512M");
assert_eq!(v["limits"]["pids"], 64);
assert_eq!(v["limits"]["timeout"], "30s");
assert_eq!(v["network"], "none");
assert_eq!(
v["runtime"],
serde_json::Value::Null,
"no entry ⇒ warm-exec"
);
}
}