1use crate::error::{CliError, CliResult};
13use schemars::JsonSchema;
14use serde::{Deserialize, Serialize};
15
16#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
18#[serde(deny_unknown_fields)]
19pub struct NotificationSpec {
20 pub name: String,
23
24 #[serde(default)]
26 pub on: Vec<EventKind>,
27
28 #[serde(default)]
31 pub min_severity: Severity,
32
33 #[serde(default)]
38 pub dedupe_window_secs: Option<u64>,
39
40 #[serde(default)]
43 pub dlq_threshold: Option<u64>,
44
45 pub channel: ChannelSpec,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
51#[serde(rename_all = "snake_case")]
52pub enum EventKind {
53 RunFailure,
55 RunSuccess,
57 SlaBreach,
59 CircuitOpen,
61 ContractAbort,
63 DlqThreshold,
66 SchedulerStuck,
69}
70
71impl EventKind {
72 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#[derive(
90 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize, JsonSchema,
91)]
92#[serde(rename_all = "snake_case")]
93pub enum Severity {
94 #[default]
96 Info,
97 Warning,
99 Error,
101 Critical,
104}
105
106impl Severity {
107 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 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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
131#[serde(tag = "type", content = "config", rename_all = "snake_case")]
132pub enum ChannelSpec {
133 Slack(SlackConfig),
135 Pagerduty(PagerdutyConfig),
137 Webhook(WebhookConfig),
139}
140
141impl ChannelSpec {
142 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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
154#[serde(deny_unknown_fields)]
155pub struct SlackConfig {
156 pub webhook_url: String,
159 #[serde(default)]
161 pub channel: Option<String>,
162 #[serde(default)]
164 pub username: Option<String>,
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
169#[serde(deny_unknown_fields)]
170pub struct PagerdutyConfig {
171 pub routing_key: String,
173 #[serde(default)]
176 pub source: Option<String>,
177 #[serde(default)]
180 pub endpoint: Option<String>,
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
185#[serde(deny_unknown_fields)]
186pub struct WebhookConfig {
187 pub url: String,
189 #[serde(default = "default_webhook_method")]
191 pub method: String,
192 #[serde(default)]
194 pub headers: std::collections::BTreeMap<String, String>,
195 #[serde(default)]
198 pub hmac_secret: Option<String>,
199 #[serde(default = "default_signature_header")]
201 pub signature_header: String,
202
203 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
215 pub extra_fields: std::collections::BTreeMap<String, serde_json::Value>,
216}
217
218pub 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 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 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
284pub 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 assert!(s.on.is_empty());
384 }
385
386 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 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}