Skip to main content

faucet_cli/notify/
spec.rs

1//! Config types for the `notifications:` block (#280).
2//!
3//! One [`NotificationSpec`] is a rule: which [`EventKind`]s it fires on, a
4//! severity floor, an optional leading-edge coalesce window, and a single
5//! delivery [`ChannelSpec`]. The channel uses the project-wide adjacently
6//! tagged `{ type, config }` shape (matching connectors / shared auth /
7//! lineage transports).
8//!
9//! Everything here is pure data + validation. Dispatch lives in
10//! [`crate::notify::dispatch`]; rendering in [`crate::notify::render`].
11
12use crate::error::{CliError, CliResult};
13use schemars::JsonSchema;
14use serde::{Deserialize, Serialize};
15
16/// A single notification rule.
17#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
18#[serde(deny_unknown_fields)]
19pub struct NotificationSpec {
20    /// Stable, human-readable name. Used in metric labels, dedupe keys, and
21    /// log lines — keep it unique across rules.
22    pub name: String,
23
24    /// Event kinds this rule fires on. **Empty = every kind.**
25    #[serde(default)]
26    pub on: Vec<EventKind>,
27
28    /// Only deliver events at or above this severity. Defaults to `info`
29    /// (deliver everything the `on` selector matches).
30    #[serde(default)]
31    pub min_severity: Severity,
32
33    /// Coalesce repeated identical events (same rule + dedupe key) within this
34    /// many seconds — leading-edge: the first event fires, subsequent ones
35    /// inside the window are dropped as `coalesced`. Absent / `0` disables
36    /// coalescing.
37    #[serde(default)]
38    pub dedupe_window_secs: Option<u64>,
39
40    /// For the `dlq_threshold` event only: fire when a run routed **at least**
41    /// this many rows to the DLQ. Defaults to `1` (any DLQ traffic).
42    #[serde(default)]
43    pub dlq_threshold: Option<u64>,
44
45    /// The delivery channel.
46    pub channel: ChannelSpec,
47}
48
49/// The lifecycle / health events a rule can subscribe to.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
51#[serde(rename_all = "snake_case")]
52pub enum EventKind {
53    /// A pipeline run (or its final flush) failed.
54    RunFailure,
55    /// A pipeline run completed successfully.
56    RunSuccess,
57    /// A post-run SLA check was violated (staleness / min_rows / volume).
58    SlaBreach,
59    /// The resilience circuit breaker tripped open.
60    CircuitOpen,
61    /// A data contract breach aborted the run (`on_breach: fail`).
62    ContractAbort,
63    /// A run routed rows to the dead-letter queue at/over the configured
64    /// threshold.
65    DlqThreshold,
66    /// The cron scheduler appears stuck (no heartbeat / consecutive-failure
67    /// exit). Emitted by `faucet schedule`.
68    SchedulerStuck,
69}
70
71impl EventKind {
72    /// Stable metric-label / log string.
73    pub fn as_str(self) -> &'static str {
74        match self {
75            EventKind::RunFailure => "run_failure",
76            EventKind::RunSuccess => "run_success",
77            EventKind::SlaBreach => "sla_breach",
78            EventKind::CircuitOpen => "circuit_open",
79            EventKind::ContractAbort => "contract_abort",
80            EventKind::DlqThreshold => "dlq_threshold",
81            EventKind::SchedulerStuck => "scheduler_stuck",
82        }
83    }
84}
85
86/// Event severity — ordered so `min_severity` can gate with `>=`. A rule with
87/// no `min_severity` defaults to the lowest floor (`info`), so it delivers
88/// everything it subscribes to.
89#[derive(
90    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize, JsonSchema,
91)]
92#[serde(rename_all = "snake_case")]
93pub enum Severity {
94    /// Informational (e.g. `run_success`).
95    #[default]
96    Info,
97    /// A degraded but non-failing condition (e.g. `sla_breach`, `dlq_threshold`).
98    Warning,
99    /// A failure that stopped the run (e.g. `run_failure`, `contract_abort`).
100    Error,
101    /// A systemic failure needing immediate attention (e.g. `circuit_open`,
102    /// `scheduler_stuck`).
103    Critical,
104}
105
106impl Severity {
107    /// PagerDuty Events-API severity string.
108    pub fn as_pagerduty(self) -> &'static str {
109        match self {
110            Severity::Info => "info",
111            Severity::Warning => "warning",
112            Severity::Error => "error",
113            Severity::Critical => "critical",
114        }
115    }
116
117    /// Stable metric-label / log string.
118    pub fn as_str(self) -> &'static str {
119        match self {
120            Severity::Info => "info",
121            Severity::Warning => "warning",
122            Severity::Error => "error",
123            Severity::Critical => "critical",
124        }
125    }
126}
127
128/// A delivery channel. Adjacently tagged `{ type, config }` to match the
129/// project-wide connector / auth / lineage-transport shape.
130#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
131#[serde(tag = "type", content = "config", rename_all = "snake_case")]
132pub enum ChannelSpec {
133    /// Slack incoming webhook.
134    Slack(SlackConfig),
135    /// PagerDuty Events API v2 (trigger + auto-resolve).
136    Pagerduty(PagerdutyConfig),
137    /// Generic HTTP POST with an optional HMAC-SHA256 signature.
138    Webhook(WebhookConfig),
139}
140
141impl ChannelSpec {
142    /// Stable channel-kind label for metrics/logs.
143    pub fn kind(&self) -> &'static str {
144        match self {
145            ChannelSpec::Slack(_) => "slack",
146            ChannelSpec::Pagerduty(_) => "pagerduty",
147            ChannelSpec::Webhook(_) => "webhook",
148        }
149    }
150}
151
152/// Slack incoming-webhook config.
153#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
154#[serde(deny_unknown_fields)]
155pub struct SlackConfig {
156    /// Incoming-webhook URL. Supply via `${env:...}` / `${secret:...}` so it is
157    /// redacted from logs.
158    pub webhook_url: String,
159    /// Optional channel override (`#alerts`).
160    #[serde(default)]
161    pub channel: Option<String>,
162    /// Optional bot username override.
163    #[serde(default)]
164    pub username: Option<String>,
165}
166
167/// PagerDuty Events API v2 config.
168#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
169#[serde(deny_unknown_fields)]
170pub struct PagerdutyConfig {
171    /// Events API v2 integration (routing) key.
172    pub routing_key: String,
173    /// Optional `source` field for the PD payload (defaults to the pipeline
174    /// name).
175    #[serde(default)]
176    pub source: Option<String>,
177    /// Override the events endpoint (tests / EU service region). Defaults to
178    /// the global US endpoint.
179    #[serde(default)]
180    pub endpoint: Option<String>,
181}
182
183/// Generic signed-webhook config.
184#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
185#[serde(deny_unknown_fields)]
186pub struct WebhookConfig {
187    /// Destination URL.
188    pub url: String,
189    /// HTTP method — defaults to `POST`.
190    #[serde(default = "default_webhook_method")]
191    pub method: String,
192    /// Extra headers sent with every request.
193    #[serde(default)]
194    pub headers: std::collections::BTreeMap<String, String>,
195    /// If set, sign the JSON body with HMAC-SHA256 and send the lowercase-hex
196    /// digest in `signature_header`. Supply via `${env:...}` / `${secret:...}`.
197    #[serde(default)]
198    pub hmac_secret: Option<String>,
199    /// Header carrying the HMAC signature (default `X-Faucet-Signature`).
200    #[serde(default = "default_signature_header")]
201    pub signature_header: String,
202
203    /// Static fields merged into the emitted JSON body (#480), so a callback can
204    /// be tagged with a tenant, environment, or external job id. Values go
205    /// through the normal interpolation pass, so `${env:...}` / `${vars.X}` /
206    /// `${secret:...}` all work.
207    ///
208    /// Keys that collide with a field faucet itself emits (`event`, `severity`,
209    /// `pipeline`, `row`, `title`, `message`, `details`, `run_id`,
210    /// `invocation_id`, `started_at`, `finished_at`, `duration_secs`) are
211    /// rejected at load time. A typo'd `event` key would otherwise let a config
212    /// silently spoof the success/failure signal a receiver keys off, so this
213    /// fails fast rather than dropping the key at render time.
214    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
215    pub extra_fields: std::collections::BTreeMap<String, serde_json::Value>,
216}
217
218/// Top-level keys faucet emits in the webhook body. `extra_fields` may not
219/// shadow any of them (see [`WebhookConfig::extra_fields`]).
220pub const RESERVED_BODY_KEYS: &[&str] = &[
221    "event",
222    "severity",
223    "pipeline",
224    "row",
225    "title",
226    "message",
227    "details",
228    "run_id",
229    "invocation_id",
230    "started_at",
231    "finished_at",
232    "duration_secs",
233];
234
235fn default_webhook_method() -> String {
236    "POST".to_string()
237}
238
239fn default_signature_header() -> String {
240    "X-Faucet-Signature".to_string()
241}
242
243impl NotificationSpec {
244    /// Fail-fast structural validation (called at load time so misconfiguration
245    /// surfaces from `faucet validate`, never mid-run).
246    pub fn validate(&self) -> CliResult<()> {
247        if self.name.trim().is_empty() {
248            return Err(CliError::Config(
249                "notifications: each rule needs a non-empty `name`".into(),
250            ));
251        }
252        let bad = |field: &str| {
253            Err(CliError::Config(format!(
254                "notifications rule `{}`: `{field}` must be non-empty",
255                self.name
256            )))
257        };
258        match &self.channel {
259            ChannelSpec::Slack(c) if c.webhook_url.trim().is_empty() => bad("webhook_url"),
260            ChannelSpec::Pagerduty(c) if c.routing_key.trim().is_empty() => bad("routing_key"),
261            ChannelSpec::Webhook(c) if c.url.trim().is_empty() => bad("url"),
262            ChannelSpec::Webhook(c) if c.method.trim().is_empty() => bad("method"),
263            ChannelSpec::Webhook(c) => {
264                // Reject a reserved key rather than silently dropping it at
265                // render time: `extra_fields: { event: "ok" }` would otherwise
266                // look accepted while never reaching the receiver.
267                for key in c.extra_fields.keys() {
268                    if RESERVED_BODY_KEYS.contains(&key.as_str()) {
269                        return Err(CliError::Config(format!(
270                            "notifications rule `{}`: `extra_fields.{key}` collides with a field \
271                             faucet emits — reserved keys are: {}",
272                            self.name,
273                            RESERVED_BODY_KEYS.join(", ")
274                        )));
275                    }
276                }
277                Ok(())
278            }
279            _ => Ok(()),
280        }
281    }
282}
283
284/// Validate a whole `notifications:` list (unique names + per-rule validation).
285pub fn validate_all(specs: &[NotificationSpec]) -> CliResult<()> {
286    let mut seen = std::collections::HashSet::new();
287    for s in specs {
288        s.validate()?;
289        if !seen.insert(s.name.as_str()) {
290            return Err(CliError::Config(format!(
291                "notifications: duplicate rule name `{}`",
292                s.name
293            )));
294        }
295    }
296    Ok(())
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    fn slack(name: &str, url: &str) -> NotificationSpec {
304        NotificationSpec {
305            name: name.into(),
306            on: vec![EventKind::RunFailure],
307            min_severity: Severity::default(),
308            dedupe_window_secs: None,
309            dlq_threshold: None,
310            channel: ChannelSpec::Slack(SlackConfig {
311                webhook_url: url.into(),
312                channel: None,
313                username: None,
314            }),
315        }
316    }
317
318    #[test]
319    fn severity_orders_low_to_high() {
320        assert!(Severity::Info < Severity::Warning);
321        assert!(Severity::Warning < Severity::Error);
322        assert!(Severity::Error < Severity::Critical);
323        assert_eq!(Severity::default(), Severity::Info);
324    }
325
326    #[test]
327    fn channel_kind_labels() {
328        assert_eq!(slack("a", "u").channel.kind(), "slack");
329    }
330
331    #[test]
332    fn empty_name_rejected() {
333        let s = slack("  ", "u");
334        assert!(s.validate().is_err());
335    }
336
337    #[test]
338    fn empty_channel_field_rejected() {
339        let s = slack("a", "");
340        assert!(s.validate().is_err());
341    }
342
343    #[test]
344    fn duplicate_names_rejected() {
345        let list = vec![slack("dup", "u1"), slack("dup", "u2")];
346        assert!(validate_all(&list).is_err());
347    }
348
349    #[test]
350    fn valid_list_passes() {
351        let list = vec![slack("a", "u1"), slack("b", "u2")];
352        assert!(validate_all(&list).is_ok());
353    }
354
355    #[test]
356    fn adjacently_tagged_channel_roundtrips() {
357        let json = serde_json::json!({
358            "name": "x",
359            "on": ["run_failure", "circuit_open"],
360            "channel": { "type": "pagerduty", "config": { "routing_key": "k" } }
361        });
362        let s: NotificationSpec = serde_json::from_value(json).unwrap();
363        assert_eq!(s.on, vec![EventKind::RunFailure, EventKind::CircuitOpen]);
364        assert_eq!(s.channel.kind(), "pagerduty");
365        s.validate().unwrap();
366    }
367
368    #[test]
369    fn webhook_defaults_applied() {
370        let json = serde_json::json!({
371            "name": "w",
372            "channel": { "type": "webhook", "config": { "url": "http://x" } }
373        });
374        let s: NotificationSpec = serde_json::from_value(json).unwrap();
375        match &s.channel {
376            ChannelSpec::Webhook(c) => {
377                assert_eq!(c.method, "POST");
378                assert_eq!(c.signature_header, "X-Faucet-Signature");
379            }
380            _ => panic!("expected webhook"),
381        }
382        // Empty `on` means "all kinds".
383        assert!(s.on.is_empty());
384    }
385
386    /// Build a webhook rule carrying `extra_fields`.
387    fn webhook_with_extra(name: &str, key: &str) -> NotificationSpec {
388        let mut extra = std::collections::BTreeMap::new();
389        extra.insert(key.to_string(), serde_json::Value::String("x".into()));
390        NotificationSpec {
391            name: name.into(),
392            on: vec![],
393            min_severity: Severity::default(),
394            dedupe_window_secs: None,
395            dlq_threshold: None,
396            channel: ChannelSpec::Webhook(WebhookConfig {
397                url: "http://x".into(),
398                method: "POST".into(),
399                headers: Default::default(),
400                hmac_secret: None,
401                signature_header: "X-Faucet-Signature".into(),
402                extra_fields: extra,
403            }),
404        }
405    }
406
407    #[test]
408    fn extra_fields_accepts_a_non_reserved_key() {
409        assert!(webhook_with_extra("w", "tenant").validate().is_ok());
410    }
411
412    #[test]
413    fn extra_fields_rejects_every_reserved_key() {
414        // #480: a reserved key must fail fast at load time rather than be
415        // silently dropped at render time — `extra_fields: { event: "ok" }`
416        // would otherwise look accepted while never reaching the receiver, and
417        // a receiver keying off `event` would be spoofed if it *did*.
418        for key in RESERVED_BODY_KEYS {
419            let err = webhook_with_extra("w", key)
420                .validate()
421                .expect_err("reserved key must be rejected");
422            let msg = err.to_string();
423            assert!(msg.contains(key), "error should name the key: {msg}");
424            assert!(
425                msg.contains("extra_fields"),
426                "error should name the field: {msg}"
427            );
428        }
429    }
430
431    #[test]
432    fn extra_fields_defaults_to_empty() {
433        let json = serde_json::json!({
434            "name": "w",
435            "channel": { "type": "webhook", "config": { "url": "http://x" } }
436        });
437        let s: NotificationSpec = serde_json::from_value(json).unwrap();
438        match &s.channel {
439            ChannelSpec::Webhook(c) => assert!(c.extra_fields.is_empty()),
440            _ => panic!("expected webhook"),
441        }
442    }
443
444    #[test]
445    fn pagerduty_severity_strings() {
446        assert_eq!(Severity::Critical.as_pagerduty(), "critical");
447        assert_eq!(Severity::Info.as_pagerduty(), "info");
448    }
449}