use crate::serve::history::{RunRecord, RunStatus};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
use std::time::Duration;
const ATTEMPT_TIMEOUT: Duration = Duration::from_secs(10);
const MAX_ATTEMPTS: u32 = 3;
const RETRY_BASE: Duration = Duration::from_millis(250);
pub const RESERVED_BODY_KEYS: &[&str] = &[
"event",
"run_id",
"status",
"name",
"labels",
"submitted_at",
"started_at",
"finished_at",
"elapsed_secs",
"records_written",
"error",
"attempt",
];
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct CallbackSpec {
pub url: String,
#[serde(default = "default_method")]
pub method: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub headers: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub extra_fields: BTreeMap<String, Value>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub on: Vec<RunStatus>,
}
fn default_method() -> String {
"POST".to_string()
}
impl CallbackSpec {
pub fn validate(&self, allow_hosts: &[String]) -> Result<(), String> {
if self.url.trim().is_empty() {
return Err("callback.url must be non-empty".into());
}
if self.method.trim().is_empty() {
return Err("callback.method must be non-empty".into());
}
if reqwest::Method::from_bytes(self.method.as_bytes()).is_err() {
return Err(format!("callback.method `{}` is not valid", self.method));
}
for key in self.extra_fields.keys() {
if RESERVED_BODY_KEYS.contains(&key.as_str()) {
return Err(format!(
"callback.extra_fields.{key} collides with a field faucet emits — \
reserved keys are: {}",
RESERVED_BODY_KEYS.join(", ")
));
}
}
for st in &self.on {
if !st.is_terminal() {
return Err(format!(
"callback.on contains non-terminal status `{}` — a callback \
only fires on a terminal state (completed, failed, cancelled)",
st.as_str()
));
}
}
let url = reqwest::Url::parse(&self.url)
.map_err(|e| format!("callback.url is not a valid URL: {e}"))?;
match url.scheme() {
"http" | "https" => {}
other => {
return Err(format!(
"callback.url scheme `{other}` is not allowed (http or https only)"
));
}
}
let host = url
.host_str()
.ok_or_else(|| "callback.url has no host".to_string())?;
if !allow_hosts.is_empty() {
if !allow_hosts.iter().any(|h| h == host) {
return Err(format!(
"callback.url host `{host}` is not in the server's callback allowlist \
({}) — set --callback-allow-host to permit it",
allow_hosts.join(", ")
));
}
return Ok(());
}
if is_link_local(host) {
return Err(format!(
"callback.url host `{host}` is a link-local / cloud-metadata address, \
which the server refuses to call. Add it to --callback-allow-host if \
this is genuinely intended"
));
}
Ok(())
}
pub fn reject_secrets_in_cluster(&self, clustered: bool) -> Result<(), String> {
if clustered && !self.headers.is_empty() {
return Err(
"this callback carries `headers`, and a clustered server persists the run \
record so a peer can execute it — which would store those values in the \
shared run-history database in clear text. Authenticate the callback \
without a request header (e.g. a capability token embedded in a \
single-use URL path), or submit to a non-clustered server"
.into(),
);
}
Ok(())
}
pub fn fires_on(&self, status: RunStatus) -> bool {
status.is_terminal() && (self.on.is_empty() || self.on.contains(&status))
}
}
fn is_link_local(host: &str) -> bool {
let bare = host.trim_start_matches('[').trim_end_matches(']');
match bare.parse::<std::net::IpAddr>() {
Ok(std::net::IpAddr::V4(v4)) => v4.is_link_local(),
Ok(std::net::IpAddr::V6(v6)) => {
let seg = v6.segments()[0];
(seg & 0xffc0) == 0xfe80
}
Err(_) => matches!(
bare,
"metadata" | "metadata.google.internal" | "metadata.goog" | "instance-data"
),
}
}
fn payload(rec: &RunRecord, spec: &CallbackSpec) -> Value {
let mut body = serde_json::json!({
"event": format!("run.{}", rec.status.as_str()),
"run_id": rec.run_id,
"status": rec.status.as_str(),
"name": rec.name.clone().map_or(Value::Null, Value::String),
"labels": rec.labels.iter()
.map(|(k, v)| (k.clone(), Value::String(v.clone())))
.collect::<serde_json::Map<String, Value>>(),
"submitted_at": rec.submitted_at.to_rfc3339(),
"started_at": rec.started_at.map_or(Value::Null, |t| Value::String(t.to_rfc3339())),
"finished_at": rec.finished_at.map_or(Value::Null, |t| Value::String(t.to_rfc3339())),
"elapsed_secs": rec.elapsed_secs
.and_then(serde_json::Number::from_f64)
.map_or(Value::Null, Value::Number),
"records_written": rec.records_written,
"error": rec.error.as_deref()
.map(|e| Value::String(crate::secrets::registry::redact(e).into_owned()))
.unwrap_or(Value::Null),
"attempt": rec.attempt,
});
if let Some(map) = body.as_object_mut() {
for (k, v) in &spec.extra_fields {
map.insert(k.clone(), v.clone());
}
}
body
}
pub async fn fire(rec: &RunRecord) {
let Some(spec) = rec.callback.as_ref() else {
return;
};
if !spec.fires_on(rec.status) {
return;
}
let body = payload(rec, spec);
match deliver(spec, &body).await {
Ok(()) => tracing::debug!(run_id = %rec.run_id, "callback delivered"),
Err(e) => tracing::warn!(
run_id = %rec.run_id,
url = %crate::secrets::registry::redact(&spec.url),
error = %e,
"callback delivery failed; the run outcome is unaffected \
(reconcile via GET /v1/runs/<id>)"
),
}
}
async fn deliver(spec: &CallbackSpec, body: &Value) -> Result<(), String> {
let client = reqwest::Client::builder()
.timeout(ATTEMPT_TIMEOUT)
.build()
.map_err(|e| format!("building callback client: {e}"))?;
let method = reqwest::Method::from_bytes(spec.method.as_bytes())
.map_err(|_| format!("invalid method `{}`", spec.method))?;
let mut last = String::new();
for attempt in 1..=MAX_ATTEMPTS {
let mut req = client
.request(method.clone(), &spec.url)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.json(body);
for (k, v) in &spec.headers {
req = req.header(k, v);
}
match req.send().await {
Ok(resp) if resp.status().is_success() => return Ok(()),
Ok(resp) => {
let status = resp.status();
last = format!("HTTP {status}");
if status.is_client_error()
&& status != reqwest::StatusCode::REQUEST_TIMEOUT
&& status != reqwest::StatusCode::TOO_MANY_REQUESTS
{
return Err(last);
}
}
Err(e) => last = format!("request failed: {e}"),
}
if attempt < MAX_ATTEMPTS {
tokio::time::sleep(RETRY_BASE * 2u32.pow(attempt - 1)).await;
}
}
Err(last)
}
#[cfg(test)]
mod tests {
use super::*;
fn spec(url: &str) -> CallbackSpec {
CallbackSpec {
url: url.into(),
method: "POST".into(),
headers: BTreeMap::new(),
extra_fields: BTreeMap::new(),
on: Vec::new(),
}
}
#[test]
fn accepts_a_plain_https_url() {
assert!(spec("https://caller.example/hook").validate(&[]).is_ok());
}
#[test]
fn rejects_non_http_schemes() {
for u in ["file:///etc/passwd", "gopher://x/", "ftp://x/"] {
let err = spec(u).validate(&[]).expect_err("scheme must be refused");
assert!(err.contains("scheme"), "{err}");
}
}
#[test]
fn rejects_link_local_and_metadata_targets() {
for u in [
"http://169.254.169.254/latest/meta-data/",
"http://metadata.google.internal/computeMetadata/v1/",
"http://[fe80::1]/",
] {
let err = spec(u).validate(&[]).expect_err("must be refused");
assert!(err.contains("link-local"), "{err}");
}
}
#[test]
fn loopback_is_allowed_without_an_allowlist() {
assert!(spec("http://127.0.0.1:8080/cb").validate(&[]).is_ok());
}
#[test]
fn allowlist_restricts_to_named_hosts() {
let allow = vec!["caller.example".to_string()];
assert!(spec("https://caller.example/hook").validate(&allow).is_ok());
let err = spec("https://elsewhere.example/hook")
.validate(&allow)
.expect_err("must be refused");
assert!(err.contains("allowlist"), "{err}");
}
#[test]
fn allowlist_overrides_the_link_local_refusal() {
let allow = vec!["169.254.169.254".to_string()];
assert!(
spec("http://169.254.169.254/x").validate(&allow).is_ok(),
"an explicitly allowlisted host is trusted"
);
}
#[test]
fn rejects_reserved_extra_field_keys() {
for key in RESERVED_BODY_KEYS {
let mut s = spec("https://x.example/h");
s.extra_fields
.insert((*key).to_string(), Value::String("x".into()));
let err = s.validate(&[]).expect_err("reserved key must be refused");
assert!(err.contains(key), "{err}");
}
}
#[test]
fn rejects_a_non_terminal_on_filter() {
let mut s = spec("https://x.example/h");
s.on = vec![RunStatus::Running];
let err = s.validate(&[]).expect_err("must be refused");
assert!(err.contains("non-terminal"), "{err}");
}
#[test]
fn rejects_bad_method_and_empty_url() {
let mut s = spec("https://x.example/h");
s.method = "NOT A METHOD".into();
assert!(s.validate(&[]).is_err());
assert!(spec(" ").validate(&[]).is_err());
}
#[test]
fn cluster_guard_refuses_caller_supplied_headers() {
let mut s = spec("https://x.example/h");
s.headers
.insert("Authorization".into(), "Bearer t".to_string());
assert!(s.reject_secrets_in_cluster(false).is_ok());
let err = s
.reject_secrets_in_cluster(true)
.expect_err("clustered must refuse");
assert!(err.contains("shared run-history"), "{err}");
assert!(
spec("https://x.example/h")
.reject_secrets_in_cluster(true)
.is_ok()
);
}
#[test]
fn fires_on_respects_the_filter_and_terminality() {
let mut s = spec("https://x.example/h");
assert!(s.fires_on(RunStatus::Completed));
assert!(s.fires_on(RunStatus::Failed));
assert!(s.fires_on(RunStatus::Cancelled));
assert!(!s.fires_on(RunStatus::Running));
assert!(!s.fires_on(RunStatus::Queued));
s.on = vec![RunStatus::Failed];
assert!(s.fires_on(RunStatus::Failed));
assert!(!s.fires_on(RunStatus::Completed));
}
#[test]
fn payload_carries_run_identity_and_merges_extra_fields() {
let mut rec = RunRecord::queued(
"run-7".into(),
Some("orders".into()),
BTreeMap::from([("env".to_string(), "prod".to_string())]),
None,
chrono::Utc::now(),
);
rec.status = RunStatus::Completed;
rec.records_written = 42;
rec.finished_at = Some(chrono::Utc::now());
rec.elapsed_secs = Some(1.5);
let mut s = spec("https://x.example/h");
s.extra_fields
.insert("job_id".into(), Value::String("abc".into()));
let body = payload(&rec, &s);
assert_eq!(body["event"], "run.completed");
assert_eq!(body["run_id"], "run-7");
assert_eq!(body["status"], "completed");
assert_eq!(body["name"], "orders");
assert_eq!(body["labels"]["env"], "prod");
assert_eq!(body["records_written"], 42);
assert_eq!(body["elapsed_secs"], 1.5);
assert!(body["error"].is_null());
assert_eq!(body["job_id"], "abc");
}
#[test]
fn payload_emits_null_for_absent_optional_fields() {
let rec = RunRecord::queued("r".into(), None, BTreeMap::new(), None, chrono::Utc::now());
let body = payload(&rec, &spec("https://x.example/h"));
for k in ["name", "started_at", "finished_at", "elapsed_secs", "error"] {
assert!(body.get(k).is_some(), "{k} key must exist");
assert!(body[k].is_null(), "{k} must be null");
}
}
}