1use crate::serve::history::{RunRecord, RunStatus};
36use schemars::JsonSchema;
37use serde::{Deserialize, Serialize};
38use serde_json::Value;
39use std::collections::BTreeMap;
40use std::time::Duration;
41
42const ATTEMPT_TIMEOUT: Duration = Duration::from_secs(10);
44const MAX_ATTEMPTS: u32 = 3;
46const RETRY_BASE: Duration = Duration::from_millis(250);
48
49pub const RESERVED_BODY_KEYS: &[&str] = &[
54 "event",
55 "run_id",
56 "status",
57 "name",
58 "labels",
59 "submitted_at",
60 "started_at",
61 "finished_at",
62 "elapsed_secs",
63 "records_written",
64 "error",
65 "attempt",
66];
67
68#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
70#[serde(deny_unknown_fields)]
71pub struct CallbackSpec {
72 pub url: String,
74 #[serde(default = "default_method")]
76 pub method: String,
77 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
85 pub headers: BTreeMap<String, String>,
86 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
90 pub extra_fields: BTreeMap<String, Value>,
91 #[serde(default, skip_serializing_if = "Vec::is_empty")]
95 pub on: Vec<RunStatus>,
96}
97
98fn default_method() -> String {
99 "POST".to_string()
100}
101
102impl CallbackSpec {
103 pub fn validate(&self, allow_hosts: &[String]) -> Result<(), String> {
107 if self.url.trim().is_empty() {
108 return Err("callback.url must be non-empty".into());
109 }
110 if self.method.trim().is_empty() {
111 return Err("callback.method must be non-empty".into());
112 }
113 if reqwest::Method::from_bytes(self.method.as_bytes()).is_err() {
114 return Err(format!("callback.method `{}` is not valid", self.method));
115 }
116 for key in self.extra_fields.keys() {
117 if RESERVED_BODY_KEYS.contains(&key.as_str()) {
118 return Err(format!(
119 "callback.extra_fields.{key} collides with a field faucet emits — \
120 reserved keys are: {}",
121 RESERVED_BODY_KEYS.join(", ")
122 ));
123 }
124 }
125 for st in &self.on {
126 if !st.is_terminal() {
127 return Err(format!(
128 "callback.on contains non-terminal status `{}` — a callback \
129 only fires on a terminal state (completed, failed, cancelled)",
130 st.as_str()
131 ));
132 }
133 }
134
135 let url = reqwest::Url::parse(&self.url)
136 .map_err(|e| format!("callback.url is not a valid URL: {e}"))?;
137 match url.scheme() {
138 "http" | "https" => {}
139 other => {
140 return Err(format!(
141 "callback.url scheme `{other}` is not allowed (http or https only)"
142 ));
143 }
144 }
145 let host = url
146 .host_str()
147 .ok_or_else(|| "callback.url has no host".to_string())?;
148
149 if !allow_hosts.is_empty() {
150 if !allow_hosts.iter().any(|h| h == host) {
151 return Err(format!(
152 "callback.url host `{host}` is not in the server's callback allowlist \
153 ({}) — set --callback-allow-host to permit it",
154 allow_hosts.join(", ")
155 ));
156 }
157 return Ok(());
159 }
160
161 if is_link_local(host) {
162 return Err(format!(
163 "callback.url host `{host}` is a link-local / cloud-metadata address, \
164 which the server refuses to call. Add it to --callback-allow-host if \
165 this is genuinely intended"
166 ));
167 }
168 Ok(())
169 }
170
171 pub fn reject_secrets_in_cluster(&self, clustered: bool) -> Result<(), String> {
176 if clustered && !self.headers.is_empty() {
177 return Err(
178 "this callback carries `headers`, and a clustered server persists the run \
179 record so a peer can execute it — which would store those values in the \
180 shared run-history database in clear text. Authenticate the callback \
181 without a request header (e.g. a capability token embedded in a \
182 single-use URL path), or submit to a non-clustered server"
183 .into(),
184 );
185 }
186 Ok(())
187 }
188
189 pub fn fires_on(&self, status: RunStatus) -> bool {
191 status.is_terminal() && (self.on.is_empty() || self.on.contains(&status))
192 }
193}
194
195fn is_link_local(host: &str) -> bool {
198 let bare = host.trim_start_matches('[').trim_end_matches(']');
200 match bare.parse::<std::net::IpAddr>() {
201 Ok(std::net::IpAddr::V4(v4)) => v4.is_link_local(),
202 Ok(std::net::IpAddr::V6(v6)) => {
203 let seg = v6.segments()[0];
205 (seg & 0xffc0) == 0xfe80
206 }
207 Err(_) => matches!(
211 bare,
212 "metadata" | "metadata.google.internal" | "metadata.goog" | "instance-data"
213 ),
214 }
215}
216
217fn payload(rec: &RunRecord, spec: &CallbackSpec) -> Value {
219 let mut body = serde_json::json!({
220 "event": format!("run.{}", rec.status.as_str()),
221 "run_id": rec.run_id,
222 "status": rec.status.as_str(),
223 "name": rec.name.clone().map_or(Value::Null, Value::String),
224 "labels": rec.labels.iter()
225 .map(|(k, v)| (k.clone(), Value::String(v.clone())))
226 .collect::<serde_json::Map<String, Value>>(),
227 "submitted_at": rec.submitted_at.to_rfc3339(),
228 "started_at": rec.started_at.map_or(Value::Null, |t| Value::String(t.to_rfc3339())),
229 "finished_at": rec.finished_at.map_or(Value::Null, |t| Value::String(t.to_rfc3339())),
230 "elapsed_secs": rec.elapsed_secs
231 .and_then(serde_json::Number::from_f64)
232 .map_or(Value::Null, Value::Number),
233 "records_written": rec.records_written,
234 "error": rec.error.as_deref()
238 .map(|e| Value::String(crate::secrets::registry::redact(e).into_owned()))
239 .unwrap_or(Value::Null),
240 "attempt": rec.attempt,
241 });
242 if let Some(map) = body.as_object_mut() {
243 for (k, v) in &spec.extra_fields {
244 map.insert(k.clone(), v.clone());
245 }
246 }
247 body
248}
249
250pub async fn fire(rec: &RunRecord) {
254 let Some(spec) = rec.callback.as_ref() else {
255 return;
256 };
257 if !spec.fires_on(rec.status) {
258 return;
259 }
260 let body = payload(rec, spec);
261 match deliver(spec, &body).await {
262 Ok(()) => tracing::debug!(run_id = %rec.run_id, "callback delivered"),
263 Err(e) => tracing::warn!(
264 run_id = %rec.run_id,
265 url = %crate::secrets::registry::redact(&spec.url),
267 error = %e,
268 "callback delivery failed; the run outcome is unaffected \
269 (reconcile via GET /v1/runs/<id>)"
270 ),
271 }
272}
273
274async fn deliver(spec: &CallbackSpec, body: &Value) -> Result<(), String> {
276 let client = reqwest::Client::builder()
277 .timeout(ATTEMPT_TIMEOUT)
278 .build()
279 .map_err(|e| format!("building callback client: {e}"))?;
280 let method = reqwest::Method::from_bytes(spec.method.as_bytes())
281 .map_err(|_| format!("invalid method `{}`", spec.method))?;
282
283 let mut last = String::new();
284 for attempt in 1..=MAX_ATTEMPTS {
285 let mut req = client
286 .request(method.clone(), &spec.url)
287 .header(reqwest::header::CONTENT_TYPE, "application/json")
288 .json(body);
289 for (k, v) in &spec.headers {
290 req = req.header(k, v);
291 }
292 match req.send().await {
293 Ok(resp) if resp.status().is_success() => return Ok(()),
294 Ok(resp) => {
295 let status = resp.status();
296 last = format!("HTTP {status}");
297 if status.is_client_error()
299 && status != reqwest::StatusCode::REQUEST_TIMEOUT
300 && status != reqwest::StatusCode::TOO_MANY_REQUESTS
301 {
302 return Err(last);
303 }
304 }
305 Err(e) => last = format!("request failed: {e}"),
306 }
307 if attempt < MAX_ATTEMPTS {
308 tokio::time::sleep(RETRY_BASE * 2u32.pow(attempt - 1)).await;
309 }
310 }
311 Err(last)
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317
318 fn spec(url: &str) -> CallbackSpec {
319 CallbackSpec {
320 url: url.into(),
321 method: "POST".into(),
322 headers: BTreeMap::new(),
323 extra_fields: BTreeMap::new(),
324 on: Vec::new(),
325 }
326 }
327
328 #[test]
329 fn accepts_a_plain_https_url() {
330 assert!(spec("https://caller.example/hook").validate(&[]).is_ok());
331 }
332
333 #[test]
334 fn rejects_non_http_schemes() {
335 for u in ["file:///etc/passwd", "gopher://x/", "ftp://x/"] {
336 let err = spec(u).validate(&[]).expect_err("scheme must be refused");
337 assert!(err.contains("scheme"), "{err}");
338 }
339 }
340
341 #[test]
342 fn rejects_link_local_and_metadata_targets() {
343 for u in [
345 "http://169.254.169.254/latest/meta-data/",
346 "http://metadata.google.internal/computeMetadata/v1/",
347 "http://[fe80::1]/",
348 ] {
349 let err = spec(u).validate(&[]).expect_err("must be refused");
350 assert!(err.contains("link-local"), "{err}");
351 }
352 }
353
354 #[test]
355 fn loopback_is_allowed_without_an_allowlist() {
356 assert!(spec("http://127.0.0.1:8080/cb").validate(&[]).is_ok());
359 }
360
361 #[test]
362 fn allowlist_restricts_to_named_hosts() {
363 let allow = vec!["caller.example".to_string()];
364 assert!(spec("https://caller.example/hook").validate(&allow).is_ok());
365 let err = spec("https://elsewhere.example/hook")
366 .validate(&allow)
367 .expect_err("must be refused");
368 assert!(err.contains("allowlist"), "{err}");
369 }
370
371 #[test]
372 fn allowlist_overrides_the_link_local_refusal() {
373 let allow = vec!["169.254.169.254".to_string()];
374 assert!(
375 spec("http://169.254.169.254/x").validate(&allow).is_ok(),
376 "an explicitly allowlisted host is trusted"
377 );
378 }
379
380 #[test]
381 fn rejects_reserved_extra_field_keys() {
382 for key in RESERVED_BODY_KEYS {
383 let mut s = spec("https://x.example/h");
384 s.extra_fields
385 .insert((*key).to_string(), Value::String("x".into()));
386 let err = s.validate(&[]).expect_err("reserved key must be refused");
387 assert!(err.contains(key), "{err}");
388 }
389 }
390
391 #[test]
392 fn rejects_a_non_terminal_on_filter() {
393 let mut s = spec("https://x.example/h");
394 s.on = vec![RunStatus::Running];
395 let err = s.validate(&[]).expect_err("must be refused");
396 assert!(err.contains("non-terminal"), "{err}");
397 }
398
399 #[test]
400 fn rejects_bad_method_and_empty_url() {
401 let mut s = spec("https://x.example/h");
402 s.method = "NOT A METHOD".into();
403 assert!(s.validate(&[]).is_err());
404 assert!(spec(" ").validate(&[]).is_err());
405 }
406
407 #[test]
408 fn cluster_guard_refuses_caller_supplied_headers() {
409 let mut s = spec("https://x.example/h");
410 s.headers
411 .insert("Authorization".into(), "Bearer t".to_string());
412 assert!(s.reject_secrets_in_cluster(false).is_ok());
414 let err = s
415 .reject_secrets_in_cluster(true)
416 .expect_err("clustered must refuse");
417 assert!(err.contains("shared run-history"), "{err}");
418 assert!(
420 spec("https://x.example/h")
421 .reject_secrets_in_cluster(true)
422 .is_ok()
423 );
424 }
425
426 #[test]
427 fn fires_on_respects_the_filter_and_terminality() {
428 let mut s = spec("https://x.example/h");
429 assert!(s.fires_on(RunStatus::Completed));
431 assert!(s.fires_on(RunStatus::Failed));
432 assert!(s.fires_on(RunStatus::Cancelled));
433 assert!(!s.fires_on(RunStatus::Running));
434 assert!(!s.fires_on(RunStatus::Queued));
435
436 s.on = vec![RunStatus::Failed];
437 assert!(s.fires_on(RunStatus::Failed));
438 assert!(!s.fires_on(RunStatus::Completed));
439 }
440
441 #[test]
442 fn payload_carries_run_identity_and_merges_extra_fields() {
443 let mut rec = RunRecord::queued(
444 "run-7".into(),
445 Some("orders".into()),
446 BTreeMap::from([("env".to_string(), "prod".to_string())]),
447 None,
448 chrono::Utc::now(),
449 );
450 rec.status = RunStatus::Completed;
451 rec.records_written = 42;
452 rec.finished_at = Some(chrono::Utc::now());
453 rec.elapsed_secs = Some(1.5);
454
455 let mut s = spec("https://x.example/h");
456 s.extra_fields
457 .insert("job_id".into(), Value::String("abc".into()));
458
459 let body = payload(&rec, &s);
460 assert_eq!(body["event"], "run.completed");
461 assert_eq!(body["run_id"], "run-7");
462 assert_eq!(body["status"], "completed");
463 assert_eq!(body["name"], "orders");
464 assert_eq!(body["labels"]["env"], "prod");
465 assert_eq!(body["records_written"], 42);
466 assert_eq!(body["elapsed_secs"], 1.5);
467 assert!(body["error"].is_null());
468 assert_eq!(body["job_id"], "abc");
469 }
470
471 #[test]
472 fn payload_emits_null_for_absent_optional_fields() {
473 let rec = RunRecord::queued("r".into(), None, BTreeMap::new(), None, chrono::Utc::now());
474 let body = payload(&rec, &spec("https://x.example/h"));
475 for k in ["name", "started_at", "finished_at", "elapsed_secs", "error"] {
476 assert!(body.get(k).is_some(), "{k} key must exist");
477 assert!(body[k].is_null(), "{k} must be null");
478 }
479 }
480}