//! Inbound infra-alert ingestion: PoM / MT monitoring agents → MNW. //! //! `POST /api/internal/alerts` accepts an ops alert (health failure, TLS //! expiry, DNS change, ...) from an external monitoring agent, persists it to //! `admin_alerts`, and emails the operator (`ALERT_EMAIL`). Admin-routed only: //! these are operator signals, never creator-facing. Authed by [`AlertsAuth`], a //! bearer token distinct from the CLI internal API's `ServiceAuth`. use axum::{Json, extract::State, response::IntoResponse}; use serde::Deserialize; use sqlx::PgPool; use crate::{ auth::AlertsAuth, db::{ self, admin_alerts::{AlertKind, AlertSeverity}, }, email::EmailClient, error::{AppError, Result}, }; #[derive(Deserialize)] pub(super) struct AlertRequest { /// Reporting agent, e.g. "pom" or "mt". source: String, /// Alert class. Unknown values are rejected at deserialization. kind: AlertKind, /// Alert urgency. Unknown values are rejected at deserialization. severity: AlertSeverity, /// Short human-readable headline. title: String, /// Full alert detail. body: String, /// Optional agent-side collapse key for repeated firings of one condition. #[serde(default)] dedup_key: Option, /// Optional structured context (JSON object), stored verbatim. #[serde(default)] details: Option, } fn require_field(name: &str, value: &str, max: usize) -> Result<()> { if value.is_empty() { return Err(AppError::validation(format!("{name} is required"))); } if value.len() > max { return Err(AppError::validation(format!( "{name} must be at most {max} characters" ))); } Ok(()) } /// POST /api/internal/alerts /// /// Persist an inbound alert and email the operator. Returns 202 with the row id. /// A missing `ALERT_EMAIL` is not an error: the alert is still logged (the row's /// `emailed` stays false so the gap is visible), because dropping the durable /// record just because mail is unconfigured would defeat the point. pub(super) async fn ingest_alert( State(pool): State, State(email): State, _auth: AlertsAuth, Json(req): Json, ) -> Result { // `kind` and `severity` are already validated by the type system: the // request would not have deserialized if either were outside its enum. Only // the free-text fields need bounds here. require_field("source", &req.source, 64)?; require_field("title", &req.title, 200)?; require_field("body", &req.body, 5000)?; if let Some(key) = &req.dedup_key && key.len() > 200 { return Err(AppError::validation( "dedup_key must be at most 200 characters".to_string(), )); } let id = db::admin_alerts::insert_alert( &pool, &db::admin_alerts::NewAlert { source: &req.source, kind: req.kind, severity: req.severity, title: &req.title, body: &req.body, dedup_key: req.dedup_key.as_deref(), details: req.details.as_ref(), }, ) .await?; match std::env::var("ALERT_EMAIL").ok() { Some(to) if !to.is_empty() => { let subject = format!("[{}/{}] {}", req.source, req.severity.as_str(), req.title); let mail_body = format!( "{}\n\nsource: {}\nkind: {}\nseverity: {}\n", req.body, req.source, req.kind.as_str(), req.severity.as_str() ); match email.send_alert(&to, &subject, &mail_body).await { Ok(()) => { if let Err(e) = db::admin_alerts::mark_emailed(&pool, id).await { tracing::error!(alert_id = id, error = ?e, "alert emailed but mark_emailed failed"); } } // Ingestion succeeded and the row is durable; a mail failure is // logged, not surfaced as a 5xx to the agent (which would make it // retry and duplicate the row). Err(e) => { tracing::error!(alert_id = id, error = ?e, "failed to email ingested alert"); } } } _ => tracing::warn!( alert_id = id, "ALERT_EMAIL unset; alert logged but not emailed" ), } Ok(( axum::http::StatusCode::ACCEPTED, Json(serde_json::json!({ "id": id })), )) }