max / pom
9 files changed,
+257 insertions,
-70 deletions
| @@ -8,6 +8,7 @@ use tracing::{info, instrument, warn}; | |||
| 8 | 8 | ||
| 9 | 9 | use crate::config::AlertConfig; | |
| 10 | 10 | use crate::db; | |
| 11 | + | use crate::types::AlertCategory; | |
| 11 | 12 | ||
| 12 | 13 | #[derive(Clone)] | |
| 13 | 14 | pub struct Alerter { | |
| @@ -56,7 +57,7 @@ impl Alerter { | |||
| 56 | 57 | body.push_str("\n- PoM"); | |
| 57 | 58 | ||
| 58 | 59 | self.send_email(&subject, &body).await; | |
| 59 | - | self.record_alert(&alert_key, "health", Some(from_status), Some(to_status), error).await; | |
| 60 | + | self.record_alert(&alert_key, AlertCategory::Health, Some(from_status), Some(to_status), error).await; | |
| 60 | 61 | } | |
| 61 | 62 | ||
| 62 | 63 | #[instrument(skip_all)] | |
| @@ -80,7 +81,7 @@ impl Alerter { | |||
| 80 | 81 | ); | |
| 81 | 82 | ||
| 82 | 83 | self.send_email(&subject, &body).await; | |
| 83 | - | self.record_alert(&alert_key, "recovery", Some(from_status), Some("operational"), None).await; | |
| 84 | + | self.record_alert(&alert_key, AlertCategory::Recovery, Some(from_status), Some("operational"), None).await; | |
| 84 | 85 | } | |
| 85 | 86 | ||
| 86 | 87 | #[instrument(skip_all)] | |
| @@ -111,7 +112,7 @@ impl Alerter { | |||
| 111 | 112 | ); | |
| 112 | 113 | ||
| 113 | 114 | self.send_email(&subject, &body).await; | |
| 114 | - | self.record_alert(&alert_key, "tls_expiry", None, None, None).await; | |
| 115 | + | self.record_alert(&alert_key, AlertCategory::TlsExpiry, None, None, None).await; | |
| 115 | 116 | } | |
| 116 | 117 | ||
| 117 | 118 | #[instrument(skip_all)] | |
| @@ -140,7 +141,7 @@ impl Alerter { | |||
| 140 | 141 | ); | |
| 141 | 142 | ||
| 142 | 143 | self.send_email(&subject, &body).await; | |
| 143 | - | self.record_alert(&alert_key, "tls_error", None, None, Some(error)).await; | |
| 144 | + | self.record_alert(&alert_key, AlertCategory::TlsError, None, None, Some(error)).await; | |
| 144 | 145 | } | |
| 145 | 146 | ||
| 146 | 147 | #[instrument(skip_all)] | |
| @@ -164,7 +165,7 @@ impl Alerter { | |||
| 164 | 165 | ); | |
| 165 | 166 | ||
| 166 | 167 | self.send_email(&subject, &body).await; | |
| 167 | - | self.record_alert(&alert_key, "tls_recovery", None, None, None).await; | |
| 168 | + | self.record_alert(&alert_key, AlertCategory::TlsRecovery, None, None, None).await; | |
| 168 | 169 | } | |
| 169 | 170 | ||
| 170 | 171 | #[instrument(skip_all)] | |
| @@ -193,7 +194,7 @@ impl Alerter { | |||
| 193 | 194 | ); | |
| 194 | 195 | ||
| 195 | 196 | self.send_email(&subject, &body).await; | |
| 196 | - | self.record_alert(&alert_key, "peer_missing", None, None, None).await; | |
| 197 | + | self.record_alert(&alert_key, AlertCategory::PeerMissing, None, None, None).await; | |
| 197 | 198 | } | |
| 198 | 199 | ||
| 199 | 200 | #[instrument(skip_all)] | |
| @@ -215,7 +216,7 @@ impl Alerter { | |||
| 215 | 216 | ||
| 216 | 217 | let alert_key = format!("peer:{peer_name}"); | |
| 217 | 218 | self.send_email(&subject, &body).await; | |
| 218 | - | self.record_alert(&alert_key, "peer_recovery", None, None, None).await; | |
| 219 | + | self.record_alert(&alert_key, AlertCategory::PeerRecovery, None, None, None).await; | |
| 219 | 220 | } | |
| 220 | 221 | ||
| 221 | 222 | #[instrument(skip_all)] | |
| @@ -245,7 +246,7 @@ impl Alerter { | |||
| 245 | 246 | ); | |
| 246 | 247 | ||
| 247 | 248 | self.send_email(&subject, &body).await; | |
| 248 | - | self.record_alert(&alert_key, "route_failure", None, None, None).await; | |
| 249 | + | self.record_alert(&alert_key, AlertCategory::RouteFailure, None, None, None).await; | |
| 249 | 250 | } | |
| 250 | 251 | ||
| 251 | 252 | #[instrument(skip_all)] | |
| @@ -270,7 +271,7 @@ impl Alerter { | |||
| 270 | 271 | ); | |
| 271 | 272 | ||
| 272 | 273 | self.send_email(&subject, &body).await; | |
| 273 | - | self.record_alert(&alert_key, "route_recovery", None, None, None).await; | |
| 274 | + | self.record_alert(&alert_key, AlertCategory::RouteRecovery, None, None, None).await; | |
| 274 | 275 | } | |
| 275 | 276 | ||
| 276 | 277 | #[instrument(skip_all)] | |
| @@ -313,7 +314,7 @@ impl Alerter { | |||
| 313 | 314 | ); | |
| 314 | 315 | ||
| 315 | 316 | self.send_email(&subject, &body).await; | |
| 316 | - | self.record_alert(&alert_key, "dns_mismatch", None, None, None).await; | |
| 317 | + | self.record_alert(&alert_key, AlertCategory::DnsMismatch, None, None, None).await; | |
| 317 | 318 | } | |
| 318 | 319 | ||
| 319 | 320 | #[instrument(skip_all)] | |
| @@ -336,7 +337,7 @@ impl Alerter { | |||
| 336 | 337 | ); | |
| 337 | 338 | ||
| 338 | 339 | self.send_email(&subject, &body).await; | |
| 339 | - | self.record_alert(&alert_key, "dns_recovery", None, None, None).await; | |
| 340 | + | self.record_alert(&alert_key, AlertCategory::DnsRecovery, None, None, None).await; | |
| 340 | 341 | } | |
| 341 | 342 | ||
| 342 | 343 | #[instrument(skip_all)] | |
| @@ -366,7 +367,7 @@ impl Alerter { | |||
| 366 | 367 | ); | |
| 367 | 368 | ||
| 368 | 369 | self.send_email(&subject, &body).await; | |
| 369 | - | self.record_alert(&alert_key, "whois_expiry", None, None, None).await; | |
| 370 | + | self.record_alert(&alert_key, AlertCategory::WhoisExpiry, None, None, None).await; | |
| 370 | 371 | } | |
| 371 | 372 | ||
| 372 | 373 | #[instrument(skip_all)] | |
| @@ -396,7 +397,7 @@ impl Alerter { | |||
| 396 | 397 | ); | |
| 397 | 398 | ||
| 398 | 399 | self.send_email(&subject, &body).await; | |
| 399 | - | self.record_alert(&alert_key, "whois_error", None, None, Some(error)).await; | |
| 400 | + | self.record_alert(&alert_key, AlertCategory::WhoisError, None, None, Some(error)).await; | |
| 400 | 401 | } | |
| 401 | 402 | ||
| 402 | 403 | #[instrument(skip_all)] | |
| @@ -424,7 +425,7 @@ impl Alerter { | |||
| 424 | 425 | ); | |
| 425 | 426 | ||
| 426 | 427 | self.send_email(&subject, &body).await; | |
| 427 | - | self.record_alert(&alert_key, "latency_drift", None, None, Some(drift_message)).await; | |
| 428 | + | self.record_alert(&alert_key, AlertCategory::LatencyDrift, None, None, Some(drift_message)).await; | |
| 428 | 429 | } | |
| 429 | 430 | ||
| 430 | 431 | #[instrument(skip_all)] | |
| @@ -447,7 +448,7 @@ impl Alerter { | |||
| 447 | 448 | ); | |
| 448 | 449 | ||
| 449 | 450 | self.send_email(&subject, &body).await; | |
| 450 | - | self.record_alert(&alert_key, "latency_recovery", None, None, None).await; | |
| 451 | + | self.record_alert(&alert_key, AlertCategory::LatencyRecovery, None, None, None).await; | |
| 451 | 452 | } | |
| 452 | 453 | ||
| 453 | 454 | #[instrument(skip_all)] | |
| @@ -475,7 +476,7 @@ impl Alerter { | |||
| 475 | 476 | ); | |
| 476 | 477 | ||
| 477 | 478 | self.send_email(&subject, &body).await; | |
| 478 | - | self.record_alert(&alert_key, "test_duration_drift", None, None, Some(drift_message)).await; | |
| 479 | + | self.record_alert(&alert_key, AlertCategory::TestDurationDrift, None, None, Some(drift_message)).await; | |
| 479 | 480 | } | |
| 480 | 481 | ||
| 481 | 482 | /// All monitored targets are unreachable — likely a network issue with PoM itself. | |
| @@ -500,7 +501,7 @@ impl Alerter { | |||
| 500 | 501 | ); | |
| 501 | 502 | ||
| 502 | 503 | self.send_email(&subject, &body).await; | |
| 503 | - | self.record_alert(alert_key, "monitoring_offline", None, None, None).await; | |
| 504 | + | self.record_alert(alert_key, AlertCategory::MonitoringOffline, None, None, None).await; | |
| 504 | 505 | } | |
| 505 | 506 | ||
| 506 | 507 | /// At least one target is reachable again after a monitoring-offline event. | |
| @@ -518,7 +519,7 @@ impl Alerter { | |||
| 518 | 519 | ); | |
| 519 | 520 | ||
| 520 | 521 | self.send_email(&subject, &body).await; | |
| 521 | - | self.record_alert(alert_key, "monitoring_recovery", None, None, None).await; | |
| 522 | + | self.record_alert(alert_key, AlertCategory::MonitoringRecovery, None, None, None).await; | |
| 522 | 523 | } | |
| 523 | 524 | ||
| 524 | 525 | async fn is_within_cooldown(&self, target: &str) -> bool { | |
| @@ -550,38 +551,45 @@ impl Alerter { | |||
| 550 | 551 | "TextBody": body, | |
| 551 | 552 | }); | |
| 552 | 553 | ||
| 553 | - | match self.client | |
| 554 | + | let send_fut = self.client | |
| 554 | 555 | .post("https://api.postmarkapp.com/email") | |
| 555 | 556 | .header("X-Postmark-Server-Token", token) | |
| 556 | 557 | .header("Content-Type", "application/json") | |
| 557 | 558 | .header("Accept", "application/json") | |
| 558 | 559 | .json(&payload) | |
| 559 | - | .send() | |
| 560 | - | .await | |
| 561 | - | { | |
| 562 | - | Ok(resp) if resp.status().is_success() => { | |
| 560 | + | .send(); | |
| 561 | + | ||
| 562 | + | // Wrap in a 30-second timeout to prevent Postmark latency from blocking | |
| 563 | + | // the alert task. The reqwest client has its own 10s timeout, but this | |
| 564 | + | // guards against DNS resolution stalls and connection pool exhaustion. | |
| 565 | + | match tokio::time::timeout(std::time::Duration::from_secs(30), send_fut).await { | |
| 566 | + | Ok(Ok(resp)) if resp.status().is_success() => { | |
| 563 | 567 | info!("alert sent: {subject}"); | |
| 564 | 568 | } | |
| 565 | - | Ok(resp) => { | |
| 569 | + | Ok(Ok(resp)) => { | |
| 566 | 570 | let status = resp.status(); | |
| 567 | 571 | let text = resp.text().await.unwrap_or_default(); | |
| 568 | 572 | warn!("postmark error ({status}): {text}"); | |
| 569 | 573 | } | |
| 570 | - | Err(e) => { | |
| 574 | + | Ok(Err(e)) => { | |
| 571 | 575 | warn!("failed to send alert: {e}"); | |
| 572 | 576 | } | |
| 577 | + | Err(_) => { | |
| 578 | + | warn!("alert send timed out after 30s: {subject}"); | |
| 579 | + | } | |
| 573 | 580 | } | |
| 574 | 581 | } | |
| 575 | 582 | ||
| 576 | 583 | async fn record_alert( | |
| 577 | 584 | &self, | |
| 578 | 585 | target: &str, | |
| 579 | - | alert_type: &str, | |
| 586 | + | alert_type: AlertCategory, | |
| 580 | 587 | from_status: Option<&str>, | |
| 581 | 588 | to_status: Option<&str>, | |
| 582 | 589 | error: Option<&str>, | |
| 583 | 590 | ) { | |
| 584 | - | if let Err(e) = db::insert_alert(&self.pool, target, alert_type, from_status, to_status, error).await { | |
| 591 | + | let alert_type_str = alert_type.to_string(); | |
| 592 | + | if let Err(e) = db::insert_alert(&self.pool, target, &alert_type_str, from_status, to_status, error).await { | |
| 585 | 593 | warn!("failed to record alert: {e}"); | |
| 586 | 594 | } | |
| 587 | 595 | } | |
| @@ -690,7 +698,7 @@ mod tests { | |||
| 690 | 698 | let mismatches = vec![crate::types::DnsCheckResult { | |
| 691 | 699 | target: "mnw".to_string(), | |
| 692 | 700 | name: "makenot.work".to_string(), | |
| 693 | - | record_type: "A".to_string(), | |
| 701 | + | record_type: crate::types::DnsRecordType::A, | |
| 694 | 702 | expected: vec!["1.2.3.4".to_string()], | |
| 695 | 703 | actual: vec!["5.6.7.8".to_string()], | |
| 696 | 704 | matches: false, |
| @@ -257,8 +257,11 @@ async fn build_target_status( | |||
| 257 | 257 | .await | |
| 258 | 258 | .unwrap_or_default(); | |
| 259 | 259 | ||
| 260 | - | let latest_snapshot = recent.first().cloned(); | |
| 261 | - | let latest = latest_snapshot.clone().map(SnapshotJson::from); | |
| 260 | + | // Extract the version info we need before consuming the snapshots. | |
| 261 | + | let latest_version = recent.first() | |
| 262 | + | .and_then(|s| s.details.as_ref()) | |
| 263 | + | .and_then(|d| d.version.clone()); | |
| 264 | + | let latest = recent.first().cloned().map(SnapshotJson::from); | |
| 262 | 265 | let recent_json: Vec<SnapshotJson> = recent.into_iter().map(SnapshotJson::from).collect(); | |
| 263 | 266 | ||
| 264 | 267 | let uptime_24h = db::get_uptime_percent(pool, name, 24) | |
| @@ -289,10 +292,7 @@ async fn build_target_status( | |||
| 289 | 292 | let test_staleness = if let Some(target_config) = config.get_target(name) | |
| 290 | 293 | && let Some(tests_config) = &target_config.tests | |
| 291 | 294 | { | |
| 292 | - | let current_version = latest_snapshot | |
| 293 | - | .as_ref() | |
| 294 | - | .and_then(|s| s.details.as_ref()) | |
| 295 | - | .and_then(|d| d.version.clone()); | |
| 295 | + | let current_version = latest_version.clone(); | |
| 296 | 296 | ||
| 297 | 297 | let latest_test = db::get_latest_test_run(pool, name).await.unwrap_or(None); | |
| 298 | 298 |
| @@ -6,7 +6,7 @@ use hickory_resolver::TokioResolver; | |||
| 6 | 6 | use tracing::instrument; | |
| 7 | 7 | ||
| 8 | 8 | use crate::config::DnsRecord; | |
| 9 | - | use crate::types::DnsCheckResult; | |
| 9 | + | use crate::types::{DnsCheckResult, DnsRecordType}; | |
| 10 | 10 | ||
| 11 | 11 | /// Resolve DNS records and compare against expected values. | |
| 12 | 12 | /// Returns one `DnsCheckResult` per `DnsRecord` in the input. | |
| @@ -20,7 +20,7 @@ pub async fn check_dns(target: &str, records: &[DnsRecord]) -> Vec<DnsCheckResul | |||
| 20 | 20 | .map(|r| DnsCheckResult { | |
| 21 | 21 | target: target.to_string(), | |
| 22 | 22 | name: r.name.clone(), | |
| 23 | - | record_type: r.record_type.clone(), | |
| 23 | + | record_type: r.record_type, | |
| 24 | 24 | expected: r.expected.clone(), | |
| 25 | 25 | actual: vec![], | |
| 26 | 26 | matches: false, | |
| @@ -46,13 +46,12 @@ async fn resolve_record( | |||
| 46 | 46 | ) -> DnsCheckResult { | |
| 47 | 47 | let now = chrono::Utc::now().to_rfc3339(); | |
| 48 | 48 | ||
| 49 | - | let actual = match record.record_type.as_str() { | |
| 50 | - | "A" => resolve_a(resolver, &record.name).await, | |
| 51 | - | "AAAA" => resolve_aaaa(resolver, &record.name).await, | |
| 52 | - | "CNAME" => resolve_cname(resolver, &record.name).await, | |
| 53 | - | "MX" => resolve_mx(resolver, &record.name).await, | |
| 54 | - | "TXT" => resolve_txt(resolver, &record.name).await, | |
| 55 | - | other => Err(format!("unsupported record type: {other}")), | |
| 49 | + | let actual = match record.record_type { | |
| 50 | + | DnsRecordType::A => resolve_a(resolver, &record.name).await, | |
| 51 | + | DnsRecordType::Aaaa => resolve_aaaa(resolver, &record.name).await, | |
| 52 | + | DnsRecordType::Cname => resolve_cname(resolver, &record.name).await, | |
| 53 | + | DnsRecordType::Mx => resolve_mx(resolver, &record.name).await, | |
| 54 | + | DnsRecordType::Txt => resolve_txt(resolver, &record.name).await, | |
| 56 | 55 | }; | |
| 57 | 56 | ||
| 58 | 57 | match actual { | |
| @@ -61,7 +60,7 @@ async fn resolve_record( | |||
| 61 | 60 | DnsCheckResult { | |
| 62 | 61 | target: target.to_string(), | |
| 63 | 62 | name: record.name.clone(), | |
| 64 | - | record_type: record.record_type.clone(), | |
| 63 | + | record_type: record.record_type, | |
| 65 | 64 | expected: record.expected.clone(), | |
| 66 | 65 | actual: actual_values, | |
| 67 | 66 | matches, | |
| @@ -72,7 +71,7 @@ async fn resolve_record( | |||
| 72 | 71 | Err(e) => DnsCheckResult { | |
| 73 | 72 | target: target.to_string(), | |
| 74 | 73 | name: record.name.clone(), | |
| 75 | - | record_type: record.record_type.clone(), | |
| 74 | + | record_type: record.record_type, | |
| 76 | 75 | expected: record.expected.clone(), | |
| 77 | 76 | actual: vec![], | |
| 78 | 77 | matches: false, |
| @@ -155,11 +155,11 @@ pub fn validate_expectations( | |||
| 155 | 155 | for (path, expected_value) in &expect.json_fields { | |
| 156 | 156 | match resolve_json_path(json, path) { | |
| 157 | 157 | Some(actual) => { | |
| 158 | - | let actual_str = match actual { | |
| 159 | - | serde_json::Value::String(s) => s.clone(), | |
| 160 | - | other => other.to_string(), | |
| 158 | + | let actual_str: std::borrow::Cow<'_, str> = match actual { | |
| 159 | + | serde_json::Value::String(s) => std::borrow::Cow::Borrowed(s), | |
| 160 | + | other => std::borrow::Cow::Owned(other.to_string()), | |
| 161 | 161 | }; | |
| 162 | - | if actual_str != *expected_value { | |
| 162 | + | if *actual_str != *expected_value { | |
| 163 | 163 | failures.push(format!("json field \"{path}\": expected \"{expected_value}\", got \"{actual_str}\"")); | |
| 164 | 164 | } | |
| 165 | 165 | } |
| @@ -34,7 +34,7 @@ pub(crate) fn spawn_dns_tasks( | |||
| 34 | 34 | std::time::Duration::from_secs(dns_interval_secs), | |
| 35 | 35 | ); | |
| 36 | 36 | interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); | |
| 37 | - | let mut prev_mismatched: std::collections::HashSet<(String, String)> = std::collections::HashSet::new(); | |
| 37 | + | let mut prev_mismatched: std::collections::HashSet<(String, pom::types::DnsRecordType)> = std::collections::HashSet::new(); | |
| 38 | 38 | ||
| 39 | 39 | interval.tick().await; // consume immediate first tick | |
| 40 | 40 | loop { | |
| @@ -50,10 +50,10 @@ pub(crate) fn spawn_dns_tasks( | |||
| 50 | 50 | } | |
| 51 | 51 | } | |
| 52 | 52 | ||
| 53 | - | let current_mismatched: std::collections::HashSet<(String, String)> = results | |
| 53 | + | let current_mismatched: std::collections::HashSet<(String, pom::types::DnsRecordType)> = results | |
| 54 | 54 | .iter() | |
| 55 | 55 | .filter(|r| !r.matches) | |
| 56 | - | .map(|r| (r.name.clone(), r.record_type.clone())) | |
| 56 | + | .map(|r| (r.name.clone(), r.record_type)) | |
| 57 | 57 | .collect(); | |
| 58 | 58 | ||
| 59 | 59 | let ok_count = results.iter().filter(|r| r.matches).count(); | |
| @@ -63,7 +63,7 @@ pub(crate) fn spawn_dns_tasks( | |||
| 63 | 63 | // New mismatches | |
| 64 | 64 | let new_mismatches: Vec<&pom::types::DnsCheckResult> = results | |
| 65 | 65 | .iter() | |
| 66 | - | .filter(|r| !r.matches && !prev_mismatched.contains(&(r.name.clone(), r.record_type.clone()))) | |
| 66 | + | .filter(|r| !r.matches && !prev_mismatched.contains(&(r.name.clone(), r.record_type))) | |
| 67 | 67 | .collect(); | |
| 68 | 68 | if !new_mismatches.is_empty() { | |
| 69 | 69 | let owned: Vec<pom::types::DnsCheckResult> = new_mismatches.into_iter().cloned().collect(); |
| @@ -6,6 +6,7 @@ use std::path::{Path, PathBuf}; | |||
| 6 | 6 | ||
| 7 | 7 | use crate::error::{PomError, Result}; | |
| 8 | 8 | use crate::peer::OnMissing; | |
| 9 | + | use crate::types::DnsRecordType; | |
| 9 | 10 | ||
| 10 | 11 | #[derive(Debug, Clone, Deserialize)] | |
| 11 | 12 | pub struct Config { | |
| @@ -168,8 +169,8 @@ pub struct TargetConfig { | |||
| 168 | 169 | pub struct DnsRecord { | |
| 169 | 170 | /// Hostname to resolve (e.g. "makenot.work"). | |
| 170 | 171 | pub name: String, | |
| 171 | - | /// DNS record type: "A", "AAAA", "CNAME", "MX", "TXT". | |
| 172 | - | pub record_type: String, | |
| 172 | + | /// DNS record type: A, AAAA, CNAME, MX, TXT. | |
| 173 | + | pub record_type: DnsRecordType, | |
| 173 | 174 | /// Expected values (order-independent set comparison). | |
| 174 | 175 | pub expected: Vec<String>, | |
| 175 | 176 | } | |
| @@ -780,7 +781,7 @@ expected = ["5.78.144.244"] | |||
| 780 | 781 | let mnw = config.get_target("mnw").unwrap(); | |
| 781 | 782 | assert_eq!(mnw.dns.len(), 2); | |
| 782 | 783 | assert_eq!(mnw.dns[0].name, "makenot.work"); | |
| 783 | - | assert_eq!(mnw.dns[0].record_type, "A"); | |
| 784 | + | assert_eq!(mnw.dns[0].record_type, DnsRecordType::A); | |
| 784 | 785 | assert_eq!(mnw.dns[0].expected, vec!["5.78.144.244"]); | |
| 785 | 786 | assert_eq!(mnw.dns[1].name, "git.makenot.work"); | |
| 786 | 787 | } |
| @@ -876,6 +876,7 @@ pub async fn insert_dns_check( | |||
| 876 | 876 | ) -> Result<i64> { | |
| 877 | 877 | let expected = serde_json::to_string(&result.expected).unwrap_or_default(); | |
| 878 | 878 | let actual = serde_json::to_string(&result.actual).unwrap_or_default(); | |
| 879 | + | let record_type_str = result.record_type.to_string(); | |
| 879 | 880 | ||
| 880 | 881 | let row = sqlx::query( | |
| 881 | 882 | "INSERT INTO dns_checks (target, name, record_type, expected, actual, matches, checked_at, error) | |
| @@ -883,7 +884,7 @@ pub async fn insert_dns_check( | |||
| 883 | 884 | ) | |
| 884 | 885 | .bind(&result.target) | |
| 885 | 886 | .bind(&result.name) | |
| 886 | - | .bind(&result.record_type) | |
| 887 | + | .bind(&record_type_str) | |
| 887 | 888 | .bind(&expected) | |
| 888 | 889 | .bind(&actual) | |
| 889 | 890 | .bind(result.matches) |
| @@ -1,7 +1,127 @@ | |||
| 1 | 1 | //! Shared domain types — health snapshots, test runs, and target info. | |
| 2 | 2 | ||
| 3 | + | use std::fmt; | |
| 4 | + | ||
| 3 | 5 | use serde::{Deserialize, Serialize}; | |
| 4 | 6 | ||
| 7 | + | /// Alert category — identifies the type of alert being sent. | |
| 8 | + | /// Stored as the `alert_type` column in the `alerts` table. | |
| 9 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | |
| 10 | + | #[serde(rename_all = "snake_case")] | |
| 11 | + | pub enum AlertCategory { | |
| 12 | + | Health, | |
| 13 | + | Recovery, | |
| 14 | + | TlsExpiry, | |
| 15 | + | TlsError, | |
| 16 | + | TlsRecovery, | |
| 17 | + | PeerMissing, | |
| 18 | + | PeerRecovery, | |
| 19 | + | RouteFailure, | |
| 20 | + | RouteRecovery, | |
| 21 | + | DnsMismatch, | |
| 22 | + | DnsRecovery, | |
| 23 | + | WhoisExpiry, | |
| 24 | + | WhoisError, | |
| 25 | + | LatencyDrift, | |
| 26 | + | LatencyRecovery, | |
| 27 | + | TestDurationDrift, | |
| 28 | + | MonitoringOffline, | |
| 29 | + | MonitoringRecovery, | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | impl fmt::Display for AlertCategory { | |
| 33 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 34 | + | match self { | |
| 35 | + | Self::Health => write!(f, "health"), | |
| 36 | + | Self::Recovery => write!(f, "recovery"), | |
| 37 | + | Self::TlsExpiry => write!(f, "tls_expiry"), | |
| 38 | + | Self::TlsError => write!(f, "tls_error"), | |
| 39 | + | Self::TlsRecovery => write!(f, "tls_recovery"), | |
| 40 | + | Self::PeerMissing => write!(f, "peer_missing"), | |
| 41 | + | Self::PeerRecovery => write!(f, "peer_recovery"), | |
| 42 | + | Self::RouteFailure => write!(f, "route_failure"), | |
| 43 | + | Self::RouteRecovery => write!(f, "route_recovery"), | |
| 44 | + | Self::DnsMismatch => write!(f, "dns_mismatch"), | |
| 45 | + | Self::DnsRecovery => write!(f, "dns_recovery"), | |
| 46 | + | Self::WhoisExpiry => write!(f, "whois_expiry"), | |
| 47 | + | Self::WhoisError => write!(f, "whois_error"), | |
| 48 | + | Self::LatencyDrift => write!(f, "latency_drift"), | |
| 49 | + | Self::LatencyRecovery => write!(f, "latency_recovery"), | |
| 50 | + | Self::TestDurationDrift => write!(f, "test_duration_drift"), | |
| 51 | + | Self::MonitoringOffline => write!(f, "monitoring_offline"), | |
| 52 | + | Self::MonitoringRecovery => write!(f, "monitoring_recovery"), | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | impl std::str::FromStr for AlertCategory { | |
| 58 | + | type Err = String; | |
| 59 | + | fn from_str(s: &str) -> Result<Self, Self::Err> { | |
| 60 | + | match s { | |
| 61 | + | "health" => Ok(Self::Health), | |
| 62 | + | "recovery" => Ok(Self::Recovery), | |
| 63 | + | "tls_expiry" => Ok(Self::TlsExpiry), | |
| 64 | + | "tls_error" => Ok(Self::TlsError), | |
| 65 | + | "tls_recovery" => Ok(Self::TlsRecovery), | |
| 66 | + | "peer_missing" => Ok(Self::PeerMissing), | |
| 67 | + | "peer_recovery" => Ok(Self::PeerRecovery), | |
| 68 | + | "route_failure" => Ok(Self::RouteFailure), | |
| 69 | + | "route_recovery" => Ok(Self::RouteRecovery), | |
| 70 | + | "dns_mismatch" => Ok(Self::DnsMismatch), | |
| 71 | + | "dns_recovery" => Ok(Self::DnsRecovery), | |
| 72 | + | "whois_expiry" => Ok(Self::WhoisExpiry), | |
| 73 | + | "whois_error" => Ok(Self::WhoisError), | |
| 74 | + | "latency_drift" => Ok(Self::LatencyDrift), | |
| 75 | + | "latency_recovery" => Ok(Self::LatencyRecovery), | |
| 76 | + | "test_duration_drift" => Ok(Self::TestDurationDrift), | |
| 77 | + | "monitoring_offline" => Ok(Self::MonitoringOffline), | |
| 78 | + | "monitoring_recovery" => Ok(Self::MonitoringRecovery), | |
| 79 | + | other => Err(format!("unknown alert category: {other}")), | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | /// DNS record type for configuration and checks. | |
| 85 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] | |
| 86 | + | #[serde(rename_all = "SCREAMING_SNAKE_CASE")] | |
| 87 | + | pub enum DnsRecordType { | |
| 88 | + | A, | |
| 89 | + | #[serde(rename = "AAAA")] | |
| 90 | + | Aaaa, | |
| 91 | + | #[serde(rename = "CNAME")] | |
| 92 | + | Cname, | |
| 93 | + | #[serde(rename = "MX")] | |
| 94 | + | Mx, | |
| 95 | + | #[serde(rename = "TXT")] | |
| 96 | + | Txt, | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | impl fmt::Display for DnsRecordType { | |
| 100 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 101 | + | match self { | |
| 102 | + | Self::A => write!(f, "A"), | |
| 103 | + | Self::Aaaa => write!(f, "AAAA"), | |
| 104 | + | Self::Cname => write!(f, "CNAME"), | |
| 105 | + | Self::Mx => write!(f, "MX"), | |
| 106 | + | Self::Txt => write!(f, "TXT"), | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | impl std::str::FromStr for DnsRecordType { | |
| 112 | + | type Err = String; | |
| 113 | + | fn from_str(s: &str) -> Result<Self, Self::Err> { | |
| 114 | + | match s { | |
| 115 | + | "A" => Ok(Self::A), | |
| 116 | + | "AAAA" => Ok(Self::Aaaa), | |
| 117 | + | "CNAME" => Ok(Self::Cname), | |
| 118 | + | "MX" => Ok(Self::Mx), | |
| 119 | + | "TXT" => Ok(Self::Txt), | |
| 120 | + | other => Err(format!("unsupported DNS record type: {other}")), | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | ||
| 5 | 125 | #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] | |
| 6 | 126 | #[serde(rename_all = "lowercase")] | |
| 7 | 127 | pub enum HealthStatus { | |
| @@ -226,7 +346,7 @@ pub struct DnsCheckResult { | |||
| 226 | 346 | /// Queried hostname (e.g. "makenot.work"). | |
| 227 | 347 | pub name: String, | |
| 228 | 348 | /// DNS record type (A, AAAA, CNAME, MX, TXT). | |
| 229 | - | pub record_type: String, | |
| 349 | + | pub record_type: DnsRecordType, | |
| 230 | 350 | /// Expected values from config. | |
| 231 | 351 | pub expected: Vec<String>, | |
| 232 | 352 | /// Actually resolved values. | |
| @@ -449,7 +569,7 @@ mod tests { | |||
| 449 | 569 | let result = DnsCheckResult { | |
| 450 | 570 | target: "mnw".to_string(), | |
| 451 | 571 | name: "makenot.work".to_string(), | |
| 452 | - | record_type: "A".to_string(), | |
| 572 | + | record_type: DnsRecordType::A, | |
| 453 | 573 | expected: vec!["5.78.144.244".to_string()], | |
| 454 | 574 | actual: vec!["5.78.144.244".to_string()], | |
| 455 | 575 | matches: true, | |
| @@ -460,10 +580,68 @@ mod tests { | |||
| 460 | 580 | let parsed: DnsCheckResult = serde_json::from_str(&json).unwrap(); | |
| 461 | 581 | assert_eq!(parsed.target, "mnw"); | |
| 462 | 582 | assert_eq!(parsed.name, "makenot.work"); | |
| 583 | + | assert_eq!(parsed.record_type, DnsRecordType::A); | |
| 463 | 584 | assert!(parsed.matches); | |
| 464 | 585 | assert!(parsed.error.is_none()); | |
| 465 | 586 | } | |
| 466 | 587 | ||
| 588 | + | // --- AlertCategory --- | |
| 589 | + | ||
| 590 | + | #[test] | |
| 591 | + | fn alert_category_display_roundtrip() { | |
| 592 | + | for category in [ | |
| 593 | + | AlertCategory::Health, | |
| 594 | + | AlertCategory::Recovery, | |
| 595 | + | AlertCategory::TlsExpiry, | |
| 596 | + | AlertCategory::TlsError, | |
| 597 | + | AlertCategory::TlsRecovery, | |
| 598 | + | AlertCategory::PeerMissing, | |
| 599 | + | AlertCategory::PeerRecovery, | |
| 600 | + | AlertCategory::RouteFailure, | |
| 601 | + | AlertCategory::RouteRecovery, | |
| 602 | + | AlertCategory::DnsMismatch, | |
| 603 | + | AlertCategory::DnsRecovery, | |
| 604 | + | AlertCategory::WhoisExpiry, | |
| 605 | + | AlertCategory::WhoisError, | |
| 606 | + | AlertCategory::LatencyDrift, | |
| 607 | + | AlertCategory::LatencyRecovery, | |
| 608 | + | AlertCategory::TestDurationDrift, | |
| 609 | + | AlertCategory::MonitoringOffline, | |
| 610 | + | AlertCategory::MonitoringRecovery, | |
| 611 | + | ] { | |
| 612 | + | let s = category.to_string(); | |
| 613 | + | let parsed: AlertCategory = s.parse().unwrap(); | |
| 614 | + | assert_eq!(parsed, category); | |
| 615 | + | } | |
| 616 | + | } | |
| 617 | + | ||
| 618 | + | #[test] | |
| 619 | + | fn alert_category_from_str_rejects_unknown() { | |
| 620 | + | assert!("bogus".parse::<AlertCategory>().is_err()); | |
| 621 | + | } | |
| 622 | + | ||
| 623 | + | // --- DnsRecordType --- | |
| 624 | + | ||
| 625 | + | #[test] | |
| 626 | + | fn dns_record_type_display_roundtrip() { | |
| 627 | + | for rt in [ | |
| 628 | + | DnsRecordType::A, | |
| 629 | + | DnsRecordType::Aaaa, | |
| 630 | + | DnsRecordType::Cname, | |
| 631 | + | DnsRecordType::Mx, | |
| 632 | + | DnsRecordType::Txt, | |
| 633 | + | ] { | |
| 634 | + | let s = rt.to_string(); | |
| 635 | + | let parsed: DnsRecordType = s.parse().unwrap(); | |
| 636 | + | assert_eq!(parsed, rt); | |
| 637 | + | } | |
| 638 | + | } | |
| 639 | + | ||
| 640 | + | #[test] | |
| 641 | + | fn dns_record_type_from_str_rejects_unknown() { | |
| 642 | + | assert!("SRV".parse::<DnsRecordType>().is_err()); | |
| 643 | + | } | |
| 644 | + | ||
| 467 | 645 | #[test] | |
| 468 | 646 | fn whois_result_serde_roundtrip() { | |
| 469 | 647 | let result = WhoisResult { |
| @@ -2203,7 +2203,7 @@ async fn migration_v6_creates_dns_and_whois_tables() { | |||
| 2203 | 2203 | let dns_result = DnsCheckResult { | |
| 2204 | 2204 | target: "mnw".to_string(), | |
| 2205 | 2205 | name: "makenot.work".to_string(), | |
| 2206 | - | record_type: "A".to_string(), | |
| 2206 | + | record_type: pom::types::DnsRecordType::A, | |
| 2207 | 2207 | expected: vec!["5.78.144.244".to_string()], | |
| 2208 | 2208 | actual: vec!["5.78.144.244".to_string()], | |
| 2209 | 2209 | matches: true, | |
| @@ -2235,7 +2235,7 @@ async fn dns_check_insert_and_query() { | |||
| 2235 | 2235 | let result = DnsCheckResult { | |
| 2236 | 2236 | target: "mnw".to_string(), | |
| 2237 | 2237 | name: "makenot.work".to_string(), | |
| 2238 | - | record_type: "A".to_string(), | |
| 2238 | + | record_type: pom::types::DnsRecordType::A, | |
| 2239 | 2239 | expected: vec!["5.78.144.244".to_string()], | |
| 2240 | 2240 | actual: vec!["5.78.144.244".to_string()], | |
| 2241 | 2241 | matches: true, | |
| @@ -2259,7 +2259,7 @@ async fn dns_check_latest_per_name_and_type() { | |||
| 2259 | 2259 | let r1 = DnsCheckResult { | |
| 2260 | 2260 | target: "mnw".to_string(), | |
| 2261 | 2261 | name: "makenot.work".to_string(), | |
| 2262 | - | record_type: "A".to_string(), | |
| 2262 | + | record_type: pom::types::DnsRecordType::A, | |
| 2263 | 2263 | expected: vec!["1.2.3.4".to_string()], | |
| 2264 | 2264 | actual: vec!["5.6.7.8".to_string()], | |
| 2265 | 2265 | matches: false, | |
| @@ -2269,7 +2269,7 @@ async fn dns_check_latest_per_name_and_type() { | |||
| 2269 | 2269 | let r2 = DnsCheckResult { | |
| 2270 | 2270 | target: "mnw".to_string(), | |
| 2271 | 2271 | name: "makenot.work".to_string(), | |
| 2272 | - | record_type: "A".to_string(), | |
| 2272 | + | record_type: pom::types::DnsRecordType::A, | |
| 2273 | 2273 | expected: vec!["5.78.144.244".to_string()], | |
| 2274 | 2274 | actual: vec!["5.78.144.244".to_string()], | |
| 2275 | 2275 | matches: true, | |
| @@ -2292,7 +2292,7 @@ async fn dns_check_multiple_records() { | |||
| 2292 | 2292 | let r1 = DnsCheckResult { | |
| 2293 | 2293 | target: "mnw".to_string(), | |
| 2294 | 2294 | name: "makenot.work".to_string(), | |
| 2295 | - | record_type: "A".to_string(), | |
| 2295 | + | record_type: pom::types::DnsRecordType::A, | |
| 2296 | 2296 | expected: vec!["5.78.144.244".to_string()], | |
| 2297 | 2297 | actual: vec!["5.78.144.244".to_string()], | |
| 2298 | 2298 | matches: true, | |
| @@ -2302,7 +2302,7 @@ async fn dns_check_multiple_records() { | |||
| 2302 | 2302 | let r2 = DnsCheckResult { | |
| 2303 | 2303 | target: "mnw".to_string(), | |
| 2304 | 2304 | name: "forums.makenot.work".to_string(), | |
| 2305 | - | record_type: "A".to_string(), | |
| 2305 | + | record_type: pom::types::DnsRecordType::A, | |
| 2306 | 2306 | expected: vec!["5.78.144.244".to_string()], | |
| 2307 | 2307 | actual: vec!["5.78.144.244".to_string()], | |
| 2308 | 2308 | matches: true, | |
| @@ -2323,7 +2323,7 @@ async fn dns_check_filters_by_target() { | |||
| 2323 | 2323 | let r1 = DnsCheckResult { | |
| 2324 | 2324 | target: "mnw".to_string(), | |
| 2325 | 2325 | name: "makenot.work".to_string(), | |
| 2326 | - | record_type: "A".to_string(), | |
| 2326 | + | record_type: pom::types::DnsRecordType::A, | |
| 2327 | 2327 | expected: vec!["5.78.144.244".to_string()], | |
| 2328 | 2328 | actual: vec!["5.78.144.244".to_string()], | |
| 2329 | 2329 | matches: true, | |
| @@ -2333,7 +2333,7 @@ async fn dns_check_filters_by_target() { | |||
| 2333 | 2333 | let r2 = DnsCheckResult { | |
| 2334 | 2334 | target: "htpy".to_string(), | |
| 2335 | 2335 | name: "htpy.app".to_string(), | |
| 2336 | - | record_type: "A".to_string(), | |
| 2336 | + | record_type: pom::types::DnsRecordType::A, | |
| 2337 | 2337 | expected: vec!["5.78.135.189".to_string()], | |
| 2338 | 2338 | actual: vec!["5.78.135.189".to_string()], | |
| 2339 | 2339 | matches: true, | |
| @@ -2456,7 +2456,7 @@ async fn prune_removes_old_dns_checks() { | |||
| 2456 | 2456 | let recent = DnsCheckResult { | |
| 2457 | 2457 | target: "mnw".to_string(), | |
| 2458 | 2458 | name: "makenot.work".to_string(), | |
| 2459 | - | record_type: "A".to_string(), | |
| 2459 | + | record_type: pom::types::DnsRecordType::A, | |
| 2460 | 2460 | expected: vec![], | |
| 2461 | 2461 | actual: vec![], | |
| 2462 | 2462 | matches: true, | |
| @@ -2519,7 +2519,7 @@ async fn api_status_includes_dns_status() { | |||
| 2519 | 2519 | let dns_result = DnsCheckResult { | |
| 2520 | 2520 | target: "mnw".to_string(), | |
| 2521 | 2521 | name: "makenot.work".to_string(), | |
| 2522 | - | record_type: "A".to_string(), | |
| 2522 | + | record_type: pom::types::DnsRecordType::A, | |
| 2523 | 2523 | expected: vec!["5.78.144.244".to_string()], | |
| 2524 | 2524 | actual: vec!["5.78.144.244".to_string()], | |
| 2525 | 2525 | matches: true, | |
| @@ -2605,7 +2605,7 @@ expected = ["5.78.144.244"] | |||
| 2605 | 2605 | let mnw = config.get_target("mnw").unwrap(); | |
| 2606 | 2606 | assert_eq!(mnw.dns.len(), 2); | |
| 2607 | 2607 | assert_eq!(mnw.dns[0].name, "makenot.work"); | |
| 2608 | - | assert_eq!(mnw.dns[0].record_type, "A"); | |
| 2608 | + | assert_eq!(mnw.dns[0].record_type, pom::types::DnsRecordType::A); | |
| 2609 | 2609 | assert_eq!(mnw.dns[0].expected, vec!["5.78.144.244"]); | |
| 2610 | 2610 | assert_eq!(mnw.dns[1].name, "forums.makenot.work"); | |
| 2611 | 2611 | } |