Skip to main content

max / makenotwork

4.5 KB · 130 lines History Blame Raw
1 //! Inbound infra-alert ingestion: PoM / MT monitoring agents → MNW.
2 //!
3 //! `POST /api/internal/alerts` accepts an ops alert (health failure, TLS
4 //! expiry, DNS change, ...) from an external monitoring agent, persists it to
5 //! `admin_alerts`, and emails the operator (`ALERT_EMAIL`). Admin-routed only:
6 //! these are operator signals, never creator-facing. Authed by [`AlertsAuth`], a
7 //! bearer token distinct from the CLI internal API's `ServiceAuth`.
8
9 use axum::{Json, extract::State, response::IntoResponse};
10 use serde::Deserialize;
11 use sqlx::PgPool;
12
13 use crate::{
14 auth::AlertsAuth,
15 db::{
16 self,
17 admin_alerts::{AlertKind, AlertSeverity},
18 },
19 email::EmailClient,
20 error::{AppError, Result},
21 };
22
23 #[derive(Deserialize)]
24 pub(super) struct AlertRequest {
25 /// Reporting agent, e.g. "pom" or "mt".
26 source: String,
27 /// Alert class. Unknown values are rejected at deserialization.
28 kind: AlertKind,
29 /// Alert urgency. Unknown values are rejected at deserialization.
30 severity: AlertSeverity,
31 /// Short human-readable headline.
32 title: String,
33 /// Full alert detail.
34 body: String,
35 /// Optional agent-side collapse key for repeated firings of one condition.
36 #[serde(default)]
37 dedup_key: Option<String>,
38 /// Optional structured context (JSON object), stored verbatim.
39 #[serde(default)]
40 details: Option<serde_json::Value>,
41 }
42
43 fn require_field(name: &str, value: &str, max: usize) -> Result<()> {
44 if value.is_empty() {
45 return Err(AppError::validation(format!("{name} is required")));
46 }
47 if value.len() > max {
48 return Err(AppError::validation(format!(
49 "{name} must be at most {max} characters"
50 )));
51 }
52 Ok(())
53 }
54
55 /// POST /api/internal/alerts
56 ///
57 /// Persist an inbound alert and email the operator. Returns 202 with the row id.
58 /// A missing `ALERT_EMAIL` is not an error: the alert is still logged (the row's
59 /// `emailed` stays false so the gap is visible), because dropping the durable
60 /// record just because mail is unconfigured would defeat the point.
61 pub(super) async fn ingest_alert(
62 State(pool): State<PgPool>,
63 State(email): State<EmailClient>,
64 _auth: AlertsAuth,
65 Json(req): Json<AlertRequest>,
66 ) -> Result<impl IntoResponse> {
67 // `kind` and `severity` are already validated by the type system: the
68 // request would not have deserialized if either were outside its enum. Only
69 // the free-text fields need bounds here.
70 require_field("source", &req.source, 64)?;
71 require_field("title", &req.title, 200)?;
72 require_field("body", &req.body, 5000)?;
73 if let Some(key) = &req.dedup_key
74 && key.len() > 200
75 {
76 return Err(AppError::validation(
77 "dedup_key must be at most 200 characters".to_string(),
78 ));
79 }
80
81 let id = db::admin_alerts::insert_alert(
82 &pool,
83 &db::admin_alerts::NewAlert {
84 source: &req.source,
85 kind: req.kind,
86 severity: req.severity,
87 title: &req.title,
88 body: &req.body,
89 dedup_key: req.dedup_key.as_deref(),
90 details: req.details.as_ref(),
91 },
92 )
93 .await?;
94
95 match std::env::var("ALERT_EMAIL").ok() {
96 Some(to) if !to.is_empty() => {
97 let subject = format!("[{}/{}] {}", req.source, req.severity.as_str(), req.title);
98 let mail_body = format!(
99 "{}\n\nsource: {}\nkind: {}\nseverity: {}\n",
100 req.body,
101 req.source,
102 req.kind.as_str(),
103 req.severity.as_str()
104 );
105 match email.send_alert(&to, &subject, &mail_body).await {
106 Ok(()) => {
107 if let Err(e) = db::admin_alerts::mark_emailed(&pool, id).await {
108 tracing::error!(alert_id = id, error = ?e, "alert emailed but mark_emailed failed");
109 }
110 }
111 // Ingestion succeeded and the row is durable; a mail failure is
112 // logged, not surfaced as a 5xx to the agent (which would make it
113 // retry and duplicate the row).
114 Err(e) => {
115 tracing::error!(alert_id = id, error = ?e, "failed to email ingested alert");
116 }
117 }
118 }
119 _ => tracing::warn!(
120 alert_id = id,
121 "ALERT_EMAIL unset; alert logged but not emailed"
122 ),
123 }
124
125 Ok((
126 axum::http::StatusCode::ACCEPTED,
127 Json(serde_json::json!({ "id": id })),
128 ))
129 }
130