Skip to main content

max / makenotwork

3.7 KB · 117 lines History Blame Raw
1 //! Inbound infra-alert log (see migration `169_admin_alerts.sql`).
2 //!
3 //! Backs the `POST /api/internal/alerts` ingestion endpoint: external
4 //! monitoring agents (PoM, MT) push ops alerts here, each persisted as one row
5 //! and emailed to the operator. Admin-routed only -- never creator-facing.
6
7 use serde::Deserialize;
8 use sqlx::PgPool;
9
10 /// Alert domain an agent may report. Closed set, deserialized straight from the
11 /// wire (`#[serde(rename_all = "snake_case")]`), so an unknown value is rejected
12 /// when the request body is parsed, there is no runtime membership check.
13 /// Extending the taxonomy is a variant here plus its `as_str`, not a migration
14 /// (the column is TEXT).
15 ///
16 /// These are domain-level, not per-event: PoM's finer categories (tls expiry vs
17 /// tls error vs tls recovery) all fold onto `Tls`, with the failure/recovery
18 /// distinction carried by [`AlertSeverity`] instead. Kept small so the operator
19 /// log filters by system, not by every sub-condition.
20 #[derive(Debug, Clone, Copy, Deserialize)]
21 #[serde(rename_all = "snake_case")]
22 pub enum AlertKind {
23 Health,
24 Tls,
25 Dns,
26 Whois,
27 Latency,
28 Cors,
29 Backup,
30 Peer,
31 Route,
32 Scan,
33 Monitoring,
34 }
35
36 impl AlertKind {
37 /// Canonical wire/storage token. Matches the `serde(rename_all)` mapping.
38 pub fn as_str(self) -> &'static str {
39 match self {
40 Self::Health => "health",
41 Self::Tls => "tls",
42 Self::Dns => "dns",
43 Self::Whois => "whois",
44 Self::Latency => "latency",
45 Self::Cors => "cors",
46 Self::Backup => "backup",
47 Self::Peer => "peer",
48 Self::Route => "route",
49 Self::Scan => "scan",
50 Self::Monitoring => "monitoring",
51 }
52 }
53 }
54
55 /// Alert urgency, most to least. Closed set, deserialized like [`AlertKind`].
56 #[derive(Debug, Clone, Copy, Deserialize)]
57 #[serde(rename_all = "snake_case")]
58 pub enum AlertSeverity {
59 Critical,
60 Warning,
61 Info,
62 }
63
64 impl AlertSeverity {
65 /// Canonical wire/storage token.
66 pub fn as_str(self) -> &'static str {
67 match self {
68 Self::Critical => "critical",
69 Self::Warning => "warning",
70 Self::Info => "info",
71 }
72 }
73 }
74
75 /// A validated inbound alert, ready to persist.
76 pub struct NewAlert<'a> {
77 pub source: &'a str,
78 pub kind: AlertKind,
79 pub severity: AlertSeverity,
80 pub title: &'a str,
81 pub body: &'a str,
82 pub dedup_key: Option<&'a str>,
83 pub details: Option<&'a serde_json::Value>,
84 }
85
86 /// Insert an ingested alert and return its generated id. `emailed` starts false;
87 /// the handler flips it via [`mark_emailed`] after the notification is sent so a
88 /// mail failure stays visible in the log.
89 #[tracing::instrument(skip_all, fields(source = alert.source, kind = alert.kind.as_str(), severity = alert.severity.as_str()))]
90 pub async fn insert_alert(pool: &PgPool, alert: &NewAlert<'_>) -> Result<i64, sqlx::Error> {
91 let id: i64 = sqlx::query_scalar(
92 "INSERT INTO admin_alerts (source, kind, severity, title, body, dedup_key, details)
93 VALUES ($1, $2, $3, $4, $5, $6, $7)
94 RETURNING id",
95 )
96 .bind(alert.source)
97 .bind(alert.kind.as_str())
98 .bind(alert.severity.as_str())
99 .bind(alert.title)
100 .bind(alert.body)
101 .bind(alert.dedup_key)
102 .bind(alert.details)
103 .fetch_one(pool)
104 .await?;
105 Ok(id)
106 }
107
108 /// Mark an alert row as successfully emailed to the operator.
109 #[tracing::instrument(skip_all)]
110 pub async fn mark_emailed(pool: &PgPool, id: i64) -> Result<(), sqlx::Error> {
111 sqlx::query("UPDATE admin_alerts SET emailed = true WHERE id = $1")
112 .bind(id)
113 .execute(pool)
114 .await?;
115 Ok(())
116 }
117