use std::sync::Arc;
use hyper::{Response, StatusCode};
use zygo_core::supervisor::{Request as Control, Response as Reply};
use super::reply::{ApiBody, HttpError, json, reply_to_response};
use super::{Api, control};
use zygo_core::spec::Layer;
use zygo_core::supervisor::WorkspaceRequest;
use anyhow::Context;
use super::reply::reply_to_json;
use super::request::{DEFAULT_TIMEOUT_MS, Query};
use super::usage::count_usage;
pub(super) const MAX_BATCH: usize = 1024;
pub(super) const BATCH_IN_FLIGHT: usize = 16;
const RUN_GRACE_MS: u64 = 30_000;
pub(super) 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))
}
pub(super) 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
}
pub(super) 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(),
}
}
pub(super) 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)),
}
}
pub(super) 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)),
}
}
pub(super) 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))
}
pub(super) 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))
}
#[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,
}
pub(super) 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))
}
pub(super) 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),
runtimes: false,
})?)
})
.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))
}
pub(super) 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))
}
#[derive(Debug, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct RunRequest {
layer: Layer,
#[serde(default)]
stdin: String,
}
pub(super) 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 || {
crate::cmd::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,
}),
))
}
#[cfg(test)]
mod tests {
use super::*;
fn status(name: &str, tenant: &str) -> zygo_core::pool::Status {
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: 3,
failures: 1,
}
}
#[test]
fn a_listing_shows_one_tenant_their_own_functions_only() {
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 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"
);
}
}