g1t/services/integrations/src/alerts.rs
| 1 | //! Alerts: what an outside system reports, in one shape, whichever system |
| 2 | //! it came from. Sentry has its own reader; Datadog and plain webhooks |
| 3 | //! send JSON whose fields g1t picks out by their usual names. |
| 4 | |
| 5 | use serde_json::Value; |
| 6 | |
| 7 | use crate::crypto; |
| 8 | |
| 9 | /// What an alert asks g1t to do. |
| 10 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 11 | pub enum Action { |
| 12 | /// Open an issue for it, or count it against the one already open. |
| 13 | Open, |
| 14 | /// It came back after being fixed: reopen the issue. |
| 15 | Reopen, |
| 16 | /// It stopped. Say so on the issue, and nothing more. |
| 17 | Recovered, |
| 18 | } |
| 19 | |
| 20 | /// One alert, from any system. |
| 21 | #[derive(Clone, Debug)] |
| 22 | pub struct Signal { |
| 23 | /// What the sender called it: `issue.created`, `Triggered`. |
| 24 | pub event: String, |
| 25 | pub action: Action, |
| 26 | /// The sender's stable id for the problem, so it maps to one issue. |
| 27 | pub external_id: String, |
| 28 | pub key: String, |
| 29 | pub title: String, |
| 30 | pub url: String, |
| 31 | /// Markdown describing it. |
| 32 | pub body: String, |
| 33 | /// How many times it has happened, if the sender says. |
| 34 | pub count: Option<u32>, |
| 35 | } |
| 36 | |
| 37 | fn first(payload: &Value, names: &[&str]) -> Option<String> { |
| 38 | names.iter().find_map(|name| match &payload[*name] { |
| 39 | Value::String(text) if !text.trim().is_empty() => Some(text.trim().to_owned()), |
| 40 | Value::Number(number) => Some(number.to_string()), |
| 41 | _ => None, |
| 42 | }) |
| 43 | } |
| 44 | |
| 45 | /// Whether a Datadog or webhook request carries the connection's secret: |
| 46 | /// as `Authorization: Bearer <secret>`, or as an HMAC-SHA256 of the body in |
| 47 | /// `X-G1t-Signature`. |
| 48 | pub fn authentic(headers: &std::collections::HashMap<String, String>, body: &str, secret: &str) -> bool { |
| 49 | if let Some(signature) = headers.get("x-g1t-signature") { |
| 50 | return crypto::signed(secret, body, signature); |
| 51 | } |
| 52 | headers |
| 53 | .get("authorization") |
| 54 | .and_then(|value| value.strip_prefix("Bearer ").or_else(|| value.strip_prefix("bearer "))) |
| 55 | .is_some_and(|given| crypto::same(given.trim(), secret)) |
| 56 | } |
| 57 | |
| 58 | /// Reads a Datadog webhook or a plain one. |
| 59 | /// |
| 60 | /// Fields, by their first name present: an id (`id`, `alert_id`, |
| 61 | /// `aggregate`, `incident_key`), a title (`title`, `event_title`, |
| 62 | /// `summary`), a description (`body`, `message`, `event_msg`, `text`), an |
| 63 | /// address (`url`, `link`) and a state (`status`, `transition`, |
| 64 | /// `alert_transition`), where `recovered`, `resolved` or `ok` means it |
| 65 | /// stopped. |
| 66 | pub fn signal(system: &str, payload: &Value) -> std::result::Result<Signal, String> { |
| 67 | if !payload.is_object() { |
| 68 | return Err("The body is not a JSON object.".to_owned()); |
| 69 | } |
| 70 | let title = first(payload, &["title", "event_title", "summary", "name"]) |
| 71 | .ok_or_else(|| "The payload has no title.".to_owned())?; |
| 72 | let external_id = first(payload, &["id", "alert_id", "aggregate", "incident_key", "dedup_key"]) |
| 73 | .unwrap_or_else(|| title.clone()); |
| 74 | let state = first(payload, &["status", "transition", "alert_transition", "state"]).unwrap_or_default(); |
| 75 | let action = match state.to_ascii_lowercase().as_str() { |
| 76 | "recovered" | "resolved" | "ok" | "closed" => Action::Recovered, |
| 77 | _ => Action::Open, |
| 78 | }; |
| 79 | let url = first(payload, &["url", "link", "html_url"]).unwrap_or_default(); |
| 80 | let mut body = vec"), |
| 83 | }]; |
| 84 | if !state.is_empty() { |
| 85 | body.push(format!("State: {state}.")); |
| 86 | } |
| 87 | if let Some(priority) = first(payload, &["priority", "severity", "level"]) { |
| 88 | body.push(format!("Priority: {priority}.")); |
| 89 | } |
| 90 | if let Some(text) = first(payload, &["body", "message", "event_msg", "text", "description"]) { |
| 91 | body.push(crate::http::shorten(&text, 6000)); |
| 92 | } |
| 93 | if let Some(tags) = first(payload, &["tags"]) { |
| 94 | body.push(format!("Tags: `{tags}`")); |
| 95 | } |
| 96 | Ok(Signal { |
| 97 | event: if state.is_empty() { "alert".to_owned() } else { state }, |
| 98 | action, |
| 99 | key: external_id.chars().take(40).collect(), |
| 100 | external_id, |
| 101 | title: title.chars().take(200).collect(), |
| 102 | url, |
| 103 | body: body.join("\n\n"), |
| 104 | count: first(payload, &["count"]).and_then(|count| count.parse().ok()), |
| 105 | }) |
| 106 | } |
| 107 | |
| 108 | #[cfg(test)] |
| 109 | mod tests { |
| 110 | use super::*; |
| 111 | use serde_json::json; |
| 112 | |
| 113 | #[test] |
| 114 | fn a_datadog_alert_is_read_by_its_usual_names() { |
| 115 | let alert = signal( |
| 116 | "Datadog", |
| 117 | &json!({ "alert_id": 123, "event_title": "[Triggered] p99 latency high", "event_msg": "p99 > 2s", |
| 118 | "link": "https://app.datadoghq.com/monitors/123", "alert_transition": "Triggered", "priority": "P2" }), |
| 119 | ) |
| 120 | .unwrap(); |
| 121 | assert_eq!(alert.external_id, "123"); |
| 122 | assert_eq!(alert.action, Action::Open); |
| 123 | assert!(alert.body.contains("p99 > 2s")); |
| 124 | assert!(alert.body.contains("Priority: P2.")); |
| 125 | } |
| 126 | |
| 127 | #[test] |
| 128 | fn recovered_means_it_stopped() { |
| 129 | let alert = signal("Datadog", &json!({ "id": "1", "title": "x", "alert_transition": "Recovered" })).unwrap(); |
| 130 | assert_eq!(alert.action, Action::Recovered); |
| 131 | } |
| 132 | |
| 133 | #[test] |
| 134 | fn a_title_is_required() { |
| 135 | assert!(signal("Webhook", &json!({ "id": "1" })).is_err()); |
| 136 | assert!(signal("Webhook", &json!([1, 2])).is_err()); |
| 137 | } |
| 138 | |
| 139 | #[test] |
| 140 | fn the_secret_is_checked_either_way() { |
| 141 | let body = "{\"title\":\"x\"}"; |
| 142 | let mut headers = std::collections::HashMap::new(); |
| 143 | headers.insert("authorization".to_owned(), "Bearer shh".to_owned()); |
| 144 | assert!(authentic(&headers, body, "shh")); |
| 145 | assert!(!authentic(&headers, body, "other")); |
| 146 | let mut signed = std::collections::HashMap::new(); |
| 147 | signed.insert("x-g1t-signature".to_owned(), format!("sha256={}", crypto::hmac_sha256_hex("shh", body))); |
| 148 | assert!(authentic(&signed, body, "shh")); |
| 149 | assert!(!authentic(&std::collections::HashMap::new(), body, "shh")); |
| 150 | } |
| 151 | } |