Skip to main content

max / pom

Typed enums (AlertCategory, DnsRecordType), Postmark send timeout - AlertCategory enum (18 variants) replaces raw &str alert types - DnsRecordType enum (A/AAAA/CNAME/MX/TXT) replaces String, now Copy+Hash - Postmark email send wrapped in 30s tokio timeout - JSON field comparison uses Cow<str> to avoid cloning Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-22 05:29 UTC
Commit: fa85ab4ff9af2755d13f5bb9de51e3afcb75fe8d
Parent: 20b0c7f
9 files changed, +257 insertions, -70 deletions
M src/alerts.rs +36 -28
@@ -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,
M src/api.rs +6 -6
@@ -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
M src/checks/dns.rs +10 -11
@@ -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();
M src/config.rs +4 -3
@@ -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 }
M src/db.rs +2 -1
@@ -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)
M src/types.rs +180 -2
@@ -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 }