Skip to main content

faucet_cli/serve/
callback.rs

1//! Per-run completion callbacks (#481).
2//!
3//! A caller that submits a run over HTTP usually owns a job record and wants to
4//! be told when the run finishes, at an endpoint that varies per job. The
5//! config-declared `notifications:` block cannot express that — it is static per
6//! pipeline — so the callback destination rides the *submission* instead
7//! (`POST /v1/runs`, `POST /v1/templates/{id}/runs`) and is stored on the run
8//! record.
9//!
10//! ## Delivery guarantee: at-most-once
11//!
12//! The callback fires from the in-process terminal transitions — [`fire`] is
13//! called by `runner::finalize`, by `runner::maybe_finalize_parent` (the sharded
14//! parent), and by the pending-cancel handler. It is **not** fired by the
15//! recovery sweeps that live inside the history backend (lease-expiry orphan
16//! recovery, reclaim-poison, the sharded-parent completion sweep), because those
17//! run inside the SQL layer and never see this dispatcher.
18//!
19//! So: if the instance owning a run dies and the run is later failed by lease
20//! recovery, **no callback is delivered**. A caller must therefore treat a
21//! missing callback as "unknown", not as "still running", and reconcile against
22//! `GET /v1/runs/{id}`, which is always authoritative. This is stated in the
23//! HTTP API reference too — it is the one thing that will hang an integration
24//! that assumes otherwise.
25//!
26//! ## Egress
27//!
28//! [`CallbackSpec::validate`] restricts the scheme to http/https and refuses
29//! link-local / cloud-metadata targets, which is the concrete SSRF risk called
30//! out in the serve cookbook. `--callback-allow-host` narrows it further to an
31//! explicit allowlist. Note the broader posture is unchanged: a caller who can
32//! submit a run can already point a `rest` source anywhere, so this guard closes
33//! the metadata hole rather than pretending to be a general egress control.
34
35use 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
42/// Per-attempt HTTP timeout for a callback POST.
43const ATTEMPT_TIMEOUT: Duration = Duration::from_secs(10);
44/// Total attempts (1 initial + retries) before giving up.
45const MAX_ATTEMPTS: u32 = 3;
46/// Base backoff between attempts.
47const RETRY_BASE: Duration = Duration::from_millis(250);
48
49/// Top-level keys the callback body always carries. `extra_fields` may not
50/// shadow any of them — the same fail-fast rule the notify webhook uses, for the
51/// same reason: a typo'd `status` key would otherwise let a submission spoof the
52/// very signal the receiver keys off.
53pub 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/// A caller-supplied completion callback, carried on the run record.
69#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
70#[serde(deny_unknown_fields)]
71pub struct CallbackSpec {
72    /// Destination URL. `http`/`https` only.
73    pub url: String,
74    /// HTTP method — defaults to `POST`.
75    #[serde(default = "default_method")]
76    pub method: String,
77    /// Extra headers sent with the request.
78    ///
79    /// These are **credentials** in practice, so a clustered server refuses them
80    /// (see [`CallbackSpec::reject_secrets_in_cluster`]): a clustered submit
81    /// persists the run record — including this map — into the shared history
82    /// database for a peer to execute, which would store the value in clear
83    /// text.
84    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
85    pub headers: BTreeMap<String, String>,
86    /// Static fields merged into the callback body (e.g. the caller's own job
87    /// id). A key colliding with a faucet-emitted field is rejected at submit
88    /// time — see [`RESERVED_BODY_KEYS`].
89    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
90    pub extra_fields: BTreeMap<String, Value>,
91    /// Terminal statuses to fire on. Empty (the default) means **all** of them,
92    /// which is what a job-status callback wants — a caller that only subscribes
93    /// to success will hang on a cancelled run.
94    #[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    /// Structural + egress validation, run at submit time so a bad callback is a
104    /// `422` on the submission rather than a silent no-op an hour later when the
105    /// run finishes.
106    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            // An explicitly allowlisted host is trusted, metadata range or not.
158            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    /// A clustered submit persists the run record for a peer to execute, so
172    /// caller-supplied credentials would land in the shared history database in
173    /// clear text. Mirrors the guard the template registry already applies to
174    /// secret params.
175    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    /// Whether this spec subscribes to `status`.
190    pub fn fires_on(&self, status: RunStatus) -> bool {
191        status.is_terminal() && (self.on.is_empty() || self.on.contains(&status))
192    }
193}
194
195/// Whether `host` is a link-local address (IPv4 `169.254.0.0/16`, IPv6
196/// `fe80::/10`) — the range cloud instance-metadata services live on.
197fn is_link_local(host: &str) -> bool {
198    // Strip an IPv6 literal's brackets if the caller passed them.
199    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            // `Ipv6Addr::is_unicast_link_local` is unstable; check fe80::/10.
204            let seg = v6.segments()[0];
205            (seg & 0xffc0) == 0xfe80
206        }
207        // Not an IP literal. The well-known metadata hostnames resolve into the
208        // link-local range, so refuse them by name too rather than relying on
209        // resolution at request time.
210        Err(_) => matches!(
211            bare,
212            "metadata" | "metadata.google.internal" | "metadata.goog" | "instance-data"
213        ),
214    }
215}
216
217/// The body delivered to the callback URL.
218fn 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        // Redacted: an error string frequently embeds the material that produced
235        // it (a request URL with an API key in a query param), and a callback
236        // leaves the trust boundary entirely.
237        "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
250/// Deliver the completion callback for `rec`, if it has one that subscribes to
251/// its terminal status. Never propagates an error: a callback is best-effort and
252/// must not affect the recorded outcome of a run that already finished.
253pub 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            // The URL can itself embed a token, so redact before logging.
266            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
274/// One bounded, retried delivery attempt sequence.
275async 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                // 4xx other than 408/429 will not succeed on retry.
298                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        // The concrete SSRF risk documented for serve: cloud instance metadata.
344        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        // Deliberate: the guard closes the metadata hole, it is not a general
357        // egress control, and a local receiver is a legitimate deployment.
358        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        // Non-clustered: fine, nothing is persisted for a peer.
413        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        // Without headers a clustered submit is fine.
419        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        // Empty filter = every terminal status.
430        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}