use crate::error::{CliError, CliResult};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct NotificationSpec {
pub name: String,
#[serde(default)]
pub on: Vec<EventKind>,
#[serde(default)]
pub min_severity: Severity,
#[serde(default)]
pub dedupe_window_secs: Option<u64>,
#[serde(default)]
pub dlq_threshold: Option<u64>,
pub channel: ChannelSpec,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum EventKind {
RunFailure,
RunSuccess,
SlaBreach,
CircuitOpen,
ContractAbort,
DlqThreshold,
SchedulerStuck,
}
impl EventKind {
pub fn as_str(self) -> &'static str {
match self {
EventKind::RunFailure => "run_failure",
EventKind::RunSuccess => "run_success",
EventKind::SlaBreach => "sla_breach",
EventKind::CircuitOpen => "circuit_open",
EventKind::ContractAbort => "contract_abort",
EventKind::DlqThreshold => "dlq_threshold",
EventKind::SchedulerStuck => "scheduler_stuck",
}
}
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize, JsonSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum Severity {
#[default]
Info,
Warning,
Error,
Critical,
}
impl Severity {
pub fn as_pagerduty(self) -> &'static str {
match self {
Severity::Info => "info",
Severity::Warning => "warning",
Severity::Error => "error",
Severity::Critical => "critical",
}
}
pub fn as_str(self) -> &'static str {
match self {
Severity::Info => "info",
Severity::Warning => "warning",
Severity::Error => "error",
Severity::Critical => "critical",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type", content = "config", rename_all = "snake_case")]
pub enum ChannelSpec {
Slack(SlackConfig),
Pagerduty(PagerdutyConfig),
Webhook(WebhookConfig),
}
impl ChannelSpec {
pub fn kind(&self) -> &'static str {
match self {
ChannelSpec::Slack(_) => "slack",
ChannelSpec::Pagerduty(_) => "pagerduty",
ChannelSpec::Webhook(_) => "webhook",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SlackConfig {
pub webhook_url: String,
#[serde(default)]
pub channel: Option<String>,
#[serde(default)]
pub username: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct PagerdutyConfig {
pub routing_key: String,
#[serde(default)]
pub source: Option<String>,
#[serde(default)]
pub endpoint: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct WebhookConfig {
pub url: String,
#[serde(default = "default_webhook_method")]
pub method: String,
#[serde(default)]
pub headers: std::collections::BTreeMap<String, String>,
#[serde(default)]
pub hmac_secret: Option<String>,
#[serde(default = "default_signature_header")]
pub signature_header: String,
}
fn default_webhook_method() -> String {
"POST".to_string()
}
fn default_signature_header() -> String {
"X-Faucet-Signature".to_string()
}
impl NotificationSpec {
pub fn validate(&self) -> CliResult<()> {
if self.name.trim().is_empty() {
return Err(CliError::Config(
"notifications: each rule needs a non-empty `name`".into(),
));
}
let bad = |field: &str| {
Err(CliError::Config(format!(
"notifications rule `{}`: `{field}` must be non-empty",
self.name
)))
};
match &self.channel {
ChannelSpec::Slack(c) if c.webhook_url.trim().is_empty() => bad("webhook_url"),
ChannelSpec::Pagerduty(c) if c.routing_key.trim().is_empty() => bad("routing_key"),
ChannelSpec::Webhook(c) if c.url.trim().is_empty() => bad("url"),
ChannelSpec::Webhook(c) if c.method.trim().is_empty() => bad("method"),
_ => Ok(()),
}
}
}
pub fn validate_all(specs: &[NotificationSpec]) -> CliResult<()> {
let mut seen = std::collections::HashSet::new();
for s in specs {
s.validate()?;
if !seen.insert(s.name.as_str()) {
return Err(CliError::Config(format!(
"notifications: duplicate rule name `{}`",
s.name
)));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn slack(name: &str, url: &str) -> NotificationSpec {
NotificationSpec {
name: name.into(),
on: vec![EventKind::RunFailure],
min_severity: Severity::default(),
dedupe_window_secs: None,
dlq_threshold: None,
channel: ChannelSpec::Slack(SlackConfig {
webhook_url: url.into(),
channel: None,
username: None,
}),
}
}
#[test]
fn severity_orders_low_to_high() {
assert!(Severity::Info < Severity::Warning);
assert!(Severity::Warning < Severity::Error);
assert!(Severity::Error < Severity::Critical);
assert_eq!(Severity::default(), Severity::Info);
}
#[test]
fn channel_kind_labels() {
assert_eq!(slack("a", "u").channel.kind(), "slack");
}
#[test]
fn empty_name_rejected() {
let s = slack(" ", "u");
assert!(s.validate().is_err());
}
#[test]
fn empty_channel_field_rejected() {
let s = slack("a", "");
assert!(s.validate().is_err());
}
#[test]
fn duplicate_names_rejected() {
let list = vec![slack("dup", "u1"), slack("dup", "u2")];
assert!(validate_all(&list).is_err());
}
#[test]
fn valid_list_passes() {
let list = vec![slack("a", "u1"), slack("b", "u2")];
assert!(validate_all(&list).is_ok());
}
#[test]
fn adjacently_tagged_channel_roundtrips() {
let json = serde_json::json!({
"name": "x",
"on": ["run_failure", "circuit_open"],
"channel": { "type": "pagerduty", "config": { "routing_key": "k" } }
});
let s: NotificationSpec = serde_json::from_value(json).unwrap();
assert_eq!(s.on, vec![EventKind::RunFailure, EventKind::CircuitOpen]);
assert_eq!(s.channel.kind(), "pagerduty");
s.validate().unwrap();
}
#[test]
fn webhook_defaults_applied() {
let json = serde_json::json!({
"name": "w",
"channel": { "type": "webhook", "config": { "url": "http://x" } }
});
let s: NotificationSpec = serde_json::from_value(json).unwrap();
match &s.channel {
ChannelSpec::Webhook(c) => {
assert_eq!(c.method, "POST");
assert_eq!(c.signature_header, "X-Faucet-Signature");
}
_ => panic!("expected webhook"),
}
assert!(s.on.is_empty());
}
#[test]
fn pagerduty_severity_strings() {
assert_eq!(Severity::Critical.as_pagerduty(), "critical");
assert_eq!(Severity::Info.as_pagerduty(), "info");
}
}