use std::convert::Infallible;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Instant;
use anyhow::Context;
use bytes::Bytes;
use http_body_util::{BodyExt, Full, Limited};
type ApiBody = http_body_util::combinators::BoxBody<Bytes, std::io::Error>;
fn whole(bytes: Bytes) -> ApiBody {
Full::new(bytes).map_err(|never| match never {}).boxed()
}
struct Streamed(tokio::sync::mpsc::Receiver<Bytes>);
impl hyper::body::Body for Streamed {
type Data = Bytes;
type Error = std::io::Error;
fn poll_frame(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Option<std::result::Result<hyper::body::Frame<Bytes>, Self::Error>>> {
self.0
.poll_recv(cx)
.map(|frame| frame.map(|bytes| Ok(hyper::body::Frame::data(bytes))))
}
}
use hyper::body::Incoming;
use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper::{Method, Request, Response, StatusCode};
use hyper_util::rt::TokioIo;
use zygo_core::pool::Outcome;
use zygo_core::spec::{ApiAuth, Layer, Spec};
use zygo_core::supervisor::client::Client;
use zygo_core::supervisor::{
ControlError, Request as Control, Response as Reply, WorkspaceRequest,
};
use crate::cli::{ApiArgs, Cli};
use crate::output::Style;
const MAX_BODY_BYTES: usize = 16 * 1024 * 1024;
const DEFAULT_TIMEOUT_MS: u64 = 60_000;
pub const API_VERSION: u32 = 1;
const MAX_BATCH: usize = 1024;
const BATCH_IN_FLIGHT: usize = 16;
const MAX_IDLE_CLIENTS: usize = 32;
const MAX_TIMEOUT_MS: u64 = 24 * 3_600_000;
const HEADER_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
const RUN_GRACE_MS: u64 = 30_000;
#[derive(Default)]
struct Usage {
totals: std::collections::BTreeMap<String, super::otlp::TenantUsage>,
queued: std::collections::VecDeque<zygo_core::pool::Usage>,
dropped: u64,
}
const USAGE_QUEUE: usize = 10_000;
const USAGE_BATCH: usize = 256;
impl Usage {
fn record(&mut self, usage: zygo_core::pool::Usage) {
let totals = self.totals.entry(usage.tenant.clone()).or_default();
totals.tenant = usage.tenant.clone();
totals.requests += 1;
if usage.outcome != "ok" {
totals.failures += 1;
}
totals.cpu_ms += usage.cpu_ms;
totals.wall_ms += usage.wall_ms;
*totals.by_outcome.entry(usage.outcome.clone()).or_default() += 1;
if self.queued.len() >= USAGE_QUEUE {
self.queued.pop_front();
self.dropped += 1;
}
self.queued.push_back(usage);
}
fn snapshot(&self) -> Vec<super::otlp::TenantUsage> {
self.totals.values().cloned().collect()
}
fn take_batch(&mut self) -> Vec<zygo_core::pool::Usage> {
self.queued
.drain(..USAGE_BATCH.min(self.queued.len()))
.collect()
}
fn return_batch(&mut self, batch: Vec<zygo_core::pool::Usage>) {
for usage in batch.into_iter().rev() {
if self.queued.len() >= USAGE_QUEUE {
self.dropped += 1;
continue;
}
self.queued.push_front(usage);
}
}
}
struct Api {
paths: zygo_core::Paths,
exe: std::path::PathBuf,
token: Option<String>,
deploy: bool,
clients: std::sync::Mutex<Vec<Client>>,
usage: std::sync::Mutex<Usage>,
health: std::sync::Mutex<Option<(Instant, serde_json::Value)>>,
started: Instant,
requests: AtomicU64,
errors: AtomicU64,
}
pub fn run(cli: &Cli, args: &ApiArgs) -> anyhow::Result<u8> {
if args.openapi {
crate::output::json(&super::openapi::document())?;
return Ok(0);
}
let spec = Spec::discover(args.spec_file.path())?.unwrap_or_default();
let api_spec = spec.api.clone().unwrap_or_default();
let listen = Listen::parse(args.listen.as_deref().unwrap_or(&api_spec.listen))?;
let auth = if args.no_auth {
ApiAuth::None
} else {
api_spec.auth
};
let token = match auth {
ApiAuth::Bearer => Some(
std::env::var("ZYGO_API_TOKEN")
.ok()
.filter(|t| !t.is_empty())
.context(
"bearer auth is on and ZYGO_API_TOKEN is not set\n \
→ export ZYGO_API_TOKEN=<a long random string>, \
or pass --no-auth for a unix socket or loopback listener",
)?,
),
ApiAuth::None => {
anyhow::ensure!(
listen.allows_no_auth(),
"refusing to serve without authentication on {listen}\n \
→ an unauthenticated API on a reachable address lets anyone on the \
network run code as you; listen on 127.0.0.1 or a unix socket, \
or set ZYGO_API_TOKEN and use bearer auth"
);
None
}
};
let paths = super::paths(cli);
let exe = std::env::current_exe().context("cannot find this binary to start a supervisor")?;
let first = Client::connect_or_start(&paths, &exe)?;
let api = Arc::new(Api {
paths,
exe,
token,
deploy: args.allow_deploy,
clients: std::sync::Mutex::new(vec![first]),
usage: std::sync::Mutex::new(Usage::default()),
health: std::sync::Mutex::new(None),
started: Instant::now(),
requests: AtomicU64::new(0),
errors: AtomicU64::new(0),
});
let exporter = match &args.otlp_endpoint {
Some(endpoint) => Some(super::otlp::Exporter::new(endpoint, args.otlp_interval.0)?),
None => None,
};
let style = Style::stderr();
eprintln!(
"{} {listen} {} {}",
style.dim("api"),
style.dim(if api.token.is_some() {
"bearer auth"
} else {
"no auth"
}),
style.dim(if api.deploy {
"deploy on: callers may serve, stop and run"
} else {
"call-only: serve, stop and run are refused"
})
);
if let Some(exporter) = &exporter {
eprintln!(
"{} {} every {:?}",
style.dim("otlp"),
exporter.url,
exporter.interval
);
}
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
if let Some(exporter) = exporter {
let started = std::time::SystemTime::now();
let for_export = Arc::clone(&api);
runtime.spawn(super::otlp::run(exporter, started, move || {
let api = Arc::clone(&for_export);
async move { snapshot(&api).await }
}));
}
if let Some(url) = &args.usage_webhook {
let url = reqwest::Url::parse(url)
.with_context(|| format!("`{url}` is not a URL for --usage-webhook"))?;
eprintln!(
"{} {url} every {:?}",
style.dim("usage"),
args.usage_interval.get()
);
let for_usage = Arc::clone(&api);
runtime.spawn(deliver_usage(for_usage, url, args.usage_interval.get()));
}
runtime.block_on(serve(listen, api))?;
Ok(0)
}
async fn deliver_usage(api: Arc<Api>, url: reqwest::Url, interval: std::time::Duration) {
let client = match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
{
Ok(client) => client,
Err(e) => {
tracing::error!("usage: cannot build an HTTP client: {e}");
return;
}
};
let mut ticker = tokio::time::interval(interval);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
let mut failing = false;
loop {
ticker.tick().await;
loop {
let batch = api.usage.lock().expect("usage").take_batch();
if batch.is_empty() {
break;
}
let body = serde_json::json!({ "events": batch });
match client.post(url.clone()).json(&body).send().await {
Ok(response) if response.status().is_success() => {
if failing {
tracing::info!("usage: the webhook is answering again");
failing = false;
}
}
outcome => {
api.usage.lock().expect("usage").return_batch(batch);
if !failing {
failing = true;
match outcome {
Ok(r) => tracing::warn!("usage: the webhook answered {}", r.status()),
Err(e) => tracing::warn!("usage: the webhook is unreachable: {e}"),
}
}
break;
}
}
}
let dropped = {
let mut usage = api.usage.lock().expect("usage");
std::mem::take(&mut usage.dropped)
};
if dropped > 0 {
tracing::warn!(
"usage: dropped {dropped} events; the queue holds {USAGE_QUEUE} and the \
webhook is behind"
);
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Listen {
Tcp(std::net::SocketAddr),
Unix(std::path::PathBuf),
}
impl Listen {
fn parse(text: &str) -> anyhow::Result<Listen> {
if let Some(path) = text.strip_prefix("unix://") {
anyhow::ensure!(!path.is_empty(), "unix:// needs a path");
return Ok(Listen::Unix(path.into()));
}
let addr = text
.parse()
.with_context(|| format!("`{text}` is not HOST:PORT or unix://PATH"))?;
Ok(Listen::Tcp(addr))
}
fn allows_no_auth(&self) -> bool {
match self {
Listen::Unix(_) => true,
Listen::Tcp(addr) => addr.ip().is_loopback(),
}
}
}
impl std::fmt::Display for Listen {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Listen::Tcp(addr) => write!(f, "http://{addr}"),
Listen::Unix(path) => write!(f, "unix://{}", path.display()),
}
}
}
async fn serve(listen: Listen, api: Arc<Api>) -> anyhow::Result<()> {
match listen {
Listen::Tcp(addr) => {
let listener = tokio::net::TcpListener::bind(addr)
.await
.with_context(|| format!("cannot listen on {addr}"))?;
loop {
let (stream, _) = listener.accept().await?;
spawn_connection(stream, Arc::clone(&api));
}
}
Listen::Unix(path) => {
let _ = std::fs::remove_file(&path);
let listener = tokio::net::UnixListener::bind(&path)
.with_context(|| format!("cannot listen on {}", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
}
loop {
let (stream, _) = listener.accept().await?;
spawn_connection(stream, Arc::clone(&api));
}
}
}
}
fn spawn_connection<S>(stream: S, api: Arc<Api>)
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
{
tokio::spawn(async move {
let io = TokioIo::new(stream);
let service = service_fn(move |req| handle(req, Arc::clone(&api)));
let result = http1::Builder::new()
.timer(hyper_util::rt::TokioTimer::new())
.header_read_timeout(HEADER_READ_TIMEOUT)
.serve_connection(io, service)
.await;
if let Err(e) = result {
tracing::debug!("connection ended: {e}");
}
});
}
async fn handle(req: Request<Incoming>, api: Arc<Api>) -> Result<Response<ApiBody>, Infallible> {
api.requests.fetch_add(1, Ordering::Relaxed);
let response = match route(req, &api).await {
Ok(response) => response,
Err(e) => e.into_response(),
};
if response.status().is_server_error() {
api.errors.fetch_add(1, Ordering::Relaxed);
}
Ok(response)
}
#[derive(Debug)]
struct HttpError {
status: StatusCode,
body: serde_json::Value,
close: bool,
}
impl HttpError {
fn new(status: StatusCode, message: impl std::fmt::Display) -> HttpError {
HttpError {
status,
body: serde_json::json!({ "error": message.to_string() }),
close: false,
}
}
fn closing(status: StatusCode, message: impl std::fmt::Display) -> HttpError {
HttpError {
close: true,
..HttpError::new(status, message)
}
}
fn into_response(self) -> Response<ApiBody> {
let mut response = json(self.status, &self.body);
if self.close {
response
.headers_mut()
.insert(hyper::header::CONNECTION, HeaderValue::from_static("close"));
}
response
}
}
impl From<anyhow::Error> for HttpError {
fn from(e: anyhow::Error) -> HttpError {
HttpError::new(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}"))
}
}
use hyper::header::HeaderValue;
async fn route(req: Request<Incoming>, api: &Arc<Api>) -> Result<Response<ApiBody>, HttpError> {
if req.method() == Method::GET && req.uri().path() == "/healthz" {
return healthz(api).await;
}
let actor = authorise(&req, api)?;
let path = req.uri().path().to_string();
let segments: Vec<&str> = path
.trim_matches('/')
.split('/')
.filter(|s| !s.is_empty())
.collect();
let tenant = actor.tenant().map(str::to_string);
match (req.method(), segments.as_slice()) {
(&Method::GET, ["fn"]) => list(api, tenant).await,
(&Method::POST, ["tenants"]) => {
let body = read_body(req).await?;
actor.operator_only("creating a tenant")?;
create_tenant(api, &body).await
}
(&Method::GET, ["tenants"]) => {
actor.operator_only("listing the tenants")?;
tenants(api, None).await
}
(&Method::GET, ["tenants", id]) => {
let id = id.to_string();
if actor.tenant() != Some(id.as_str()) {
actor.operator_only("reading another tenant")?;
}
tenants(api, Some(id)).await
}
(&Method::DELETE, ["tenants", id]) => {
let id = id.to_string();
actor.may_deploy()?;
delete_tenant(api, id).await
}
(&Method::PATCH, ["tenants", id, "limits"]) => {
let id = id.to_string();
let body = read_body(req).await?;
actor.may_deploy()?;
set_limits(api, id, &body).await
}
(&Method::GET, ["tenants", id, "secrets"]) => {
let id = id.to_string();
if actor.tenant() != Some(id.as_str()) {
actor.operator_only("reading another tenant's secrets")?;
}
secret_names(api, id).await
}
(&Method::PUT, ["tenants", id, "secrets", name]) => {
let (id, name) = (id.to_string(), name.to_string());
let body = read_body(req).await?;
actor.may_deploy()?;
put_secret(api, id, name, &body).await
}
(&Method::DELETE, ["tenants", id, "secrets", name]) => {
let (id, name) = (id.to_string(), name.to_string());
actor.may_deploy()?;
delete_secret(api, id, name).await
}
(&Method::POST, ["tenants", id, "tokens"]) => {
let id = id.to_string();
actor.may_deploy()?;
mint_token(api, Some(id)).await
}
(&Method::POST, ["tokens"]) => {
actor.may_deploy()?;
mint_token(api, None).await
}
(&Method::GET, ["tokens"]) => {
actor.may_deploy()?;
list_tokens(api).await
}
(&Method::DELETE, ["tokens", id]) => {
let id = id.to_string();
actor.may_deploy()?;
revoke_token(api, id).await
}
(&Method::DELETE, ["requests", id]) => {
let id = id.to_string();
cancel(api, id, tenant).await
}
(&Method::POST, ["drain"]) => {
let query = Query::parse(req.uri().query().unwrap_or(""));
let grace_ms = query.number("grace_ms")?.unwrap_or(30_000);
actor.may_deploy()?;
drain(api, grace_ms).await
}
(&Method::GET, ["metrics"]) => metrics(api).await,
(&Method::GET, ["version"]) => Ok(json(
StatusCode::OK,
&serde_json::json!({
"version": env!("CARGO_PKG_VERSION"),
"api": API_VERSION,
"control": zygo_core::supervisor::CONTROL_VERSION,
"deploy": actor.deploy && actor.is_operator(),
}),
)),
(&Method::PUT, ["fn", name]) => {
let name = name.to_string();
let body = read_body(req).await?;
actor.may_deploy()?;
serve_fn(api, name, &body, tenant).await
}
(&Method::DELETE, ["fn", name]) => {
let name = name.to_string();
actor.may_deploy()?;
stop(api, name).await
}
(&Method::POST, ["run"]) => {
let body = read_body(req).await?;
actor.may_deploy()?;
one_shot(api, &body).await
}
(&Method::GET, ["fn", name, "logs"]) => {
let name = name.to_string();
let query = req.uri().query().unwrap_or("").to_string();
logs(api, name, &query, tenant).await
}
(&Method::POST, ["fn", name]) => {
let name = name.to_string();
let timeout_ms = timeout_header(&req)?;
let key = request_key(&req)?;
let query = Query::parse(req.uri().query().unwrap_or(""));
let streaming = query.flag("stream")?;
let out = query.flag("out")?;
let workspace = with_out(query.blob("workspace")?, out);
let body = read_body(req).await?;
let event = parse_event(&body)?;
if streaming {
return exec_streaming(
api,
Control::Exec {
name,
event,
timeout_ms,
tenant,
key,
stream: true,
workspace,
},
)
.await;
}
exec(api, name, event, timeout_ms, tenant, key, workspace).await
}
(&Method::POST, ["fn", name, "batch"]) => {
let name = name.to_string();
let timeout_ms = timeout_header(&req)?;
let body = read_body(req).await?;
let events: Vec<serde_json::Value> = serde_json::from_slice(&body).map_err(|e| {
HttpError::new(
StatusCode::BAD_REQUEST,
format!("body must be a JSON array of events: {e}"),
)
})?;
batch(api, name, events, timeout_ms, tenant).await
}
(&Method::GET, ["fn", name, "stats"]) => stats(api, name.to_string(), tenant).await,
(&Method::POST, ["fn", name, "warm"]) => warm(api, name.to_string(), tenant).await,
(&Method::GET, ["runtimes"]) => runtimes(api, tenant).await,
(&Method::POST, ["runtimes"]) => {
let body = read_body(req).await?;
actor.may_deploy()?;
serve_runtime(api, &body, tenant).await
}
(&Method::DELETE, ["runtimes", name]) => {
let name = name.to_string();
actor.may_deploy()?;
stop_runtime(api, name).await
}
(&Method::POST, ["runtimes", name, "call"]) => {
let name = name.to_string();
let timeout_ms = timeout_header(&req)?;
let key = request_key(&req)?;
let query = Query::parse(req.uri().query().unwrap_or(""));
let streaming = query.flag("stream")?;
let out = query.flag("out")?;
let body = read_body(req).await?;
call_runtime(api, name, &body, timeout_ms, tenant, key, streaming, out).await
}
(&Method::PUT, ["scripts"]) => {
let body = read_body(req).await?;
put_script(api, &body, tenant).await
}
(&Method::PUT, ["blobs"]) => {
let body = read_body(req).await?;
put_blob(api, &body).await
}
(&Method::GET, ["blobs", digest]) => get_blob(api, digest.to_string()).await,
(&Method::DELETE, ["blobs", digest]) => {
let digest = digest.to_string();
actor.may_deploy()?;
delete_blob(api, digest).await
}
(&Method::POST, ["deps"]) => {
let body = read_body(req).await?;
put_deps(api, &body, tenant).await
}
(&Method::GET, ["deps"]) => deps(api, None, tenant).await,
(&Method::GET, ["deps", id]) => deps(api, Some(id.to_string()), tenant).await,
(&Method::DELETE, ["deps", id]) => {
let id = id.to_string();
actor.may_deploy()?;
delete_deps(api, id).await
}
(&Method::GET, ["scripts", digest]) => get_script(api, digest.to_string()).await,
(&Method::DELETE, ["scripts", digest]) => {
let digest = digest.to_string();
actor.may_deploy()?;
delete_script(api, digest).await
}
(_, ["fn", ..])
| (_, ["requests", ..])
| (_, ["drain"])
| (_, ["metrics"])
| (_, ["version"])
| (_, ["run"])
| (_, ["runtimes", ..])
| (_, ["tenants", ..])
| (_, ["tokens", ..])
| (_, ["blobs", ..])
| (_, ["deps", ..])
| (_, ["scripts", ..]) => Err(HttpError::new(
StatusCode::METHOD_NOT_ALLOWED,
format!("{} {}", req.method(), path),
)),
_ => Err(HttpError::new(
StatusCode::NOT_FOUND,
format!("no route {path}"),
)),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Actor {
tenant: Option<String>,
deploy: bool,
}
const TENANT_HEADER: &str = "x-zygo-tenant";
const REQUEST_KEY_HEADER: &str = "x-zygo-request-key";
const REQUEST_ID_HEADER: &str = "x-zygo-request-id";
impl Actor {
fn tenant(&self) -> Option<&str> {
self.tenant.as_deref()
}
fn is_operator(&self) -> bool {
self.tenant.is_none()
}
fn operator_only(&self, what: &str) -> Result<(), HttpError> {
match &self.tenant {
None => Ok(()),
Some(id) => Err(HttpError::new(
StatusCode::FORBIDDEN,
format!("{what} is the operator's, and this request acts for tenant `{id}`"),
)),
}
}
fn may_deploy(&self) -> Result<(), HttpError> {
if self.deploy {
return self.operator_only("deploying");
}
Err(HttpError::new(
StatusCode::FORBIDDEN,
match &self.tenant {
Some(id) => format!(
"serving, stopping and running are the operator's, and this \
request acts for tenant `{id}`\n \
→ a tenant token registers scripts and calls; it does not \
name images, mounts or commands"
),
None => "this API may only call functions that are already served\n \
→ start it with `zygo api --allow-deploy`, or present an \
operator token minted with `zygo token mint`, to let callers \
serve, stop and run — which is running arbitrary code as the \
user it runs as"
.to_string(),
},
))
}
}
fn authorise(req: &Request<Incoming>, api: &Api) -> Result<Actor, HttpError> {
let header = || -> Result<Option<String>, HttpError> {
let Some(value) = req.headers().get(TENANT_HEADER) else {
return Ok(None);
};
let id = value
.to_str()
.map_err(|_| {
HttpError::closing(
StatusCode::BAD_REQUEST,
"X-Zygo-Tenant must be ASCII: it is an id, not a name",
)
})?
.trim();
zygo_core::tenants::valid_id(id)
.map_err(|e| HttpError::closing(StatusCode::BAD_REQUEST, e.to_string()))?;
Ok(Some(id.to_string()))
};
let Some(bootstrap) = &api.token else {
return Ok(Actor {
tenant: header()?,
deploy: api.deploy,
});
};
let presented = req
.headers()
.get(hyper::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(str::trim)
.ok_or_else(|| HttpError::closing(StatusCode::UNAUTHORIZED, "missing bearer token"))?;
if constant_time_eq(presented.as_bytes(), bootstrap.as_bytes()) {
return Ok(Actor {
tenant: header()?,
deploy: api.deploy,
});
}
let token = zygo_core::tokens::Tokens::new(&api.paths)
.resolve(presented)
.map_err(|e| HttpError::closing(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")))?
.ok_or_else(|| {
HttpError::closing(
StatusCode::UNAUTHORIZED,
"wrong or revoked bearer token".to_string(),
)
})?;
match token.tenant() {
None => Ok(Actor {
tenant: header()?,
deploy: true,
}),
Some(id) => {
if let Some(named) = header()?
&& named != id
{
return Err(HttpError::closing(
StatusCode::FORBIDDEN,
format!(
"this token is tenant `{id}`'s and the request names `{named}`\n \
→ drop the X-Zygo-Tenant header; only an operator token \
may act for another tenant"
),
));
}
Ok(Actor {
tenant: Some(id.to_string()),
deploy: false,
})
}
}
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
fn request_key(req: &Request<Incoming>) -> Result<Option<String>, HttpError> {
let Some(value) = req.headers().get(REQUEST_KEY_HEADER) else {
return Ok(None);
};
let key = value
.to_str()
.map(str::trim)
.ok()
.filter(|k| !k.is_empty())
.filter(|k| k.len() <= 128)
.filter(|k| k.bytes().all(|b| b.is_ascii_graphic()))
.ok_or_else(|| {
HttpError::closing(
StatusCode::BAD_REQUEST,
"X-Zygo-Request-Key must be 1 to 128 printable ASCII characters",
)
})?;
Ok(Some(key.to_string()))
}
fn timeout_header(req: &Request<Incoming>) -> Result<u64, HttpError> {
timeout_header_from(req.headers())
}
fn timeout_header_from(headers: &hyper::HeaderMap) -> Result<u64, HttpError> {
match headers.get("x-zygo-timeout-ms") {
None => Ok(DEFAULT_TIMEOUT_MS),
Some(v) => {
let ms = v
.to_str()
.ok()
.and_then(|s| s.trim().parse::<u64>().ok())
.filter(|ms| *ms > 0)
.ok_or_else(|| {
HttpError::closing(
StatusCode::BAD_REQUEST,
"X-Zygo-Timeout-Ms must be a positive integer",
)
})?;
if ms > MAX_TIMEOUT_MS {
return Err(HttpError::closing(
StatusCode::BAD_REQUEST,
format!(
"X-Zygo-Timeout-Ms is {ms}, over this API's ceiling of \
{MAX_TIMEOUT_MS}\n → the function's own `timeout` is the \
real limit; this header only says how long you will wait"
),
));
}
Ok(ms)
}
}
}
async fn read_body(req: Request<Incoming>) -> Result<Bytes, HttpError> {
Limited::new(req.into_body(), MAX_BODY_BYTES)
.collect()
.await
.map(|c| c.to_bytes())
.map_err(|e| {
HttpError::new(
StatusCode::PAYLOAD_TOO_LARGE,
format!("body over {MAX_BODY_BYTES} bytes or unreadable: {e}"),
)
})
}
fn parse_event(body: &[u8]) -> Result<serde_json::Value, HttpError> {
if body.iter().all(u8::is_ascii_whitespace) {
return Ok(serde_json::Value::Null);
}
serde_json::from_slice(body)
.map_err(|e| HttpError::new(StatusCode::BAD_REQUEST, format!("body is not JSON: {e}")))
}
async fn control<T, F>(api: &Arc<Api>, f: F) -> anyhow::Result<T>
where
T: Send + 'static,
F: FnOnce(&mut Client) -> anyhow::Result<T> + Send + 'static,
{
let api = Arc::clone(api);
tokio::task::spawn_blocking(move || {
let mut client = match api.clients.lock().expect("clients").pop() {
Some(client) => client,
None => Client::connect_or_start(&api.paths, &api.exe)?,
};
let result = f(&mut client);
if result.is_ok() {
let mut idle = api.clients.lock().expect("clients");
if idle.len() < MAX_IDLE_CLIENTS {
idle.push(client);
}
}
result
})
.await
.context("the control request panicked")?
}
const STREAM_CONTENT_TYPE: &str = "application/x-ndjson";
const STREAM_BACKLOG: usize = 64;
async fn exec_streaming(api: &Arc<Api>, request: Control) -> Result<Response<ApiBody>, HttpError> {
let (lines, rx) = tokio::sync::mpsc::channel::<Bytes>(STREAM_BACKLOG);
let api = Arc::clone(api);
tokio::task::spawn_blocking(move || {
let mut client = match api.clients.lock().expect("clients").pop() {
Some(client) => client,
None => match Client::connect_or_start(&api.paths, &api.exe) {
Ok(client) => client,
Err(e) => {
let _ = lines.blocking_send(line(&serde_json::json!({
"status": 500,
"error": format!("{e:#}"),
})));
return;
}
},
};
let answer = client.send_streaming(&request, |stream, data| {
let _ = lines.blocking_send(line(&serde_json::json!({
"stream": stream.as_str(),
"data": data,
})));
});
let last = match answer {
Ok(reply) => {
count_usage(&api, &reply);
let (status, mut body) = reply_to_json(reply);
body["status"] = status.as_u16().into();
body
}
Err(e) => serde_json::json!({ "status": 500, "error": format!("{e:#}") }),
};
let _ = lines.blocking_send(line(&last));
let mut idle = api.clients.lock().expect("clients");
if idle.len() < MAX_IDLE_CLIENTS {
idle.push(client);
}
});
let body = Streamed(rx).boxed();
Response::builder()
.status(StatusCode::OK)
.header("content-type", STREAM_CONTENT_TYPE)
.header("cache-control", "no-store")
.header("x-accel-buffering", "no")
.body(body)
.map_err(|e| HttpError::new(StatusCode::INTERNAL_SERVER_ERROR, e))
}
fn line(value: &serde_json::Value) -> Bytes {
let mut out = serde_json::to_vec(value).unwrap_or_else(|_| b"{}".to_vec());
out.push(b'\n');
Bytes::from(out)
}
#[allow(clippy::too_many_arguments)]
async fn exec(
api: &Arc<Api>,
name: String,
event: serde_json::Value,
timeout_ms: u64,
tenant: Option<String>,
key: Option<String>,
workspace: Option<WorkspaceRequest>,
) -> Result<Response<ApiBody>, HttpError> {
let reply = control(api, move |c| {
Ok(c.send(&Control::Exec {
name,
event,
timeout_ms,
tenant,
key,
stream: false,
workspace,
})?)
})
.await?;
count_usage(api, &reply);
Ok(reply_to_response(reply))
}
async fn batch(
api: &Arc<Api>,
name: String,
events: Vec<serde_json::Value>,
timeout_ms: u64,
tenant: Option<String>,
) -> Result<Response<ApiBody>, HttpError> {
if events.len() > MAX_BATCH {
return Err(HttpError::new(
StatusCode::PAYLOAD_TOO_LARGE,
format!(
"a batch of {} is over this API's ceiling of {MAX_BATCH}\n \
→ send it in several requests; each element costs a supervisor \
connection and a thread",
events.len()
),
));
}
let permits = Arc::new(tokio::sync::Semaphore::new(BATCH_IN_FLIGHT));
let calls = events.into_iter().map(|event| {
let name = name.clone();
let tenant = tenant.clone();
let api = Arc::clone(api);
let permits = Arc::clone(&permits);
async move {
let _permit = permits
.acquire()
.await
.expect("the semaphore is not closed");
let reply = control(&api, move |c| {
Ok(c.send(&Control::Exec {
name,
event,
timeout_ms,
tenant,
key: None,
stream: false,
workspace: None,
})?)
})
.await;
match reply {
Ok(reply) => {
count_usage(&api, &reply);
let (status, body) = reply_to_json(reply);
let mut body = body;
body["status"] = status.as_u16().into();
body
}
Err(e) => serde_json::json!({ "status": 500, "error": format!("{e:#}") }),
}
}
});
let answers = futures_join_all(calls).await;
Ok(json(StatusCode::OK, &serde_json::Value::Array(answers)))
}
async fn futures_join_all<F>(futures: impl IntoIterator<Item = F>) -> Vec<serde_json::Value>
where
F: std::future::Future<Output = serde_json::Value> + Send + 'static,
{
let handles: Vec<_> = futures.into_iter().map(tokio::spawn).collect();
let mut out = Vec::with_capacity(handles.len());
for h in handles {
out.push(h.await.unwrap_or_else(|e| {
tracing::error!("a batch element panicked: {e}");
serde_json::json!({
"status": 500,
"error": "this element failed unexpectedly; the others are unaffected",
})
}));
}
out
}
fn mine(
functions: Vec<zygo_core::pool::Status>,
tenant: Option<&str>,
) -> Vec<zygo_core::pool::Status> {
match tenant {
None => functions,
Some(id) => functions.into_iter().filter(|f| f.tenant == id).collect(),
}
}
async fn healthz(api: &Arc<Api>) -> Result<Response<ApiBody>, HttpError> {
const FRESH: std::time::Duration = std::time::Duration::from_secs(1);
if let Some((at, body)) = api.health.lock().expect("health").clone()
&& at.elapsed() < FRESH
{
let stopping = body["status"] == "stopping";
return Ok(json(
if stopping {
StatusCode::SERVICE_UNAVAILABLE
} else {
StatusCode::OK
},
&body,
));
}
let reply = control(api, |c| Ok(c.send(&Control::Runtimes)?)).await;
let (status, body) = match reply {
Ok(Reply::Runtimes { runtimes }) => {
let below: Vec<&str> = runtimes
.iter()
.filter(|r| r.warm + r.paused < r.min_warm)
.map(|r| r.name.as_str())
.collect();
if below.is_empty() {
(
StatusCode::OK,
serde_json::json!({
"ok": true,
"status": "ok",
"uptime_s": api.started.elapsed().as_secs(),
}),
)
} else {
(
StatusCode::OK,
serde_json::json!({
"ok": true,
"status": "degraded",
"below_min_warm": below,
"uptime_s": api.started.elapsed().as_secs(),
}),
)
}
}
_ => (
StatusCode::SERVICE_UNAVAILABLE,
serde_json::json!({
"ok": false,
"status": "stopping",
"uptime_s": api.started.elapsed().as_secs(),
}),
),
};
*api.health.lock().expect("health") = Some((Instant::now(), body.clone()));
Ok(json(status, &body))
}
async fn drain(api: &Arc<Api>, grace_ms: u64) -> Result<Response<ApiBody>, HttpError> {
let reply = control(api, move |c| Ok(c.send(&Control::Drain { grace_ms })?)).await?;
let response = reply_to_response(reply);
tokio::spawn(async {
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
std::process::exit(0);
});
Ok(response)
}
async fn list(api: &Arc<Api>, tenant: Option<String>) -> Result<Response<ApiBody>, HttpError> {
let reply = control(api, |c| Ok(c.send(&Control::List)?)).await?;
match reply {
Reply::Functions { functions } => {
let functions = mine(functions, tenant.as_deref());
Ok(json(
StatusCode::OK,
&serde_json::json!({ "functions": functions }),
))
}
other => Ok(reply_to_response(other)),
}
}
async fn stats(
api: &Arc<Api>,
name: String,
tenant: Option<String>,
) -> Result<Response<ApiBody>, HttpError> {
let reply = control(api, |c| Ok(c.send(&Control::List)?)).await?;
match reply {
Reply::Functions { functions } => {
match mine(functions, tenant.as_deref())
.into_iter()
.find(|f| f.name == name)
{
Some(f) => Ok(json(
StatusCode::OK,
&serde_json::to_value(f).unwrap_or_default(),
)),
None => Err(HttpError::new(
StatusCode::NOT_FOUND,
format!("no function named `{name}`"),
)),
}
}
other => Ok(reply_to_response(other)),
}
}
async fn warm(
api: &Arc<Api>,
name: String,
tenant: Option<String>,
) -> Result<Response<ApiBody>, HttpError> {
let reply = control(api, move |c| Ok(c.send(&Control::Warm { name, tenant })?)).await?;
Ok(reply_to_response(reply))
}
#[derive(Debug, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct ServeRuntimeRequest {
name: String,
#[serde(default)]
layer: Layer,
#[serde(default)]
base_dir: Option<std::path::PathBuf>,
#[serde(default)]
deps: Option<String>,
}
async fn serve_runtime(
api: &Arc<Api>,
body: &[u8],
tenant: Option<String>,
) -> Result<Response<ApiBody>, HttpError> {
let request: ServeRuntimeRequest = serde_json::from_slice(body).map_err(|e| {
HttpError::new(
StatusCode::BAD_REQUEST,
format!("body is not a runtime definition: {e}"),
)
})?;
if let Some(base_dir) = &request.base_dir
&& !base_dir.is_absolute()
{
return Err(HttpError::new(
StatusCode::BAD_REQUEST,
format!(
"`base_dir` must be absolute, and `{}` is not\n \
→ it names a directory on the host this API runs on",
base_dir.display()
),
));
}
let base_dir = request
.base_dir
.unwrap_or_else(|| std::path::PathBuf::from("/"));
let deps = request.deps;
let reply = control(api, move |c| {
Ok(c.send(&Control::ServeRuntime {
tenant,
name: request.name,
spec: None,
layer: Box::new(request.layer),
base_dir,
deps,
allow_host_net: false,
allow_private_net: false,
allow_unlimited: false,
})?)
})
.await?;
Ok(reply_to_response(reply))
}
async fn cancel(
api: &Arc<Api>,
id: String,
tenant: Option<String>,
) -> Result<Response<ApiBody>, HttpError> {
let reply = control(api, move |c| Ok(c.send(&Control::Cancel { id, tenant })?)).await?;
Ok(reply_to_response(reply))
}
async fn runtimes(api: &Arc<Api>, tenant: Option<String>) -> Result<Response<ApiBody>, HttpError> {
let reply = control(api, |c| Ok(c.send(&Control::Runtimes)?)).await?;
match (reply, tenant) {
(Reply::Runtimes { runtimes }, Some(id)) => {
let runtimes: Vec<_> = runtimes.into_iter().filter(|r| r.tenant == id).collect();
Ok(json(
StatusCode::OK,
&serde_json::json!({ "runtimes": runtimes }),
))
}
(other, _) => Ok(reply_to_response(other)),
}
}
async fn stop_runtime(api: &Arc<Api>, name: String) -> Result<Response<ApiBody>, HttpError> {
let wanted = name.clone();
let reply = control(api, move |c| Ok(c.send(&Control::StopRuntime { name })?)).await?;
match reply {
Reply::Stopped { names } if names.is_empty() => Err(HttpError::new(
StatusCode::NOT_FOUND,
format!("no runtime named `{wanted}`"),
)),
other => Ok(reply_to_response(other)),
}
}
#[derive(Debug, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct CallRuntimeRequest {
script: ScriptRef,
#[serde(default)]
event: serde_json::Value,
#[serde(default)]
entry_point: Option<String>,
#[serde(default)]
workspace: Option<WorkspaceBody>,
}
#[derive(Debug, Default, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct WorkspaceBody {
#[serde(default)]
inline: Option<String>,
#[serde(default)]
blob: Option<String>,
}
impl From<WorkspaceBody> for WorkspaceRequest {
fn from(body: WorkspaceBody) -> WorkspaceRequest {
WorkspaceRequest {
inline: body.inline,
blob: body.blob,
collect: false,
}
}
}
fn with_out(workspace: Option<WorkspaceRequest>, out: bool) -> Option<WorkspaceRequest> {
match (workspace, out) {
(Some(w), out) => Some(WorkspaceRequest { collect: out, ..w }),
(None, true) => Some(WorkspaceRequest {
collect: true,
..WorkspaceRequest::default()
}),
(None, false) => None,
}
}
#[derive(Debug, serde::Deserialize)]
#[serde(untagged)]
enum ScriptRef {
Digest(String),
Source { source: String },
}
#[cfg(test)]
const ROUTES_THAT_NAME_A_HOST_PATH: &[&str] = &[
"PUT /fn/<name>", "POST /runtimes", "POST /run", ];
#[allow(clippy::too_many_arguments)]
async fn call_runtime(
api: &Arc<Api>,
name: String,
body: &[u8],
timeout_ms: u64,
tenant: Option<String>,
key: Option<String>,
streaming: bool,
out: bool,
) -> Result<Response<ApiBody>, HttpError> {
let request: CallRuntimeRequest = serde_json::from_slice(body).map_err(|e| {
HttpError::new(
StatusCode::BAD_REQUEST,
format!(
"body must be {{\"script\": \"sha256:…\" | {{\"source\": \"…\"}}, \
\"event\": …}}: {e}"
),
)
})?;
let mut script = match request.script {
ScriptRef::Digest(digest) => zygo_core::protocol::Script {
path: None,
source: None,
digest: Some(digest),
entry_point: None,
},
ScriptRef::Source { source } => zygo_core::protocol::Script::inline(source),
};
script.entry_point = request.entry_point;
let call = Control::ExecScript {
key,
runtime: name,
script,
event: request.event,
timeout_ms,
tenant,
stream: streaming,
workspace: with_out(request.workspace.map(Into::into), out),
};
if streaming {
return exec_streaming(api, call).await;
}
let reply = control(api, move |c| Ok(c.send(&call)?)).await?;
count_usage(api, &reply);
Ok(reply_to_response(reply))
}
async fn put_deps(
api: &Arc<Api>,
body: &[u8],
tenant: Option<String>,
) -> Result<Response<ApiBody>, HttpError> {
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct PutDepsRequest {
image: String,
files: std::collections::BTreeMap<String, String>,
}
let request: PutDepsRequest = serde_json::from_slice(body).map_err(|e| {
HttpError::new(
StatusCode::BAD_REQUEST,
format!(
"body must be {{\"image\": \"…\", \"files\": {{\"requirements.txt\": \"<base64>\"}}}}: {e}"
),
)
})?;
let reply = control(api, move |c| {
Ok(c.send(&Control::PutDeps {
image: request.image,
files: request.files,
tenant,
})?)
})
.await?;
match reply {
Reply::Dependencies { deps, existed, .. } => {
let status = if existed {
StatusCode::OK
} else {
StatusCode::ACCEPTED
};
Ok(json(status, &deps_json(deps.first(), "")))
}
other => Ok(reply_to_response(other)),
}
}
async fn deps(
api: &Arc<Api>,
id: Option<String>,
tenant: Option<String>,
) -> Result<Response<ApiBody>, HttpError> {
let one = id.is_some();
let reply = control(api, move |c| Ok(c.send(&Control::Deps { id, tenant })?)).await?;
match reply {
Reply::Dependencies { deps, log, .. } if one => {
Ok(json(StatusCode::OK, &deps_json(deps.first(), &log)))
}
Reply::Dependencies { deps, .. } => Ok(json(
StatusCode::OK,
&serde_json::json!({
"deps": deps.iter().map(|d| deps_json(Some(d), "")).collect::<Vec<_>>(),
}),
)),
other => Ok(reply_to_response(other)),
}
}
async fn delete_deps(api: &Arc<Api>, id: String) -> Result<Response<ApiBody>, HttpError> {
let reply = control(api, move |c| Ok(c.send(&Control::DeleteDeps { id })?)).await?;
match reply {
Reply::Ok => Ok(json(
StatusCode::OK,
&serde_json::json!({ "deleted": true }),
)),
other => Ok(reply_to_response(other)),
}
}
fn deps_json(status: Option<&zygo_core::deps::Status>, log: &str) -> serde_json::Value {
let Some(status) = status else {
return serde_json::json!({});
};
serde_json::json!({
"id": status.id,
"kind": status.kind.as_str(),
"state": status.state.as_str(),
"image": status.image,
"files": status.files,
"error": status.error,
"started_ms": status.started_ms,
"finished_ms": status.finished_ms,
"tenants": status.tenants,
"log": log,
})
}
async fn create_tenant(api: &Arc<Api>, body: &[u8]) -> Result<Response<ApiBody>, HttpError> {
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct CreateTenantRequest {
id: String,
}
let request: CreateTenantRequest = serde_json::from_slice(body).map_err(|e| {
HttpError::new(
StatusCode::BAD_REQUEST,
format!("body must be {{\"id\": \"<tenant>\"}}: {e}"),
)
})?;
let reply = control(api, move |c| {
Ok(c.send(&Control::CreateTenant { id: request.id })?)
})
.await?;
match reply {
Reply::Tenants {
tenants, existed, ..
} => Ok(json(
if existed {
StatusCode::OK
} else {
StatusCode::CREATED
},
&serde_json::json!({ "tenant": tenants.first(), "existed": existed }),
)),
other => Ok(reply_to_response(other)),
}
}
async fn tenants(api: &Arc<Api>, id: Option<String>) -> Result<Response<ApiBody>, HttpError> {
let one = id.is_some();
let reply = control(api, move |c| Ok(c.send(&Control::Tenants { id })?)).await?;
match reply {
Reply::Tenants { tenants, .. } if one => Ok(json(
StatusCode::OK,
&serde_json::json!({ "tenant": tenants.first() }),
)),
Reply::Tenants { tenants, .. } => Ok(json(
StatusCode::OK,
&serde_json::json!({ "tenants": tenants }),
)),
other => Ok(reply_to_response(other)),
}
}
async fn delete_tenant(api: &Arc<Api>, id: String) -> Result<Response<ApiBody>, HttpError> {
let reply = control(api, move |c| Ok(c.send(&Control::DeleteTenant { id })?)).await?;
match reply {
Reply::Tenants {
removed_scripts,
stopped,
..
} => Ok(json(
StatusCode::OK,
&serde_json::json!({
"deleted": true,
"removed_scripts": removed_scripts,
"stopped": stopped,
}),
)),
other => Ok(reply_to_response(other)),
}
}
async fn set_limits(
api: &Arc<Api>,
tenant: String,
body: &[u8],
) -> Result<Response<ApiBody>, HttpError> {
let limits: zygo_core::tenants::TenantLimits = serde_json::from_slice(body).map_err(|e| {
HttpError::new(
StatusCode::BAD_REQUEST,
format!(
"body must be a limits table — mem, cpu, pids, timeout, scratch, \
network, allow: {e}"
),
)
})?;
let reply = control(api, move |c| {
Ok(c.send(&Control::SetLimits {
tenant,
limits: Box::new(limits),
})?)
})
.await?;
match reply {
Reply::Tenants { tenants, .. } => Ok(json(
StatusCode::OK,
&serde_json::json!({ "tenant": tenants.first() }),
)),
other => Ok(reply_to_response(other)),
}
}
async fn put_secret(
api: &Arc<Api>,
tenant: String,
name: String,
body: &[u8],
) -> Result<Response<ApiBody>, HttpError> {
let value = std::str::from_utf8(body)
.map_err(|e| {
HttpError::new(
StatusCode::BAD_REQUEST,
format!("a secret value must be UTF-8 text: {e}"),
)
})?
.to_string();
if value.is_empty() {
return Err(HttpError::new(
StatusCode::BAD_REQUEST,
"the body is empty; it should be the secret's value",
));
}
let reply = control(api, move |c| {
Ok(c.send(&Control::PutSecret {
tenant,
name,
value,
})?)
})
.await?;
Ok(reply_to_response(reply))
}
async fn secret_names(api: &Arc<Api>, tenant: String) -> Result<Response<ApiBody>, HttpError> {
let reply = control(api, move |c| Ok(c.send(&Control::Secrets { tenant })?)).await?;
Ok(reply_to_response(reply))
}
async fn delete_secret(
api: &Arc<Api>,
tenant: String,
name: String,
) -> Result<Response<ApiBody>, HttpError> {
let reply = control(api, move |c| {
Ok(c.send(&Control::DeleteSecret { tenant, name })?)
})
.await?;
Ok(reply_to_response(reply))
}
async fn mint_token(
api: &Arc<Api>,
tenant: Option<String>,
) -> Result<Response<ApiBody>, HttpError> {
let reply = control(api, move |c| Ok(c.send(&Control::MintToken { tenant })?)).await?;
match reply {
Reply::Tokens { tokens, secret } => Ok(json(
StatusCode::CREATED,
&serde_json::json!({ "token": tokens.first(), "secret": secret }),
)),
other => Ok(reply_to_response(other)),
}
}
async fn list_tokens(api: &Arc<Api>) -> Result<Response<ApiBody>, HttpError> {
let reply = control(api, |c| Ok(c.send(&Control::Tokens)?)).await?;
match reply {
Reply::Tokens { tokens, .. } => Ok(json(
StatusCode::OK,
&serde_json::json!({ "tokens": tokens }),
)),
other => Ok(reply_to_response(other)),
}
}
async fn revoke_token(api: &Arc<Api>, id: String) -> Result<Response<ApiBody>, HttpError> {
let wanted = id.clone();
let reply = control(api, move |c| Ok(c.send(&Control::RevokeToken { id })?)).await?;
match reply {
Reply::Tokens { tokens, .. } => Ok(json(
StatusCode::OK,
&serde_json::json!({
"revoked": true,
"token": tokens.iter().find(|t| t.id == wanted),
}),
)),
other => Ok(reply_to_response(other)),
}
}
async fn put_blob(api: &Arc<Api>, body: &[u8]) -> Result<Response<ApiBody>, HttpError> {
if body.is_empty() {
return Err(HttpError::new(
StatusCode::BAD_REQUEST,
"the body is empty; it should be the tar itself",
));
}
let tar = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, body);
let reply = control(api, move |c| Ok(c.send(&Control::PutBlob { tar })?)).await?;
match reply {
Reply::Script {
digest,
size,
existed,
} => Ok(json(
if existed {
StatusCode::OK
} else {
StatusCode::CREATED
},
&serde_json::json!({ "sha256": digest, "size": size, "existed": existed }),
)),
other => Ok(reply_to_response(other)),
}
}
async fn get_blob(api: &Arc<Api>, digest: String) -> Result<Response<ApiBody>, HttpError> {
let reply = control(api, move |c| Ok(c.send(&Control::GetBlob { digest })?)).await?;
Ok(reply_to_response(reply))
}
async fn delete_blob(api: &Arc<Api>, digest: String) -> Result<Response<ApiBody>, HttpError> {
let reply = control(api, move |c| Ok(c.send(&Control::DeleteBlob { digest })?)).await?;
match reply {
Reply::Ok => Ok(json(
StatusCode::OK,
&serde_json::json!({ "deleted": true }),
)),
other => Ok(reply_to_response(other)),
}
}
async fn put_script(
api: &Arc<Api>,
body: &[u8],
tenant: Option<String>,
) -> Result<Response<ApiBody>, HttpError> {
let source = std::str::from_utf8(body)
.map_err(|e| {
HttpError::new(
StatusCode::BAD_REQUEST,
format!("a script must be UTF-8 text: {e}"),
)
})?
.to_string();
if source.is_empty() {
return Err(HttpError::new(
StatusCode::BAD_REQUEST,
"the body is empty; it should be the script itself",
));
}
let reply = control(api, move |c| {
Ok(c.send(&Control::PutScript { source, tenant })?)
})
.await?;
match reply {
Reply::Script {
digest,
size,
existed,
} => Ok(json(
if existed {
StatusCode::OK
} else {
StatusCode::CREATED
},
&serde_json::json!({ "sha256": digest, "size": size, "existed": existed }),
)),
other => Ok(reply_to_response(other)),
}
}
async fn get_script(api: &Arc<Api>, digest: String) -> Result<Response<ApiBody>, HttpError> {
let reply = control(api, move |c| Ok(c.send(&Control::GetScript { digest })?)).await?;
match reply {
Reply::Script { digest, size, .. } => Ok(json(
StatusCode::OK,
&serde_json::json!({ "sha256": digest, "size": size }),
)),
other => Ok(reply_to_response(other)),
}
}
async fn delete_script(api: &Arc<Api>, digest: String) -> Result<Response<ApiBody>, HttpError> {
let reply = control(api, move |c| Ok(c.send(&Control::DeleteScript { digest })?)).await?;
match reply {
Reply::Ok => Ok(json(
StatusCode::OK,
&serde_json::json!({ "deleted": true }),
)),
other => Ok(reply_to_response(other)),
}
}
#[derive(Debug, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct ServeRequest {
#[serde(default)]
layer: Layer,
base_dir: std::path::PathBuf,
#[serde(default)]
secrets: std::collections::BTreeMap<String, String>,
#[serde(default)]
if_changed: bool,
}
async fn serve_fn(
api: &Arc<Api>,
name: String,
body: &[u8],
tenant: Option<String>,
) -> Result<Response<ApiBody>, HttpError> {
let request: ServeRequest = serde_json::from_slice(body).map_err(|e| {
HttpError::new(
StatusCode::BAD_REQUEST,
format!("body is not a serve request: {e}"),
)
})?;
if !request.base_dir.is_absolute() {
return Err(HttpError::new(
StatusCode::BAD_REQUEST,
format!(
"`base_dir` must be absolute, and `{}` is not\n \
→ it names a directory on the host this API runs on",
request.base_dir.display()
),
));
}
let reply = control(api, move |c| {
Ok(c.send(&Control::Serve {
tenant,
name,
spec: None,
layer: Box::new(request.layer),
base_dir: request.base_dir,
allow_host_net: false,
allow_private_net: false,
allow_unlimited: false,
secrets: request.secrets,
if_changed: request.if_changed,
})?)
})
.await?;
Ok(reply_to_response(reply))
}
async fn stop(api: &Arc<Api>, name: String) -> Result<Response<ApiBody>, HttpError> {
let wanted = name.clone();
let reply = control(api, move |c| {
Ok(c.send(&Control::Stop { name: Some(name) })?)
})
.await?;
if let Reply::Stopped { names } = &reply
&& names.is_empty()
{
return Err(HttpError::new(
StatusCode::NOT_FOUND,
format!("no function named `{wanted}`"),
));
}
Ok(reply_to_response(reply))
}
async fn logs(
api: &Arc<Api>,
name: String,
query: &str,
tenant: Option<String>,
) -> Result<Response<ApiBody>, HttpError> {
let params = Query::parse(query);
let after = params.number("after")?.unwrap_or(0);
let limit = params.number("limit")?.unwrap_or(50);
let failed = params.flag("failed")?;
let limit = u32::try_from(limit).unwrap_or(u32::MAX);
let reply = control(api, move |c| {
Ok(c.send(&Control::Logs {
name,
after,
limit,
failed,
tenant,
})?)
})
.await?;
Ok(reply_to_response(reply))
}
struct Query<'a>(Vec<(&'a str, &'a str)>);
impl<'a> Query<'a> {
fn parse(query: &'a str) -> Query<'a> {
Query(
query
.split('&')
.filter(|p| !p.is_empty())
.map(|pair| match pair.split_once('=') {
Some((k, v)) => (k, v),
None => (pair, ""),
})
.collect(),
)
}
fn get(&self, key: &str) -> Option<&'a str> {
self.0.iter().find(|(k, _)| *k == key).map(|(_, v)| *v)
}
fn number(&self, key: &str) -> Result<Option<u64>, HttpError> {
match self.get(key) {
None => Ok(None),
Some(raw) => raw.parse().map(Some).map_err(|_| {
HttpError::new(
StatusCode::BAD_REQUEST,
format!("`{key}` must be a non-negative integer, not `{raw}`"),
)
}),
}
}
fn blob(&self, key: &str) -> Result<Option<WorkspaceRequest>, HttpError> {
let Some(digest) = self.get(key).filter(|d| !d.is_empty()) else {
return Ok(None);
};
zygo_core::scripts::ScriptDigest::parse(digest)
.map_err(|e| HttpError::new(StatusCode::BAD_REQUEST, e.to_string()))?;
Ok(Some(WorkspaceRequest {
blob: Some(digest.to_string()),
..WorkspaceRequest::default()
}))
}
fn flag(&self, key: &str) -> Result<bool, HttpError> {
match self.get(key) {
None => Ok(false),
Some("") | Some("1") | Some("true") => Ok(true),
Some("0") | Some("false") => Ok(false),
Some(raw) => Err(HttpError::new(
StatusCode::BAD_REQUEST,
format!("`{key}` must be true or false, not `{raw}`"),
)),
}
}
}
#[derive(Debug, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct RunRequest {
layer: Layer,
#[serde(default)]
stdin: String,
}
async fn one_shot(api: &Arc<Api>, body: &[u8]) -> Result<Response<ApiBody>, HttpError> {
let request: RunRequest = serde_json::from_slice(body).map_err(|e| {
HttpError::new(
StatusCode::BAD_REQUEST,
format!("body is not a run request: {e}"),
)
})?;
let layer = request.layer;
let image = layer
.image
.clone()
.ok_or_else(|| HttpError::new(StatusCode::BAD_REQUEST, "`layer.image` is required"))?;
let argv = layer.cmd.clone().unwrap_or_default();
if let Some(mounts) = &layer.mounts {
for mount in mounts {
if !mount.source.is_absolute() {
return Err(HttpError::new(
StatusCode::BAD_REQUEST,
format!(
"mount source `{}` must be absolute\n \
→ a relative path in a request body has no directory to be relative to",
mount.source.display()
),
));
}
}
}
let deadline = std::time::Duration::from_millis(
layer
.timeout
.map(|t| u64::try_from(t.0.as_millis()).unwrap_or(u64::MAX))
.unwrap_or(DEFAULT_TIMEOUT_MS)
.saturating_add(RUN_GRACE_MS),
);
let exe = api.exe.clone();
let stdin = request.stdin;
let captured = tokio::task::spawn_blocking(move || {
super::oneshot::run(&exe, layer, &image, &argv, stdin.as_bytes(), deadline)
})
.await
.context("the sandbox task panicked")??;
let status = if captured.abandoned {
StatusCode::REQUEST_TIMEOUT
} else {
StatusCode::OK
};
Ok(json(
status,
&serde_json::json!({
"exit_code": captured.exit_code,
"stdout": captured.stdout,
"stderr": captured.stderr,
"timed_out": captured.timed_out,
"oom_killed": captured.oom_killed,
"peak_rss_kb": captured.peak_rss_kb,
"wall_ms": captured.wall_ms,
"started": captured.started,
"phase": captured.phase,
}),
))
}
async fn snapshot(api: &Arc<Api>) -> anyhow::Result<super::otlp::Snapshot> {
let reply = control(api, |c| Ok(c.send(&Control::List)?)).await?;
let functions = match reply {
Reply::Functions { functions } => functions,
Reply::Error { code, message } => anyhow::bail!("{}: {message}", code.as_str()),
other => anyhow::bail!("unexpected answer to `list`: {other:?}"),
};
Ok(super::otlp::Snapshot {
api_requests: api.requests.load(Ordering::Relaxed),
api_errors: api.errors.load(Ordering::Relaxed),
functions,
tenants: api.usage.lock().expect("usage").snapshot(),
})
}
async fn metrics(api: &Arc<Api>) -> Result<Response<ApiBody>, HttpError> {
let snapshot = snapshot(api).await?;
let functions = &snapshot.functions;
use std::fmt::Write as _;
let mut out = String::new();
let _ = writeln!(
out,
"# HELP zygo_api_requests_total HTTP requests received."
);
let _ = writeln!(out, "# TYPE zygo_api_requests_total counter");
let _ = writeln!(out, "zygo_api_requests_total {}", snapshot.api_requests);
let _ = writeln!(
out,
"# HELP zygo_api_errors_total HTTP requests answered with an error."
);
let _ = writeln!(out, "# TYPE zygo_api_errors_total counter");
let _ = writeln!(out, "zygo_api_errors_total {}", snapshot.api_errors);
let _ = writeln!(
out,
"# HELP zygo_function_requests_total Requests served per function."
);
let _ = writeln!(out, "# TYPE zygo_function_requests_total counter");
for f in functions {
let _ = writeln!(
out,
"zygo_function_requests_total{{fn=\"{}\"}} {}",
f.name, f.requests
);
}
let _ = writeln!(
out,
"# HELP zygo_function_failures_total Requests that failed per function."
);
let _ = writeln!(out, "# TYPE zygo_function_failures_total counter");
for f in functions {
let _ = writeln!(
out,
"zygo_function_failures_total{{fn=\"{}\"}} {}",
f.name, f.failures
);
}
let _ = writeln!(
out,
"# HELP zygo_function_rss_bytes Resident memory of the warm zygote."
);
let _ = writeln!(out, "# TYPE zygo_function_rss_bytes gauge");
for f in functions {
let _ = writeln!(
out,
"zygo_function_rss_bytes{{fn=\"{}\"}} {}",
f.name,
f.rss_kb * 1024
);
}
let _ = writeln!(
out,
"# HELP zygo_function_state Current state, one series per function set to 1."
);
let _ = writeln!(out, "# TYPE zygo_function_state gauge");
for f in functions {
let _ = writeln!(
out,
"zygo_function_state{{fn=\"{}\",state=\"{}\"}} 1",
f.name,
f.state.as_str()
);
}
Ok(Response::builder()
.status(StatusCode::OK)
.header("content-type", "text/plain; version=0.0.4; charset=utf-8")
.body(whole(Bytes::from(out)))
.expect("a valid response"))
}
fn count_usage(api: &Api, reply: &Reply) {
if let Reply::Executed { outcome } = reply {
api.usage
.lock()
.expect("usage")
.record(zygo_core::pool::Usage::from(outcome.as_ref()));
}
}
fn reply_to_json(reply: Reply) -> (StatusCode, serde_json::Value) {
match reply {
Reply::Executed { outcome } => outcome_to_json(*outcome),
Reply::Busy {
name,
in_flight,
queued,
limit,
} => (
StatusCode::TOO_MANY_REQUESTS,
serde_json::json!({
"error": format!("`{name}` is at its concurrency limit"),
"in_flight": in_flight, "queued": queued, "limit": limit,
}),
),
Reply::Warmed { name, state } => (
StatusCode::OK,
serde_json::json!({ "name": name, "state": state }),
),
Reply::Functions { functions } => (
StatusCode::OK,
serde_json::json!({ "functions": functions }),
),
Reply::Served {
name,
runtime,
rss_kb,
imports_ms,
warm_ms,
warnings,
change,
} => (
StatusCode::OK,
serde_json::json!({
"name": name, "runtime": runtime, "rss_kb": rss_kb,
"imports_ms": imports_ms, "warm_ms": warm_ms,
"warnings": warnings, "change": change,
}),
),
Reply::Stopped { names } => (StatusCode::OK, serde_json::json!({ "stopped": names })),
Reply::Drained {
in_flight,
grace_ms,
} => (
StatusCode::OK,
serde_json::json!({
"drained": in_flight == 0,
"in_flight": in_flight,
"grace_ms": grace_ms,
}),
),
Reply::Secrets { names } => (
StatusCode::OK,
serde_json::json!({ "secrets": names }),
),
Reply::Cancelled { id, started } => (
StatusCode::OK,
serde_json::json!({
"cancelled": true,
"request_id": id,
"started": started,
}),
),
Reply::Script {
digest,
size,
existed,
} => (
StatusCode::OK,
serde_json::json!({ "sha256": digest, "size": size, "existed": existed }),
),
Reply::RuntimeServed {
name,
runtime,
warm,
rss_kb,
imports_ms,
warm_ms,
warnings,
change,
} => (
StatusCode::OK,
serde_json::json!({
"name": name, "runtime": runtime, "warm": warm, "rss_kb": rss_kb,
"imports_ms": imports_ms, "warm_ms": warm_ms,
"warnings": warnings, "change": change,
}),
),
Reply::Runtimes { runtimes } => {
(StatusCode::OK, serde_json::json!({ "runtimes": runtimes }))
}
Reply::Logs {
name,
entries,
next,
} => (
StatusCode::OK,
serde_json::json!({ "name": name, "entries": entries, "next": next }),
),
Reply::Error { code, message } => {
let status = match code {
ControlError::NotFound => StatusCode::NOT_FOUND,
ControlError::BadSpec => StatusCode::BAD_REQUEST,
ControlError::WarmFailed => StatusCode::SERVICE_UNAVAILABLE,
ControlError::Unauthorised => StatusCode::FORBIDDEN,
ControlError::AboveCeiling => StatusCode::UNPROCESSABLE_ENTITY,
ControlError::DepsBuilding => StatusCode::SERVICE_UNAVAILABLE,
ControlError::VersionMismatch
| ControlError::CallFailed
| ControlError::BadMessage => StatusCode::INTERNAL_SERVER_ERROR,
};
(
status,
serde_json::json!({ "error": message, "code": code.as_str() }),
)
}
other => (
StatusCode::INTERNAL_SERVER_ERROR,
serde_json::json!({ "error": format!("unexpected reply {other:?}") }),
),
}
}
fn reply_to_response(reply: Reply) -> Response<ApiBody> {
let (status, body) = reply_to_json(reply);
let mut response = json(status, &body);
if status == StatusCode::TOO_MANY_REQUESTS {
response
.headers_mut()
.insert("retry-after", hyper::header::HeaderValue::from_static("1"));
}
if body.get("code") == Some(&serde_json::json!("deps_building")) {
response
.headers_mut()
.insert("retry-after", hyper::header::HeaderValue::from_static("5"));
}
if let Some(id) = body.get("request_id").and_then(|v| v.as_str())
&& let Ok(value) = hyper::header::HeaderValue::from_str(id)
{
response.headers_mut().insert(REQUEST_ID_HEADER, value);
}
response
}
fn outcome_to_json(outcome: Outcome) -> (StatusCode, serde_json::Value) {
let metrics = serde_json::json!({
"wall_ms": outcome.metrics.wall_ms,
"cpu_ms": outcome.metrics.cpu_ms,
"peak_rss_kb": outcome.metrics.peak_rss_kb,
});
let id = outcome.id;
if outcome.cancelled {
return (
StatusCode::from_u16(499).expect("a valid status"),
serde_json::json!({
"error": "the request was cancelled",
"cancelled": true,
"request_id": id,
"stdout": outcome.stdout,
"stderr": outcome.stderr,
"metrics": metrics,
}),
);
}
if outcome.timed_out {
return (
StatusCode::REQUEST_TIMEOUT,
serde_json::json!({
"error": "the request exceeded the function's timeout and was killed",
"request_id": id,
"stderr": outcome.stderr,
"metrics": metrics,
}),
);
}
if outcome.stuck {
return (
StatusCode::GATEWAY_TIMEOUT,
serde_json::json!({
"error": "the sandbox stopped reporting this request and it was killed",
"stuck": true,
"request_id": id,
"stdout": outcome.stdout,
"stderr": outcome.stderr,
"metrics": metrics,
}),
);
}
if let Some(error) = outcome.error {
return (
StatusCode::INTERNAL_SERVER_ERROR,
serde_json::json!({
"error": error,
"request_id": id,
"stdout": outcome.stdout,
"stderr": outcome.stderr,
"exit_code": outcome.exit_code,
"metrics": metrics,
}),
);
}
let mut body = serde_json::json!({
"result": outcome.result,
"request_id": id,
"stdout": outcome.stdout,
"stderr": outcome.stderr,
"metrics": metrics,
});
if let Some(tar) = outcome.workspace {
body["workspace"] = tar.into();
}
(StatusCode::OK, body)
}
fn json(status: StatusCode, body: &serde_json::Value) -> Response<ApiBody> {
Response::builder()
.status(status)
.header("content-type", "application/json")
.body(whole(Bytes::from(
serde_json::to_vec(body).unwrap_or_else(|_| b"{}".to_vec()),
)))
.expect("a valid response")
}
#[cfg(test)]
mod tests {
use super::*;
use zygo_core::protocol::Metrics;
fn outcome(error: Option<&str>, timed_out: bool) -> Outcome {
Outcome {
tenant: "default".into(),
function: "resize".into(),
script: None,
id: "00000001".into(),
cancelled: false,
stuck: false,
workspace: None,
exit_code: if error.is_some() { 1 } else { 0 },
result: serde_json::json!({ "ok": true }),
stdout: "hi\n".into(),
stderr: String::new(),
error: error.map(str::to_string),
metrics: Metrics {
wall_ms: 1.5,
..Default::default()
},
timed_out,
}
}
#[test]
fn the_status_codes_are_the_design_documents() {
let (s, body) = outcome_to_json(outcome(None, false));
assert_eq!(s, StatusCode::OK);
assert_eq!(body["result"]["ok"], true);
assert_eq!(body["metrics"]["wall_ms"], 1.5);
let (s, body) = outcome_to_json(outcome(Some("ZeroDivisionError"), false));
assert_eq!(s, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(body["error"], "ZeroDivisionError");
let (s, _) = outcome_to_json(outcome(None, true));
assert_eq!(s, StatusCode::REQUEST_TIMEOUT);
let (s, body) = reply_to_json(Reply::Busy {
name: "f".into(),
in_flight: 4,
queued: 16,
limit: 4,
});
assert_eq!(s, StatusCode::TOO_MANY_REQUESTS);
assert_eq!(body["limit"], 4);
}
#[test]
fn a_deadline_kill_is_a_408_not_a_500() {
let killed = Outcome {
error: Some("killed by SIGKILL (out of memory, or the deadline expired)".into()),
exit_code: 137,
..outcome(None, true)
};
assert_eq!(outcome_to_json(killed).0, StatusCode::REQUEST_TIMEOUT);
let oom = Outcome {
error: Some("killed by SIGKILL (out of memory, or the deadline expired)".into()),
exit_code: 137,
..outcome(None, false)
};
assert_eq!(outcome_to_json(oom).0, StatusCode::INTERNAL_SERVER_ERROR);
}
#[test]
fn control_errors_map_to_distinct_statuses() {
let status = |code| reply_to_json(Reply::error(code, "x")).0;
assert_eq!(status(ControlError::NotFound), StatusCode::NOT_FOUND);
assert_eq!(status(ControlError::BadSpec), StatusCode::BAD_REQUEST);
assert_eq!(
status(ControlError::WarmFailed),
StatusCode::SERVICE_UNAVAILABLE
);
assert_eq!(
status(ControlError::CallFailed),
StatusCode::INTERNAL_SERVER_ERROR
);
}
#[test]
fn listen_addresses_parse_both_forms() {
assert_eq!(
Listen::parse("127.0.0.1:7700").unwrap(),
Listen::Tcp("127.0.0.1:7700".parse().unwrap())
);
assert_eq!(
Listen::parse("unix:///tmp/api.sock").unwrap(),
Listen::Unix("/tmp/api.sock".into())
);
assert!(Listen::parse("unix://").is_err());
assert!(Listen::parse("not an address").is_err());
}
#[test]
fn no_auth_is_only_allowed_where_it_exposes_nothing() {
assert!(Listen::parse("127.0.0.1:7700").unwrap().allows_no_auth());
assert!(Listen::parse("[::1]:7700").unwrap().allows_no_auth());
assert!(
Listen::parse("unix:///tmp/api.sock")
.unwrap()
.allows_no_auth()
);
assert!(!Listen::parse("0.0.0.0:7700").unwrap().allows_no_auth());
assert!(!Listen::parse("10.0.0.5:7700").unwrap().allows_no_auth());
assert!(!Listen::parse("[::]:7700").unwrap().allows_no_auth());
}
#[test]
fn token_comparison_does_not_depend_on_where_the_difference_is() {
assert!(constant_time_eq(b"secret", b"secret"));
assert!(!constant_time_eq(b"secret", b"secrex"));
assert!(!constant_time_eq(b"secret", b"xecret"));
assert!(!constant_time_eq(b"secret", b"secre"));
assert!(!constant_time_eq(b"", b"x"));
assert!(constant_time_eq(b"", b""));
}
#[test]
fn an_empty_body_is_a_null_event() {
assert_eq!(parse_event(b"").unwrap(), serde_json::Value::Null);
assert_eq!(parse_event(b" \n").unwrap(), serde_json::Value::Null);
assert_eq!(parse_event(br#"{"n":1}"#).unwrap()["n"], 1);
assert!(parse_event(b"{nope").is_err());
}
#[test]
fn deploy_is_off_until_it_is_asked_for() {
let operator = |deploy| Actor {
tenant: None,
deploy,
};
assert!(operator(true).may_deploy().is_ok());
let refused = operator(false)
.may_deploy()
.expect_err("a call-only API refuses");
assert_eq!(refused.status, StatusCode::FORBIDDEN);
assert!(
refused.body["error"]
.as_str()
.expect("a message")
.contains("--allow-deploy"),
"the refusal has to name the flag: {:?}",
refused.body
);
}
#[test]
fn nothing_that_names_a_host_path_is_reachable_by_a_tenant() {
let tenant = Actor {
tenant: Some("acme".into()),
deploy: true,
};
for route in ROUTES_THAT_NAME_A_HOST_PATH {
let refused = tenant
.may_deploy()
.expect_err(&format!("{route} was allowed"));
assert_eq!(refused.status, StatusCode::FORBIDDEN, "{route}");
}
}
#[test]
fn a_tenant_never_deploys_however_the_api_was_started() {
for deploy in [true, false] {
let refused = Actor {
tenant: Some("acme".into()),
deploy,
}
.may_deploy()
.expect_err("a tenant cannot deploy");
assert_eq!(refused.status, StatusCode::FORBIDDEN);
let message = refused.body["error"].as_str().expect("a message");
assert!(message.contains("acme"), "{message}");
assert!(
!message.contains("--allow-deploy"),
"a tenant cannot act on that advice: {message}"
);
}
}
#[test]
fn a_listing_shows_one_tenant_their_own_functions_only() {
let status = |name: &str, tenant: &str| zygo_core::pool::Status {
name: name.into(),
tenant: tenant.into(),
image: String::new(),
state: zygo_core::sandbox::SandboxState::Warm,
runtime: "python/3.12".into(),
rss_kb: 0,
imports_ms: 0.0,
requests: 0,
failures: 0,
};
let all = vec![
status("resize", "acme"),
status("resize-2", "globex"),
status("internal", "default"),
];
let operator = mine(all.clone(), None);
assert_eq!(operator.len(), 3, "the operator sees the host");
let acme = mine(all, Some("acme"));
assert_eq!(acme.len(), 1);
assert_eq!(acme[0].name, "resize");
}
#[test]
fn a_request_body_cannot_widen_the_boundary() {
let honest: ServeRequest =
serde_json::from_str(r#"{"layer":{"image":"alpine:3"},"base_dir":"/srv"}"#)
.expect("an ordinary serve request");
assert_eq!(honest.layer.image.as_deref(), Some("alpine:3"));
for field in ["allow_host_net", "allow_private_net", "allow_unlimited"] {
let body = format!(r#"{{"layer":{{}},"base_dir":"/srv","{field}":true}}"#);
assert!(
serde_json::from_str::<ServeRequest>(&body).is_err(),
"`{field}` was accepted from a request body"
);
}
}
#[test]
fn a_relative_base_directory_is_refused_with_the_reason() {
let request: ServeRequest =
serde_json::from_str(r#"{"layer":{},"base_dir":"./app"}"#).expect("parsed");
assert!(!request.base_dir.is_absolute());
}
#[test]
fn log_query_parameters_are_parsed_or_refused() {
let query = Query::parse("after=12&limit=5&failed=true");
assert_eq!(query.number("after").unwrap(), Some(12));
assert_eq!(query.number("limit").unwrap(), Some(5));
assert!(query.flag("failed").unwrap());
let empty = Query::parse("");
assert_eq!(empty.number("after").unwrap(), None);
assert!(!empty.flag("failed").unwrap());
assert!(Query::parse("failed").flag("failed").unwrap());
assert!(!Query::parse("failed=false").flag("failed").unwrap());
assert!(Query::parse("after=soon").number("after").is_err());
assert!(Query::parse("failed=yes").flag("failed").is_err());
}
#[test]
fn the_new_replies_have_statuses_of_their_own() {
let (status, body) = reply_to_json(Reply::Served {
name: "resize".into(),
runtime: "python3.12".into(),
rss_kb: 2048,
imports_ms: 40.0,
warm_ms: 120.0,
warnings: vec!["no timeout set".into()],
change: zygo_core::supervisor::Change::Replaced,
});
assert_eq!(status, StatusCode::OK);
assert_eq!(body["change"], "replaced");
assert_eq!(body["warnings"][0], "no timeout set");
let (status, body) = reply_to_json(Reply::Stopped {
names: vec!["resize".into()],
});
assert_eq!(status, StatusCode::OK);
assert_eq!(body["stopped"][0], "resize");
let (status, body) = reply_to_json(Reply::Logs {
name: "resize".into(),
entries: Vec::new(),
next: 7,
});
assert_eq!(status, StatusCode::OK);
assert_eq!(body["next"], 7);
}
#[test]
fn a_run_request_separates_the_sandbox_from_the_call() {
let request: RunRequest = serde_json::from_str(
r#"{"layer":{"image":"alpine:3","cmd":["echo","hi"],"mem":"64M"},"stdin":"input"}"#,
)
.expect("a run request");
assert_eq!(request.layer.image.as_deref(), Some("alpine:3"));
assert_eq!(request.stdin, "input");
assert!(
serde_json::to_value(&request.layer)
.expect("serialisable")
.get("stdin")
.is_none()
);
assert!(
serde_json::from_str::<RunRequest>(r#"{"layer":{},"stdin":"x","extra":1}"#).is_err(),
"an unknown field should be refused rather than ignored"
);
}
#[test]
fn a_caller_cannot_ask_for_an_unbounded_amount_of_work() {
let over = Request::builder()
.header("x-zygo-timeout-ms", (MAX_TIMEOUT_MS + 1).to_string())
.body(())
.expect("a request");
let refused = timeout_header_from(over.headers()).expect_err("over the ceiling");
assert_eq!(refused.status, StatusCode::BAD_REQUEST);
assert!(
refused.body["error"]
.as_str()
.expect("a message")
.contains(&MAX_TIMEOUT_MS.to_string()),
"the refusal should name the ceiling: {:?}",
refused.body
);
let at_the_line = Request::builder()
.header("x-zygo-timeout-ms", MAX_TIMEOUT_MS.to_string())
.body(())
.expect("a request");
assert_eq!(
timeout_header_from(at_the_line.headers()).expect("the ceiling itself is allowed"),
MAX_TIMEOUT_MS
);
let ordinary = Request::builder()
.header("x-zygo-timeout-ms", "2500")
.body(())
.expect("a request");
assert_eq!(
timeout_header_from(ordinary.headers()).expect("an ordinary header"),
2500
);
const _: () = assert!(
BATCH_IN_FLIGHT <= MAX_IDLE_CLIENTS,
"one batch may take every pooled connection"
);
const _: () = assert!(MAX_BATCH >= BATCH_IN_FLIGHT, "the cap is below the gate");
}
}