| 57 |
57 |
|
}
|
| 58 |
58 |
|
}
|
| 59 |
59 |
|
|
|
60 |
+ |
/// Map PoM's fine-grained [`AlertCategory`] onto MNW's domain-level alert kind
|
|
61 |
+ |
/// (the `AlertKind` enum in the MNW server's `db/admin_alerts.rs`). Recoveries
|
|
62 |
+ |
/// and sub-conditions fold onto their domain; the failure/recovery distinction
|
|
63 |
+ |
/// rides on severity, not kind. A new PoM category needs a line here — and, if
|
|
64 |
+ |
/// it introduces a new domain, a matching MNW `AlertKind` variant (else MNW
|
|
65 |
+ |
/// rejects the push with 422).
|
|
66 |
+ |
fn mnw_kind(category: AlertCategory) -> &'static str {
|
|
67 |
+ |
use AlertCategory::*;
|
|
68 |
+ |
match category {
|
|
69 |
+ |
Health | Recovery => "health",
|
|
70 |
+ |
TlsExpiry | TlsError | TlsRecovery => "tls",
|
|
71 |
+ |
DnsMismatch | DnsRecovery => "dns",
|
|
72 |
+ |
WhoisExpiry | WhoisError => "whois",
|
|
73 |
+ |
LatencyDrift | LatencyRecovery | TestDurationDrift => "latency",
|
|
74 |
+ |
CorsFailure | CorsRecovery => "cors",
|
|
75 |
+ |
BackupStale | BackupRecovery => "backup",
|
|
76 |
+ |
PeerMissing | PeerRecovery => "peer",
|
|
77 |
+ |
RouteFailure | RouteRecovery => "route",
|
|
78 |
+ |
ScanPipelineDegraded | ScanPipelineRecovery => "scan",
|
|
79 |
+ |
MonitoringOffline | MonitoringRecovery => "monitoring",
|
|
80 |
+ |
}
|
|
81 |
+ |
}
|
|
82 |
+ |
|
|
83 |
+ |
/// Map PoM's WAM priority string onto MNW's three-level severity. Recoveries do
|
|
84 |
+ |
/// not carry a priority; the caller passes `"info"` for those directly.
|
|
85 |
+ |
fn mnw_severity(priority: &str) -> &'static str {
|
|
86 |
+ |
match priority {
|
|
87 |
+ |
"critical" => "critical",
|
|
88 |
+ |
"high" | "medium" => "warning",
|
|
89 |
+ |
_ => "info",
|
|
90 |
+ |
}
|
|
91 |
+ |
}
|
|
92 |
+ |
|
|
93 |
+ |
/// Truncate `s` to at most `max` bytes on a char boundary. MNW caps title at
|
|
94 |
+ |
/// 200 and body at 5000; over-long input would be a 422, so clamp instead.
|
|
95 |
+ |
fn truncate(s: &str, max: usize) -> String {
|
|
96 |
+ |
if s.len() <= max {
|
|
97 |
+ |
return s.to_string();
|
|
98 |
+ |
}
|
|
99 |
+ |
let mut end = max;
|
|
100 |
+ |
while !s.is_char_boundary(end) {
|
|
101 |
+ |
end -= 1;
|
|
102 |
+ |
}
|
|
103 |
+ |
s[..end].to_string()
|
|
104 |
+ |
}
|
|
105 |
+ |
|
| 60 |
106 |
|
#[derive(Clone)]
|
| 61 |
107 |
|
pub struct Alerter {
|
| 62 |
108 |
|
config: AlertConfig,
|
| 101 |
147 |
|
instead of WAM tickets (set alerts.wam_url to route them to WAM)"
|
| 102 |
148 |
|
);
|
| 103 |
149 |
|
}
|
|
150 |
+ |
match (&config.mnw_url, &config.alerts_ingest_token) {
|
|
151 |
+ |
(Some(_), Some(_)) => info!("alerts: MNW sink enabled — alerts also push to the MNW operator log"),
|
|
152 |
+ |
(Some(_), None) => warn!(
|
|
153 |
+ |
"alerts: mnw_url set but alerts_ingest_token missing — MNW sink disabled \
|
|
154 |
+ |
(set alerts.alerts_ingest_token or POM_ALERTS_INGEST_TOKEN)"
|
|
155 |
+ |
),
|
|
156 |
+ |
_ => {}
|
|
157 |
+ |
}
|
| 104 |
158 |
|
Self { config, client, pool, instance_name, wam_url }
|
| 105 |
159 |
|
}
|
| 106 |
160 |
|
|
|
161 |
+ |
/// Push one alert to MNW's operator log (`POST /api/internal/alerts`).
|
|
162 |
+ |
/// Returns `true` iff MNW accepted it. Returns `false` — never enqueues —
|
|
163 |
+ |
/// when the sink is unconfigured; callers gate on config before treating a
|
|
164 |
+ |
/// `false` as a delivery miss worth queuing. Bounded by a 10s timeout so MNW
|
|
165 |
+ |
/// latency can't stall the probe loop. The server dedups on `dedup_key`
|
|
166 |
+ |
/// (`{kind}:{target}`), so a retried or repeated push of one condition
|
|
167 |
+ |
/// collapses rather than duplicating.
|
|
168 |
+ |
async fn deliver_mnw(&self, kind: &str, key: &str, severity: &str, subject: &str, body: &str) -> bool {
|
|
169 |
+ |
let (Some(base), Some(token)) =
|
|
170 |
+ |
(self.config.mnw_url.as_deref(), self.config.alerts_ingest_token.as_deref())
|
|
171 |
+ |
else {
|
|
172 |
+ |
return false;
|
|
173 |
+ |
};
|
|
174 |
+ |
let payload = serde_json::json!({
|
|
175 |
+ |
"source": "pom",
|
|
176 |
+ |
"kind": kind,
|
|
177 |
+ |
"severity": severity,
|
|
178 |
+ |
"title": truncate(subject, 200),
|
|
179 |
+ |
"body": truncate(body, 5000),
|
|
180 |
+ |
"dedup_key": truncate(&format!("{kind}:{key}"), 200),
|
|
181 |
+ |
"details": { "instance": self.instance_name, "target": key },
|
|
182 |
+ |
});
|
|
183 |
+ |
let url = format!("{}/api/internal/alerts", base.trim_end_matches('/'));
|
|
184 |
+ |
let send_fut = self.client.post(&url).bearer_auth(token).json(&payload).send();
|
|
185 |
+ |
match tokio::time::timeout(std::time::Duration::from_secs(10), send_fut).await {
|
|
186 |
+ |
Ok(Ok(resp)) if resp.status().is_success() => {
|
|
187 |
+ |
info!("mnw alert pushed: {kind}/{severity} {key}");
|
|
188 |
+ |
true
|
|
189 |
+ |
}
|
|
190 |
+ |
Ok(Ok(resp)) => {
|
|
191 |
+ |
let status = resp.status();
|
|
192 |
+ |
let text = resp.text().await.unwrap_or_default();
|
|
193 |
+ |
warn!("mnw alert push failed ({status}): {text}");
|
|
194 |
+ |
false
|
|
195 |
+ |
}
|
|
196 |
+ |
Ok(Err(e)) => {
|
|
197 |
+ |
warn!("mnw alert push error: {e}");
|
|
198 |
+ |
false
|
|
199 |
+ |
}
|
|
200 |
+ |
Err(_) => {
|
|
201 |
+ |
warn!("mnw alert push timed out: {kind}/{key}");
|
|
202 |
+ |
false
|
|
203 |
+ |
}
|
|
204 |
+ |
}
|
|
205 |
+ |
}
|
|
206 |
+ |
|
|
207 |
+ |
/// Durable push to the MNW operator log: deliver now, and on failure enqueue
|
|
208 |
+ |
/// for retry through the same `pending_alerts` queue WAM/email use. No-op
|
|
209 |
+ |
/// unless both `mnw_url` and `alerts_ingest_token` are set.
|
|
210 |
+ |
///
|
|
211 |
+ |
/// MNW is a *secondary* sink: the primary channel (WAM/email) already
|
|
212 |
+ |
/// recorded this alert in the ledger, so a queued MNW row carries
|
|
213 |
+ |
/// `channel = "mnw"` and, unlike WAM/email retries, does not re-record on
|
|
214 |
+ |
/// delivery (see [`retry_pending`]) — it only needs to eventually land.
|
|
215 |
+ |
/// `severity` derives from `priority` exactly as the retry path recomputes
|
|
216 |
+ |
/// it (`None` → recovery → `"info"`), so live and retried pushes match.
|
|
217 |
+ |
async fn push_mnw(&self, category: AlertCategory, key: &str, priority: Option<&str>, subject: &str, body: &str) {
|
|
218 |
+ |
if self.config.mnw_url.is_none() || self.config.alerts_ingest_token.is_none() {
|
|
219 |
+ |
return;
|
|
220 |
+ |
}
|
|
221 |
+ |
let kind = mnw_kind(category);
|
|
222 |
+ |
let severity = priority.map(mnw_severity).unwrap_or("info");
|
|
223 |
+ |
if self.deliver_mnw(kind, key, severity, subject, body).await {
|
|
224 |
+ |
return;
|
|
225 |
+ |
}
|
|
226 |
+ |
let category = category.to_string();
|
|
227 |
+ |
let pending = db::NewPendingAlert {
|
|
228 |
+ |
alert_key: key,
|
|
229 |
+ |
category: &category,
|
|
230 |
+ |
channel: "mnw",
|
|
231 |
+ |
subject,
|
|
232 |
+ |
body,
|
|
233 |
+ |
priority,
|
|
234 |
+ |
source: Some("pom"),
|
|
235 |
+ |
source_ref: None,
|
|
236 |
+ |
from_status: None,
|
|
237 |
+ |
to_status: None,
|
|
238 |
+ |
error: None,
|
|
239 |
+ |
};
|
|
240 |
+ |
if let Err(e) = db::enqueue_pending_alert(&self.pool, &pending).await {
|
|
241 |
+ |
warn!("failed to enqueue undelivered mnw alert for retry: {e}");
|
|
242 |
+ |
} else {
|
|
243 |
+ |
warn!("mnw alert to {key} undelivered; queued for retry");
|
|
244 |
+ |
}
|
|
245 |
+ |
}
|
|
246 |
+ |
|
| 107 |
247 |
|
/// Whether a FAILURE alert for `target` is within its cooldown.
|
| 108 |
248 |
|
///
|
| 109 |
249 |
|
/// A recorded recovery *newer* than the last failure clears the cooldown: the
|
| 233 |
373 |
|
info!("alert cooldown active for {}, skipping", meta.key);
|
| 234 |
374 |
|
return;
|
| 235 |
375 |
|
}
|
|
376 |
+ |
// Primary sink (WAM) first so a slow MNW push can't delay the ticket;
|
|
377 |
+ |
// capture what push_mnw needs before `meta` is moved into dispatch.
|
|
378 |
+ |
let (category, key) = (meta.category, meta.key);
|
| 236 |
379 |
|
self.dispatch_wam(subject, body, priority, source, source_ref, meta).await;
|
|
380 |
+ |
self.push_mnw(category, key, Some(priority), subject, body).await;
|
| 237 |
381 |
|
}
|
| 238 |
382 |
|
|
| 239 |
383 |
|
/// Fire a recovery: recovery-cooldown-gate, then dispatch by email. The single
|
| 243 |
387 |
|
info!("recovery cooldown active for {}, skipping", meta.key);
|
| 244 |
388 |
|
return;
|
| 245 |
389 |
|
}
|
|
390 |
+ |
let (category, key) = (meta.category, meta.key);
|
| 246 |
391 |
|
self.dispatch_email(subject, body, meta).await;
|
|
392 |
+ |
self.push_mnw(category, key, None, subject, body).await;
|
| 247 |
393 |
|
}
|
| 248 |
394 |
|
|
| 249 |
395 |
|
/// Dispatch a WAM failure alert: attempt delivery (WAM, falling back to
|
| 314 |
460 |
|
/// signal the caller to delete the row; otherwise leave it for a later tick.
|
| 315 |
461 |
|
/// Called by the retry task in `serve`.
|
| 316 |
462 |
|
pub async fn retry_pending(&self, p: &db::PendingAlertRow) -> bool {
|
|
463 |
+ |
// MNW is a secondary sink: the primary channel already wrote this alert
|
|
464 |
+ |
// to the ledger, so a successful retry only clears the queued row — it
|
|
465 |
+ |
// must not re-record (that would double-count and skew the cooldown).
|
|
466 |
+ |
// Reconstruct kind/severity the same way the live push derives them.
|
|
467 |
+ |
if p.channel == "mnw" {
|
|
468 |
+ |
let kind = p.category.parse::<AlertCategory>().map(mnw_kind).unwrap_or("health");
|
|
469 |
+ |
let severity = p.priority.as_deref().map(mnw_severity).unwrap_or("info");
|
|
470 |
+ |
return self.deliver_mnw(kind, &p.alert_key, severity, &p.subject, &p.body).await;
|
|
471 |
+ |
}
|
| 317 |
472 |
|
let delivered = match p.channel.as_str() {
|
| 318 |
473 |
|
"wam" => {
|
| 319 |
474 |
|
self.wam_ticket(
|
| 395 |
550 |
|
mod tests {
|
| 396 |
551 |
|
use super::*;
|
| 397 |
552 |
|
|
|
553 |
+ |
#[tokio::test]
|
|
554 |
+ |
async fn mnw_retry_routes_to_sink_and_never_records() {
|
|
555 |
+ |
// A queued mnw row must retry via the MNW sink, not the WAM/email path,
|
|
556 |
+ |
// and must NOT write the ledger (the primary channel already did). With
|
|
557 |
+ |
// the sink unconfigured (test_alerter has no mnw_url), delivery fails and
|
|
558 |
+ |
// the row stays queued — and the cooldown ledger is untouched.
|
|
559 |
+ |
let pool = db::connect_in_memory().await.unwrap();
|
|
560 |
+ |
let alerter = test_alerter(pool.clone());
|
|
561 |
+ |
let row = db::PendingAlertRow {
|
|
562 |
+ |
id: 1,
|
|
563 |
+ |
alert_key: "makenot.work".to_string(),
|
|
564 |
+ |
category: "tls_expiry".to_string(),
|
|
565 |
+ |
channel: "mnw".to_string(),
|
|
566 |
+ |
subject: "TLS expiring".to_string(),
|
|
567 |
+ |
body: "cert expires soon".to_string(),
|
|
568 |
+ |
priority: Some("high".to_string()),
|
|
569 |
+ |
source: Some("pom".to_string()),
|
|
570 |
+ |
source_ref: None,
|
|
571 |
+ |
from_status: None,
|
|
572 |
+ |
to_status: None,
|
|
573 |
+ |
error: None,
|
|
574 |
+ |
attempts: 0,
|
|
575 |
+ |
};
|
|
576 |
+ |
assert!(!alerter.retry_pending(&row).await, "unconfigured sink cannot deliver");
|
|
577 |
+ |
assert!(
|
|
578 |
+ |
!alerter.is_within_cooldown("makenot.work").await,
|
|
579 |
+ |
"mnw retry must not record to the ledger"
|
|
580 |
+ |
);
|
|
581 |
+ |
}
|
|
582 |
+ |
|
|
583 |
+ |
#[test]
|
|
584 |
+ |
fn mnw_kind_folds_failure_and_recovery_to_same_domain() {
|
|
585 |
+ |
// A condition and its recovery must map to one kind so they share a
|
|
586 |
+ |
// dedup thread in the MNW log; the failure/recovery split rides severity.
|
|
587 |
+ |
assert_eq!(mnw_kind(AlertCategory::TlsExpiry), "tls");
|
|
588 |
+ |
assert_eq!(mnw_kind(AlertCategory::TlsError), "tls");
|
|
589 |
+ |
assert_eq!(mnw_kind(AlertCategory::TlsRecovery), "tls");
|
|
590 |
+ |
assert_eq!(mnw_kind(AlertCategory::DnsMismatch), "dns");
|
|
591 |
+ |
assert_eq!(mnw_kind(AlertCategory::DnsRecovery), "dns");
|
|
592 |
+ |
assert_eq!(mnw_kind(AlertCategory::TestDurationDrift), "latency");
|
|
593 |
+ |
assert_eq!(mnw_kind(AlertCategory::MonitoringOffline), "monitoring");
|
|
594 |
+ |
}
|
|
595 |
+ |
|
|
596 |
+ |
#[test]
|
|
597 |
+ |
fn mnw_severity_collapses_priorities_to_three_levels() {
|
|
598 |
+ |
assert_eq!(mnw_severity("critical"), "critical");
|
|
599 |
+ |
assert_eq!(mnw_severity("high"), "warning");
|
|
600 |
+ |
assert_eq!(mnw_severity("medium"), "warning");
|
|
601 |
+ |
assert_eq!(mnw_severity("low"), "info");
|
|
602 |
+ |
assert_eq!(mnw_severity("anything-else"), "info");
|
|
603 |
+ |
}
|
|
604 |
+ |
|
|
605 |
+ |
#[test]
|
|
606 |
+ |
fn truncate_respects_cap_and_char_boundaries() {
|
|
607 |
+ |
assert_eq!(truncate("short", 200), "short");
|
|
608 |
+ |
assert_eq!(truncate("abcdef", 3), "abc");
|
|
609 |
+ |
// Cutting mid multi-byte char steps back to the previous boundary rather
|
|
610 |
+ |
// than panicking on a non-boundary slice.
|
|
611 |
+ |
let s = "aé"; // 'é' is two bytes; byte index 2 is not a char boundary
|
|
612 |
+ |
assert_eq!(truncate(s, 2), "a");
|
|
613 |
+ |
}
|
|
614 |
+ |
|
| 398 |
615 |
|
fn test_alerter(pool: SqlitePool) -> Alerter {
|
| 399 |
616 |
|
let config = AlertConfig {
|
| 400 |
617 |
|
postmark_token: None, // dev mode
|
| 402 |
619 |
|
from: "PoM Alerts <pom-alerts@makenot.work>".to_string(),
|
| 403 |
620 |
|
cooldown_secs: 300,
|
| 404 |
621 |
|
wam_url: None,
|
|
622 |
+ |
mnw_url: None,
|
|
623 |
+ |
alerts_ingest_token: None,
|
| 405 |
624 |
|
};
|
| 406 |
625 |
|
Alerter::new(config, pool, "test-instance".to_string())
|
| 407 |
626 |
|
}
|