max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
17 files changed,
+875 insertions,
-20 deletions
| @@ -57,6 +57,7 @@ | |||
| 57 | 57 | - **TLS**: hosts to probe for certificate expiry warnings | |
| 58 | 58 | - **Tests**: SSH targets and commands for remote test suite execution | |
| 59 | 59 | - **Repo**: per-target local checkout (`path`, optional `subdir`) that `pom versions` counts the live build against. Optional; without it the commits-behind column is blank | |
| 60 | + | - **SyncKit fleet**: per-target readout of which SyncKit SDK versions are syncing (`base_url`, `window_days`, intervals). Reuses the alerts ingest token; informational, so an old version in the field never degrades a target | |
| 60 | 61 | ||
| 61 | 62 | ## Module Overview | |
| 62 | 63 |
| @@ -68,6 +68,18 @@ | |||
| 68 | 68 | interval_secs = 300 | |
| 69 | 69 | timeout_secs = 10 | |
| 70 | 70 | ||
| 71 | + | [targets.mnw.synckit_fleet] | |
| 72 | + | # Which SyncKit SDK versions are actually syncing. SyncKit is client-side | |
| 73 | + | # only, so there is no deployed version to poll; the server aggregates the | |
| 74 | + | # version off each sync request's User-Agent and this reads that. Authed | |
| 75 | + | # with alerts.alerts_ingest_token, so the check does not spawn without one. | |
| 76 | + | # Informational: an old version in the field never degrades the target, only | |
| 77 | + | # a readout PoM cannot take does. Localhost for the same reason as above. | |
| 78 | + | base_url = "http://127.0.0.1:3000" | |
| 79 | + | window_days = 30 | |
| 80 | + | interval_secs = 3600 | |
| 81 | + | timeout_secs = 10 | |
| 82 | + | ||
| 71 | 83 | [targets.mnw-cli] | |
| 72 | 84 | label = "MNW CLI SSH Server" | |
| 73 | 85 |
| @@ -642,6 +642,22 @@ | |||
| 642 | 642 | error: s.error, | |
| 643 | 643 | }); | |
| 644 | 644 | ||
| 645 | + | let synckit_fleet = db::get_latest_synckit_fleet_check(pool, &name) | |
| 646 | + | .await | |
| 647 | + | .ok() | |
| 648 | + | .flatten() | |
| 649 | + | .map(|f| crate::status::SyncKitFleetView { | |
| 650 | + | versions: f | |
| 651 | + | .version_list() | |
| 652 | + | .into_iter() | |
| 653 | + | .map(|v| (v.client_version, v.devices)) | |
| 654 | + | .collect(), | |
| 655 | + | devices: f.devices, | |
| 656 | + | window_days: f.window_days, | |
| 657 | + | checked_at: f.checked_at, | |
| 658 | + | error: f.error, | |
| 659 | + | }); | |
| 660 | + | ||
| 645 | 661 | // Tests: the latest run plus PoM's staleness verdict, sourced exactly as | |
| 646 | 662 | // build_target_status does (version at test time vs current version). | |
| 647 | 663 | let tests = if let Some(tests_config) = &target_config.tests { | |
| @@ -725,6 +741,7 @@ | |||
| 725 | 741 | backups, | |
| 726 | 742 | scan_pipeline, | |
| 727 | 743 | systemd, | |
| 744 | + | synckit_fleet, | |
| 728 | 745 | tests, | |
| 729 | 746 | dns, | |
| 730 | 747 | cors, |
| @@ -270,6 +270,9 @@ | |||
| 270 | 270 | /// Scan-pipeline health check against `<base_url>/admin/uploads/health.json`. | |
| 271 | 271 | /// `None` disables. | |
| 272 | 272 | pub scan_pipeline: Option<ScanPipelineConfig>, | |
| 273 | + | /// SyncKit field-version readout against | |
| 274 | + | /// `<base_url>/api/internal/synckit/client-versions`. `None` disables. | |
| 275 | + | pub synckit_fleet: Option<SyncKitFleetConfig>, | |
| 273 | 276 | /// Local checkout of the code this target runs, used by `pom versions` to | |
| 274 | 277 | /// count how far the live build is behind. `None` leaves that column blank. | |
| 275 | 278 | pub repo: Option<RepoConfig>, | |
| @@ -359,6 +362,41 @@ | |||
| 359 | 362 | 10 | |
| 360 | 363 | } | |
| 361 | 364 | ||
| 365 | + | /// SyncKit field-version readout for a target. | |
| 366 | + | /// | |
| 367 | + | /// Carries no token of its own: the endpoint is authed with the same | |
| 368 | + | /// `alerts.alerts_ingest_token` PoM already holds for pushing alerts to MNW, and | |
| 369 | + | /// copying that secret into a second config block would mean a rotation has two | |
| 370 | + | /// places to miss. Without an `alerts_ingest_token`, this check does not spawn. | |
| 371 | + | #[derive(Debug, Clone, Deserialize)] | |
| 372 | + | pub struct SyncKitFleetConfig { | |
| 373 | + | /// Base URL of the makenotwork instance (e.g. "https://makenot.work"). | |
| 374 | + | pub base_url: String, | |
| 375 | + | /// Activity window handed to the server as `?days=`. Defaults to 30, the | |
| 376 | + | /// server's own default; it clamps anything outside 1..=365. | |
| 377 | + | #[serde(default = "default_synckit_fleet_window_days")] | |
| 378 | + | pub window_days: u32, | |
| 379 | + | /// Check interval. Defaults to 3600s. | |
| 380 | + | #[serde(default = "default_synckit_fleet_interval")] | |
| 381 | + | pub interval_secs: u64, | |
| 382 | + | /// HTTP request timeout. Defaults to 10s. | |
| 383 | + | #[serde(default = "default_synckit_fleet_timeout")] | |
| 384 | + | pub timeout_secs: u64, | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | fn default_synckit_fleet_window_days() -> u32 { | |
| 388 | + | 30 | |
| 389 | + | } | |
| 390 | + | fn default_synckit_fleet_interval() -> u64 { | |
| 391 | + | // Hourly. A fleet's version mix moves when users update, which is days-scale; | |
| 392 | + | // polling it as often as a liveness check would buy nothing and put a | |
| 393 | + | // GROUP BY over sync_devices on a five-minute timer. | |
| 394 | + | 3600 | |
| 395 | + | } | |
| 396 | + | fn default_synckit_fleet_timeout() -> u64 { | |
| 397 | + | 10 | |
| 398 | + | } | |
| 399 | + | ||
| 362 | 400 | #[derive(Debug, Clone, Deserialize)] | |
| 363 | 401 | pub struct DnsRecord { | |
| 364 | 402 | /// Hostname to resolve (e.g. "makenot.work"). |
| @@ -345,7 +345,7 @@ | |||
| 345 | 345 | /// Format prune results for CLI display. | |
| 346 | 346 | pub fn format_prune(result: &PruneResult, days: i64) -> String { | |
| 347 | 347 | format!( | |
| 348 | - | "Pruned {} health checks, {} test runs, {} test details, {} peer heartbeats, {} alerts, {} TLS checks, {} incidents, {} route checks, {} DNS checks, {} WHOIS checks, {} backup checks, {} systemd checks older than {} days.\n", | |
| 348 | + | "Pruned {} health checks, {} test runs, {} test details, {} peer heartbeats, {} alerts, {} TLS checks, {} incidents, {} route checks, {} DNS checks, {} WHOIS checks, {} backup checks, {} systemd checks, {} synckit fleet checks older than {} days.\n", | |
| 349 | 349 | result.health, | |
| 350 | 350 | result.tests, | |
| 351 | 351 | result.test_details, | |
| @@ -358,6 +358,7 @@ | |||
| 358 | 358 | result.whois, | |
| 359 | 359 | result.backups, | |
| 360 | 360 | result.systemd, | |
| 361 | + | result.synckit_fleet, | |
| 361 | 362 | days | |
| 362 | 363 | ) | |
| 363 | 364 | } | |
| @@ -1230,11 +1231,12 @@ | |||
| 1230 | 1231 | whois: 2, | |
| 1231 | 1232 | backups: 1, | |
| 1232 | 1233 | systemd: 6, | |
| 1234 | + | synckit_fleet: 7, | |
| 1233 | 1235 | }; | |
| 1234 | 1236 | let out = format_prune(&result, 30); | |
| 1235 | 1237 | assert_eq!( | |
| 1236 | 1238 | out, | |
| 1237 | - | "Pruned 5 health checks, 3 test runs, 15 test details, 10 peer heartbeats, 2 alerts, 1 TLS checks, 4 incidents, 0 route checks, 8 DNS checks, 2 WHOIS checks, 1 backup checks, 6 systemd checks older than 30 days.\n" | |
| 1239 | + | "Pruned 5 health checks, 3 test runs, 15 test details, 10 peer heartbeats, 2 alerts, 1 TLS checks, 4 incidents, 0 route checks, 8 DNS checks, 2 WHOIS checks, 1 backup checks, 6 systemd checks, 7 synckit fleet checks older than 30 days.\n" | |
| 1238 | 1240 | ); | |
| 1239 | 1241 | } | |
| 1240 | 1242 | ||
| @@ -1253,9 +1255,10 @@ | |||
| 1253 | 1255 | whois: 0, | |
| 1254 | 1256 | backups: 0, | |
| 1255 | 1257 | systemd: 0, | |
| 1258 | + | synckit_fleet: 0, | |
| 1256 | 1259 | }; | |
| 1257 | 1260 | let out = format_prune(&result, 7); | |
| 1258 | - | assert!(out.contains("Pruned 0 health checks, 0 test runs, 0 test details, 0 peer heartbeats, 0 alerts, 0 TLS checks, 0 incidents, 0 route checks, 0 DNS checks, 0 WHOIS checks, 0 backup checks, 0 systemd checks older than 7 days.")); | |
| 1261 | + | assert!(out.contains("Pruned 0 health checks, 0 test runs, 0 test details, 0 peer heartbeats, 0 alerts, 0 TLS checks, 0 incidents, 0 route checks, 0 DNS checks, 0 WHOIS checks, 0 backup checks, 0 systemd checks, 0 synckit fleet checks older than 7 days.")); | |
| 1259 | 1262 | } | |
| 1260 | 1263 | ||
| 1261 | 1264 | // format_mesh |
| @@ -80,6 +80,8 @@ | |||
| 80 | 80 | pub scan_pipeline: Option<ScanView>, | |
| 81 | 81 | /// Latest local systemd daemon-health check. `None` if not monitored. | |
| 82 | 82 | pub systemd: Option<SystemdView>, | |
| 83 | + | /// Latest SyncKit field-version readout. `None` if not monitored. | |
| 84 | + | pub synckit_fleet: Option<SyncKitFleetView>, | |
| 83 | 85 | /// Latest test run and PoM's staleness verdict. `None` if the target has no | |
| 84 | 86 | /// test config. | |
| 85 | 87 | pub tests: Option<TestsView>, | |
| @@ -144,6 +146,21 @@ | |||
| 144 | 146 | pub error: Option<String>, | |
| 145 | 147 | } | |
| 146 | 148 | ||
| 149 | + | /// A SyncKit field-version readout. No status field: the reading cannot be | |
| 150 | + | /// unhealthy, only unavailable. See [`crate::checks::synckit_fleet`]. | |
| 151 | + | pub(crate) struct SyncKitFleetView { | |
| 152 | + | /// Devices that synced within the server's activity window. | |
| 153 | + | pub devices: i64, | |
| 154 | + | /// The window that count covers, in days. | |
| 155 | + | pub window_days: i64, | |
| 156 | + | /// Version and device count, most devices first. `None` version means | |
| 157 | + | /// "syncing, version unknown". | |
| 158 | + | pub versions: Vec<(Option<String>, i64)>, | |
| 159 | + | pub checked_at: String, | |
| 160 | + | /// Why the readout could not be taken. `None` on a successful reading. | |
| 161 | + | pub error: Option<String>, | |
| 162 | + | } | |
| 163 | + | ||
| 147 | 164 | pub(crate) struct TestsView { | |
| 148 | 165 | /// Whether any run has ever been recorded. Separates "no evidence" | |
| 149 | 166 | /// (pending) from a run that failed. | |
| @@ -221,6 +238,9 @@ | |||
| 221 | 238 | if let Some(sd) = &target.systemd { | |
| 222 | 239 | conditions.push(systemd_condition(sd)); | |
| 223 | 240 | } | |
| 241 | + | if let Some(fleet) = &target.synckit_fleet { | |
| 242 | + | conditions.push(synckit_fleet_condition(fleet)); | |
| 243 | + | } | |
| 224 | 244 | if let Some(tests) = &target.tests { | |
| 225 | 245 | conditions.push(tests_condition(tests)); | |
| 226 | 246 | } | |
| @@ -463,6 +483,59 @@ | |||
| 463 | 483 | } | |
| 464 | 484 | } | |
| 465 | 485 | ||
| 486 | + | /// The SyncKit field-version readout as a condition. Only two outcomes, and | |
| 487 | + | /// neither of them depends on which versions came back: `ok` when the reading was | |
| 488 | + | /// taken (the detail is the distribution), `degraded` when it could not be. No | |
| 489 | + | /// version mix can fail this condition, because a user still on an old SDK is a | |
| 490 | + | /// fact about the world rather than an incident PoM can page anyone about. A | |
| 491 | + | /// broken readout *is* worth yellow: PoM has stopped being able to answer "which | |
| 492 | + | /// SyncKit is in the field" and would otherwise go on reporting green while | |
| 493 | + | /// knowing nothing. It stays out of red because whether the platform is up is the | |
| 494 | + | /// `health` condition's job. | |
| 495 | + | fn synckit_fleet_condition(fleet: &SyncKitFleetView) -> Condition { | |
| 496 | + | let since = parse_instant(&fleet.checked_at); | |
| 497 | + | let (status, detail) = match &fleet.error { | |
| 498 | + | Some(error) => ( | |
| 499 | + | Status::Degraded, | |
| 500 | + | format!("fleet readout unavailable: {error}"), | |
| 501 | + | ), | |
| 502 | + | None if fleet.devices == 0 => ( | |
| 503 | + | Status::Ok, | |
| 504 | + | format!("no devices synced in {}d", fleet.window_days), | |
| 505 | + | ), | |
| 506 | + | None => ( | |
| 507 | + | Status::Ok, | |
| 508 | + | format!( | |
| 509 | + | "{} device{} in {}d: {}", | |
| 510 | + | fleet.devices, | |
| 511 | + | plural(fleet.devices as usize), | |
| 512 | + | fleet.window_days, | |
| 513 | + | fleet_summary(&fleet.versions) | |
| 514 | + | ), | |
| 515 | + | ), | |
| 516 | + | }; | |
| 517 | + | Condition { | |
| 518 | + | condition_type: "synckit_fleet".into(), | |
| 519 | + | status, | |
| 520 | + | since, | |
| 521 | + | detail: Some(detail), | |
| 522 | + | } | |
| 523 | + | } | |
| 524 | + | ||
| 525 | + | /// The distribution as one line, e.g. `0.6.0 x12, unknown x3`. An absent version | |
| 526 | + | /// is spelled out rather than dropped: a fleet that is half unknown is a real | |
| 527 | + | /// reading and hiding the unknowns would overstate what PoM knows. | |
| 528 | + | fn fleet_summary(versions: &[(Option<String>, i64)]) -> String { | |
| 529 | + | if versions.is_empty() { | |
| 530 | + | return "no versions reported".into(); | |
| 531 | + | } | |
| 532 | + | versions | |
| 533 | + | .iter() | |
| 534 | + | .map(|(version, devices)| format!("{} x{devices}", version.as_deref().unwrap_or("unknown"))) | |
| 535 | + | .collect::<Vec<_>>() | |
| 536 | + | .join(", ") | |
| 537 | + | } | |
| 538 | + | ||
| 466 | 539 | /// Test state as a condition. A never-run target is `pending`: evidence of | |
| 467 | 540 | /// nothing, like one never health-checked. A failing or stale run is `degraded`, | |
| 468 | 541 | /// not `failed`: a red suite is a real regression signal, but the running | |
| @@ -626,6 +699,28 @@ | |||
| 626 | 699 | }, | |
| 627 | 700 | )); | |
| 628 | 701 | } | |
| 702 | + | // The fleet distribution is a field, not just a condition detail: it is the | |
| 703 | + | // answer to a question an operator asks on purpose ("which SyncKit is out | |
| 704 | + | // there"), not a by-product of something being wrong. A successful readout of | |
| 705 | + | // zero devices still reports, so the number is visibly zero rather than | |
| 706 | + | // missing. | |
| 707 | + | if let Some(fleet) = &target.synckit_fleet | |
| 708 | + | && fleet.error.is_none() | |
| 709 | + | { | |
| 710 | + | fields.push(Field::new( | |
| 711 | + | "synckit fleet", | |
| 712 | + | Value::Text { | |
| 713 | + | value: fleet_summary(&fleet.versions), | |
| 714 | + | }, | |
| 715 | + | )); | |
| 716 | + | fields.push(Field::new( | |
| 717 | + | "synckit devices", | |
| 718 | + | Value::Quantity { | |
| 719 | + | value: fleet.devices as f64, | |
| 720 | + | unit: None, | |
| 721 | + | }, | |
| 722 | + | )); | |
| 723 | + | } | |
| 629 | 724 | if let Some(checked) = target | |
| 630 | 725 | .health | |
| 631 | 726 | .as_ref() | |
| @@ -675,6 +770,7 @@ | |||
| 675 | 770 | backups: Vec::new(), | |
| 676 | 771 | scan_pipeline: None, | |
| 677 | 772 | systemd: None, | |
| 773 | + | synckit_fleet: None, | |
| 678 | 774 | tests: None, | |
| 679 | 775 | dns: None, | |
| 680 | 776 | cors: None, | |
| @@ -1106,6 +1202,120 @@ | |||
| 1106 | 1202 | assert!(scan.detail.as_deref().unwrap().contains("502 Bad Gateway")); | |
| 1107 | 1203 | } | |
| 1108 | 1204 | ||
| 1205 | + | fn fleet_view(versions: Vec<(Option<&str>, i64)>) -> SyncKitFleetView { | |
| 1206 | + | SyncKitFleetView { | |
| 1207 | + | devices: versions.iter().map(|(_, d)| d).sum(), | |
| 1208 | + | window_days: 30, | |
| 1209 | + | versions: versions | |
| 1210 | + | .into_iter() | |
| 1211 | + | .map(|(v, d)| (v.map(str::to_string), d)) | |
| 1212 | + | .collect(), | |
| 1213 | + | checked_at: checked_at(), | |
| 1214 | + | error: None, | |
| 1215 | + | } | |
| 1216 | + | } | |
| 1217 | + | ||
| 1218 | + | #[test] | |
| 1219 | + | fn a_fleet_readout_reports_the_distribution_without_degrading() { | |
| 1220 | + | let mut t = healthy("mnw"); | |
| 1221 | + | t.synckit_fleet = Some(fleet_view(vec![(Some("0.6.0"), 12), (None, 3)])); | |
| 1222 | + | let p = payload(&[t], now()); | |
| 1223 | + | ||
| 1224 | + | let n = node(&p, "target:mnw"); | |
| 1225 | + | assert_eq!(n.status, Status::Ok); | |
| 1226 | + | let fleet = n | |
| 1227 | + | .conditions | |
| 1228 | + | .iter() | |
| 1229 | + | .find(|c| c.condition_type == "synckit_fleet") | |
| 1230 | + | .unwrap(); | |
| 1231 | + | assert_eq!(fleet.status, Status::Ok); | |
| 1232 | + | let detail = fleet.detail.as_deref().unwrap(); | |
| 1233 | + | assert!(detail.contains("15 devices in 30d"), "got {detail}"); | |
| 1234 | + | assert!(detail.contains("0.6.0 x12"), "got {detail}"); | |
| 1235 | + | assert!(detail.contains("unknown x3"), "got {detail}"); | |
| 1236 | + | } | |
| 1237 | + | ||
| 1238 | + | #[test] | |
| 1239 | + | fn an_ancient_version_in_the_field_does_not_degrade_the_target() { | |
| 1240 | + | // The whole design call: version age is a fact, not an incident. A fleet | |
| 1241 | + | // entirely on a year-old SDK must still read green. | |
| 1242 | + | let mut t = healthy("mnw"); | |
| 1243 | + | t.synckit_fleet = Some(fleet_view(vec![(Some("0.1.0"), 200)])); | |
| 1244 | + | let p = payload(&[t], now()); | |
| 1245 | + | ||
| 1246 | + | let n = node(&p, "target:mnw"); | |
| 1247 | + | assert_eq!(n.status, Status::Ok); | |
| 1248 | + | } | |
| 1249 | + | ||
| 1250 | + | #[test] | |
| 1251 | + | fn an_empty_fleet_is_ok_and_says_so() { | |
| 1252 | + | let mut t = healthy("mnw"); | |
| 1253 | + | t.synckit_fleet = Some(fleet_view(vec![])); | |
| 1254 | + | let p = payload(&[t], now()); | |
| 1255 | + | ||
| 1256 | + | let n = node(&p, "target:mnw"); | |
| 1257 | + | assert_eq!(n.status, Status::Ok); | |
| 1258 | + | let fleet = n | |
| 1259 | + | .conditions | |
| 1260 | + | .iter() | |
| 1261 | + | .find(|c| c.condition_type == "synckit_fleet") | |
| 1262 | + | .unwrap(); | |
| 1263 | + | assert!( | |
| 1264 | + | fleet | |
| 1265 | + | .detail | |
| 1266 | + | .as_deref() | |
| 1267 | + | .unwrap() | |
| 1268 | + | .contains("no devices synced in 30d") | |
| 1269 | + | ); | |
| 1270 | + | } | |
| 1271 | + | ||
| 1272 | + | #[test] | |
| 1273 | + | fn an_unavailable_fleet_readout_degrades_but_never_fails() { | |
| 1274 | + | let mut t = healthy("mnw"); | |
| 1275 | + | let mut fleet = fleet_view(vec![]); | |
| 1276 | + | fleet.error = Some("HTTP 401 (alerts ingest token rejected)".into()); | |
| 1277 | + | t.synckit_fleet = Some(fleet); | |
| 1278 | + | let p = payload(&[t], now()); | |
| 1279 | + | ||
| 1280 | + | let n = node(&p, "target:mnw"); | |
| 1281 | + | assert_eq!( | |
| 1282 | + | n.status, | |
| 1283 | + | Status::Degraded, | |
| 1284 | + | "a readout PoM cannot take is yellow, not red: health owns whether MNW is up" | |
| 1285 | + | ); | |
| 1286 | + | let fleet = n | |
| 1287 | + | .conditions | |
| 1288 | + | .iter() | |
| 1289 | + | .find(|c| c.condition_type == "synckit_fleet") | |
| 1290 | + | .unwrap(); | |
| 1291 | + | assert!(fleet.detail.as_deref().unwrap().contains("401")); | |
| 1292 | + | } | |
| 1293 | + | ||
| 1294 | + | #[test] | |
| 1295 | + | fn a_successful_readout_becomes_target_fields() { | |
| 1296 | + | let mut t = healthy("mnw"); | |
| 1297 | + | t.synckit_fleet = Some(fleet_view(vec![(Some("0.6.0"), 12)])); | |
| 1298 | + | let p = payload(&[t], now()); | |
| 1299 | + | ||
| 1300 | + | let n = node(&p, "target:mnw"); | |
| 1301 | + | assert!(n.fields.iter().any(|f| f.label == "synckit fleet")); | |
| 1302 | + | assert!(n.fields.iter().any(|f| f.label == "synckit devices")); | |
| 1303 | + | } | |
| 1304 | + | ||
| 1305 | + | #[test] | |
| 1306 | + | fn an_unavailable_readout_contributes_no_fields() { | |
| 1307 | + | // A blank column beats a field reading "0 devices" that is really "PoM | |
| 1308 | + | // could not ask". | |
| 1309 | + | let mut t = healthy("mnw"); | |
| 1310 | + | let mut fleet = fleet_view(vec![]); | |
| 1311 | + | fleet.error = Some("request: timed out".into()); | |
| 1312 | + | t.synckit_fleet = Some(fleet); | |
| 1313 | + | let p = payload(&[t], now()); | |
| 1314 | + | ||
| 1315 | + | let n = node(&p, "target:mnw"); | |
| 1316 | + | assert!(!n.fields.iter().any(|f| f.label.starts_with("synckit"))); | |
| 1317 | + | } | |
| 1318 | + | ||
| 1109 | 1319 | fn tests_view(passed: bool, stale: bool) -> TestsView { | |
| 1110 | 1320 | TestsView { | |
| 1111 | 1321 | ran: true, |
| @@ -507,6 +507,41 @@ | |||
| 507 | 507 | pub error: Option<String>, | |
| 508 | 508 | } | |
| 509 | 509 | ||
| 510 | + | /// One SDK version and how many devices are on it. | |
| 511 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 512 | + | pub struct SyncKitVersionSnapshot { | |
| 513 | + | /// `None` means "syncing, version unknown": a client from before the SDK | |
| 514 | + | /// sent a User-Agent, or a value the server would not vouch for. Not a | |
| 515 | + | /// missing reading, an answer. | |
| 516 | + | pub client_version: Option<String>, | |
| 517 | + | pub devices: i64, | |
| 518 | + | /// Most recent sync from any device on this version, RFC 3339. | |
| 519 | + | pub last_seen_at: Option<String>, | |
| 520 | + | } | |
| 521 | + | ||
| 522 | + | /// Which SyncKit SDK versions are actually syncing against a target. | |
| 523 | + | /// | |
| 524 | + | /// Carries no `status` field, unlike every other check result, because there is | |
| 525 | + | /// no bad state to be in: see [`crate::checks::synckit_fleet`]. `error` being set | |
| 526 | + | /// is the only thing that can be wrong, and it means PoM could not take the | |
| 527 | + | /// reading, not that the fleet is unhealthy. | |
| 528 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 529 | + | pub struct SyncKitFleetCheckResult { | |
| 530 | + | /// Config key identifying the monitored target. | |
| 531 | + | pub target: String, | |
| 532 | + | /// Activity window the server aggregated over, echoed back from its reply. | |
| 533 | + | pub window_days: i32, | |
| 534 | + | /// Devices that synced within the window, across all versions. | |
| 535 | + | pub devices: i64, | |
| 536 | + | /// Distribution, server-ordered (most devices first). | |
| 537 | + | pub versions: Vec<SyncKitVersionSnapshot>, | |
| 538 | + | /// When this check was performed, in RFC 3339 format (UTC). | |
| 539 | + | pub checked_at: String, | |
| 540 | + | /// Why the readout could not be produced. `None` on a successful reading, | |
| 541 | + | /// including a reading of zero devices. | |
| 542 | + | pub error: Option<String>, | |
| 543 | + | } | |
| 544 | + | ||
| 510 | 545 | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 511 | 546 | pub struct SystemdUnitSnapshot { | |
| 512 | 547 | /// Unit name (e.g. "sandod.service"). |
| @@ -413,7 +413,7 @@ | |||
| 413 | 413 | // A fresh in-memory DB should run all migrations and reach the latest version. | |
| 414 | 414 | let pool = db::connect_in_memory().await.unwrap(); | |
| 415 | 415 | let version = db::get_schema_version(&pool).await.unwrap(); | |
| 416 | - | assert_eq!(version, 12); | |
| 416 | + | assert_eq!(version, 13); | |
| 417 | 417 | ||
| 418 | 418 | // Verify the schema_version table has entries for each migration | |
| 419 | 419 | let rows = sqlx::query_as::<_, (i64, String)>( | |
| @@ -422,7 +422,7 @@ | |||
| 422 | 422 | .fetch_all(&pool) | |
| 423 | 423 | .await | |
| 424 | 424 | .unwrap(); | |
| 425 | - | assert_eq!(rows.len(), 12); | |
| 425 | + | assert_eq!(rows.len(), 13); | |
| 426 | 426 | assert_eq!(rows[0].0, 1); | |
| 427 | 427 | assert_eq!(rows[0].1, "initial schema"); | |
| 428 | 428 | assert_eq!(rows[1].0, 2); | |
| @@ -447,6 +447,8 @@ | |||
| 447 | 447 | assert_eq!(rows[10].1, "add scan_pipeline_checks table"); | |
| 448 | 448 | assert_eq!(rows[11].0, 12); | |
| 449 | 449 | assert_eq!(rows[11].1, "add systemd_checks table"); | |
| 450 | + | assert_eq!(rows[12].0, 13); | |
| 451 | + | assert_eq!(rows[12].1, "add synckit_fleet_checks table"); | |
| 450 | 452 | ||
| 451 | 453 | // Verify actual tables were created by inserting data | |
| 452 | 454 | let snapshot = HealthSnapshot { | |
| @@ -466,18 +468,18 @@ | |||
| 466 | 468 | async fn migration_already_current_is_idempotent() { | |
| 467 | 469 | // Running migrations on an already-migrated DB should be a no-op. | |
| 468 | 470 | let pool = db::connect_in_memory().await.unwrap(); | |
| 469 | - | assert_eq!(db::get_schema_version(&pool).await.unwrap(), 12); | |
| 471 | + | assert_eq!(db::get_schema_version(&pool).await.unwrap(), 13); | |
| 470 | 472 | ||
| 471 | 473 | // Run migrations again | |
| 472 | 474 | db::run_migrations(&pool).await.unwrap(); | |
| 473 | - | assert_eq!(db::get_schema_version(&pool).await.unwrap(), 12); | |
| 475 | + | assert_eq!(db::get_schema_version(&pool).await.unwrap(), 13); | |
| 474 | 476 | ||
| 475 | - | // schema_version should still have exactly twelve entries (not duplicated) | |
| 477 | + | // schema_version should still have exactly thirteen entries (not duplicated) | |
| 476 | 478 | let count = sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM schema_version") | |
| 477 | 479 | .fetch_one(&pool) | |
| 478 | 480 | .await | |
| 479 | 481 | .unwrap(); | |
| 480 | - | assert_eq!(count.0, 12); | |
| 482 | + | assert_eq!(count.0, 13); | |
| 481 | 483 | } | |
| 482 | 484 | ||
| 483 | 485 | #[tokio::test] | |
| @@ -536,8 +538,8 @@ | |||
| 536 | 538 | // Now run migrations, should detect existing tables, stamp as v1, then run v2+v3+v4+v5+v6 | |
| 537 | 539 | db::run_migrations(&pool).await.unwrap(); | |
| 538 | 540 | ||
| 539 | - | // Version should be 12 (stamped v1 + ran v2..v12) | |
| 540 | - | assert_eq!(db::get_schema_version(&pool).await.unwrap(), 12); | |
| 541 | + | // Version should be 13 (stamped v1 + ran v2..v13) | |
| 542 | + | assert_eq!(db::get_schema_version(&pool).await.unwrap(), 13); | |
| 541 | 543 | ||
| 542 | 544 | // Description should indicate pre-existing | |
| 543 | 545 | let row = | |
| @@ -858,7 +860,7 @@ | |||
| 858 | 860 | async fn migration_v2_creates_alerts_table() { | |
| 859 | 861 | let pool = db::connect_in_memory().await.unwrap(); | |
| 860 | 862 | let version = db::get_schema_version(&pool).await.unwrap(); | |
| 861 | - | assert_eq!(version, 12); | |
| 863 | + | assert_eq!(version, 13); | |
| 862 | 864 | ||
| 863 | 865 | // Verify alerts table exists by inserting | |
| 864 | 866 | let id = db::insert_alert( | |
| @@ -944,7 +946,7 @@ | |||
| 944 | 946 | async fn migration_v3_creates_tls_checks_table() { | |
| 945 | 947 | let pool = db::connect_in_memory().await.unwrap(); | |
| 946 | 948 | let version = db::get_schema_version(&pool).await.unwrap(); | |
| 947 | - | assert_eq!(version, 12); | |
| 949 | + | assert_eq!(version, 13); | |
| 948 | 950 | ||
| 949 | 951 | // Verify tls_checks table exists by inserting | |
| 950 | 952 | let status = pom::types::TlsStatus { | |
| @@ -1182,7 +1184,7 @@ | |||
| 1182 | 1184 | async fn migration_v4_creates_incidents_table() { | |
| 1183 | 1185 | let pool = db::connect_in_memory().await.unwrap(); | |
| 1184 | 1186 | let version = db::get_schema_version(&pool).await.unwrap(); | |
| 1185 | - | assert_eq!(version, 12); | |
| 1187 | + | assert_eq!(version, 13); | |
| 1186 | 1188 | ||
| 1187 | 1189 | // Verify incidents table exists by inserting | |
| 1188 | 1190 | let id = db::insert_incident(&pool, "mnw", "operational", "degraded") | |
| @@ -1322,7 +1324,7 @@ | |||
| 1322 | 1324 | async fn migration_v5_creates_route_checks_table() { | |
| 1323 | 1325 | let pool = db::connect_in_memory().await.unwrap(); | |
| 1324 | 1326 | let version = db::get_schema_version(&pool).await.unwrap(); | |
| 1325 | - | assert_eq!(version, 12); | |
| 1327 | + | assert_eq!(version, 13); | |
| 1326 | 1328 | ||
| 1327 | 1329 | // Verify route_checks table exists by inserting | |
| 1328 | 1330 | let result = pom::checks::routes::RouteCheckResult { | |
| @@ -2471,7 +2473,7 @@ | |||
| 2471 | 2473 | async fn migration_v6_creates_dns_and_whois_tables() { | |
| 2472 | 2474 | let pool = db::connect_in_memory().await.unwrap(); | |
| 2473 | 2475 | let version = db::get_schema_version(&pool).await.unwrap(); | |
| 2474 | - | assert_eq!(version, 12); | |
| 2476 | + | assert_eq!(version, 13); | |
| 2475 | 2477 | ||
| 2476 | 2478 | // Verify dns_checks table exists | |
| 2477 | 2479 | let dns_result = DnsCheckResult { | |
| @@ -2648,6 +2650,115 @@ | |||
| 2648 | 2650 | assert_eq!(row.days_remaining, Some(261)); | |
| 2649 | 2651 | } | |
| 2650 | 2652 | ||
| 2653 | + | #[tokio::test] | |
| 2654 | + | async fn synckit_fleet_check_insert_and_query() { | |
| 2655 | + | let pool = db::connect_in_memory().await.unwrap(); | |
| 2656 | + | ||
| 2657 | + | let result = SyncKitFleetCheckResult { | |
| 2658 | + | target: "mnw".to_string(), | |
| 2659 | + | window_days: 30, | |
| 2660 | + | devices: 15, | |
| 2661 | + | versions: vec![ | |
| 2662 | + | SyncKitVersionSnapshot { | |
| 2663 | + | client_version: Some("0.6.0".to_string()), | |
| 2664 | + | devices: 12, | |
| 2665 | + | last_seen_at: Some("2026-07-29T12:00:00Z".to_string()), | |
| 2666 | + | }, | |
| 2667 | + | SyncKitVersionSnapshot { | |
| 2668 | + | client_version: None, | |
| 2669 | + | devices: 3, | |
| 2670 | + | last_seen_at: Some("2026-07-20T09:00:00Z".to_string()), | |
| 2671 | + | }, | |
| 2672 | + | ], | |
| 2673 | + | checked_at: "2026-07-29T13:00:00Z".to_string(), | |
| 2674 | + | error: None, | |
| 2675 | + | }; | |
| 2676 | + | db::insert_synckit_fleet_check(&pool, &result) | |
| 2677 | + | .await | |
| 2678 | + | .unwrap(); | |
| 2679 | + | ||
| 2680 | + | let row = db::get_latest_synckit_fleet_check(&pool, "mnw") | |
| 2681 | + | .await | |
| 2682 | + | .unwrap() | |
| 2683 | + | .expect("readout should be stored"); | |
| 2684 | + | assert_eq!(row.devices, 15); | |
| 2685 | + | assert_eq!(row.window_days, 30); | |
| 2686 | + | assert!(row.error.is_none()); | |
| 2687 | + | ||
| 2688 | + | let versions = row.version_list(); | |
| 2689 | + | assert_eq!(versions.len(), 2); | |
| 2690 | + | assert_eq!(versions[0].client_version.as_deref(), Some("0.6.0")); | |
| 2691 | + | assert_eq!(versions[0].devices, 12); | |
| 2692 | + | // The unknown bucket must survive the JSON round trip as null, not as the | |
| 2693 | + | // string "unknown": the readout distinguishes "no version reported" from a | |
| 2694 | + | // client that literally called itself that. | |
| 2695 | + | assert!(versions[1].client_version.is_none()); | |
| 2696 | + | assert_eq!(versions[1].devices, 3); | |
| 2697 | + | } | |
| 2698 | + | ||
| 2699 | + | #[tokio::test] | |
| 2700 | + | async fn synckit_fleet_check_returns_latest() { | |
| 2701 | + | let pool = db::connect_in_memory().await.unwrap(); | |
| 2702 | + | ||
| 2703 | + | for (devices, checked_at) in [(3, "2026-07-28T00:00:00Z"), (9, "2026-07-29T00:00:00Z")] { | |
| 2704 | + | let result = SyncKitFleetCheckResult { | |
| 2705 | + | target: "mnw".to_string(), | |
| 2706 | + | window_days: 30, | |
| 2707 | + | devices, | |
| 2708 | + | versions: Vec::new(), | |
| 2709 | + | checked_at: checked_at.to_string(), | |
| 2710 | + | error: None, | |
| 2711 | + | }; | |
| 2712 | + | db::insert_synckit_fleet_check(&pool, &result) | |
| 2713 | + | .await | |
| 2714 | + | .unwrap(); | |
| 2715 | + | } | |
| 2716 | + | ||
| 2717 | + | // History is kept rather than overwritten, so the read must be the newest row. | |
| 2718 | + | let row = db::get_latest_synckit_fleet_check(&pool, "mnw") | |
| 2719 | + | .await | |
| 2720 | + | .unwrap() | |
| 2721 | + | .unwrap(); | |
| 2722 | + | assert_eq!(row.devices, 9); | |
| 2723 | + | } | |
| 2724 | + | ||
| 2725 | + | #[tokio::test] | |
| 2726 | + | async fn synckit_fleet_check_stores_an_unavailable_readout() { | |
| 2727 | + | let pool = db::connect_in_memory().await.unwrap(); | |
| 2728 | + | ||
| 2729 | + | // A failed readout is persisted, not dropped: otherwise the ledger keeps | |
| 2730 | + | // serving the last good distribution and nothing shows that PoM went blind. | |
| 2731 | + | let result = SyncKitFleetCheckResult { | |
| 2732 | + | target: "mnw".to_string(), | |
| 2733 | + | window_days: 0, | |
| 2734 | + | devices: 0, | |
| 2735 | + | versions: Vec::new(), | |
| 2736 | + | checked_at: "2026-07-29T13:00:00Z".to_string(), | |
| 2737 | + | error: Some("HTTP 401 (alerts ingest token rejected)".to_string()), | |
| 2738 | + | }; | |
| 2739 | + | db::insert_synckit_fleet_check(&pool, &result) | |
| 2740 | + | .await | |
| 2741 | + | .unwrap(); | |
| 2742 | + | ||
| 2743 | + | let row = db::get_latest_synckit_fleet_check(&pool, "mnw") | |
| 2744 | + | .await | |
| 2745 | + | .unwrap() | |
| 2746 | + | .unwrap(); | |
| 2747 | + | assert!(row.error.as_deref().unwrap().contains("401")); | |
| 2748 | + | assert!(row.version_list().is_empty()); | |
| 2749 | + | } | |
| 2750 | + | ||
| 2751 | + | #[tokio::test] | |
| 2752 | + | async fn synckit_fleet_check_returns_none_for_unknown_target() { | |
| 2753 | + | let pool = db::connect_in_memory().await.unwrap(); | |
| 2754 | + | assert!( | |
| 2755 | + | db::get_latest_synckit_fleet_check(&pool, "nope") | |
| 2756 | + | .await | |
| 2757 | + | .unwrap() | |
| 2758 | + | .is_none() | |
| 2759 | + | ); | |
| 2760 | + | } | |
| 2761 | + | ||
| 2651 | 2762 | #[tokio::test] | |
| 2652 | 2763 | async fn whois_check_returns_latest() { | |
| 2653 | 2764 | let pool = db::connect_in_memory().await.unwrap(); |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | //! Check implementations, one module per probe kind: health, TLS, DNS, WHOIS, | |
| 2 | - | //! routes, CORS, backups, SSH, port scans, local systemd units, plus | |
| 3 | - | //! latency-drift analysis. | |
| 2 | + | //! routes, CORS, backups, SSH, port scans, local systemd units, the SyncKit | |
| 3 | + | //! field-version readout, plus latency-drift analysis. | |
| 4 | 4 | ||
| 5 | 5 | pub mod backup; | |
| 6 | 6 | pub mod cors; | |
| @@ -12,6 +12,7 @@ | |||
| 12 | 12 | pub mod scan_pipeline; | |
| 13 | 13 | pub mod ssh; | |
| 14 | 14 | pub mod ssh_banner; | |
| 15 | + | pub mod synckit_fleet; | |
| 15 | 16 | pub mod systemd; | |
| 16 | 17 | pub mod tls; | |
| 17 | 18 | pub mod whois; |
| @@ -115,6 +115,8 @@ | |||
| 115 | 115 | &token, | |
| 116 | 116 | alerter.as_ref(), | |
| 117 | 117 | )); | |
| 118 | + | // No alerter: the fleet readout has nothing to alert on. See the task module. | |
| 119 | + | handles.extend(tasks::spawn_synckit_fleet_tasks(config, pool, &token)); | |
| 118 | 120 | handles.push(tasks::spawn_prune_task(pool, prune_days, &token)); | |
| 119 | 121 | ||
| 120 | 122 | // Spawn peer heartbeat tasks |
| @@ -18,6 +18,7 @@ | |||
| 18 | 18 | pub whois: u64, | |
| 19 | 19 | pub backups: u64, | |
| 20 | 20 | pub systemd: u64, | |
| 21 | + | pub synckit_fleet: u64, | |
| 21 | 22 | } | |
| 22 | 23 | ||
| 23 | 24 | /// Delete records older than `days` from all tables. | |
| @@ -40,6 +41,7 @@ | |||
| 40 | 41 | whois: 0, | |
| 41 | 42 | backups: 0, | |
| 42 | 43 | systemd: 0, | |
| 44 | + | synckit_fleet: 0, | |
| 43 | 45 | }); | |
| 44 | 46 | } | |
| 45 | 47 | ||
| @@ -121,6 +123,11 @@ | |||
| 121 | 123 | .execute(pool) | |
| 122 | 124 | .await?; | |
| 123 | 125 | ||
| 126 | + | let synckit_fleet_result = sqlx::query("DELETE FROM synckit_fleet_checks WHERE checked_at < ?") | |
| 127 | + | .bind(&cutoff_str) | |
| 128 | + | .execute(pool) | |
| 129 | + | .await?; | |
| 130 | + | ||
| 124 | 131 | Ok(PruneResult { | |
| 125 | 132 | health: health_result.rows_affected(), | |
| 126 | 133 | tests: test_result.rows_affected(), | |
| @@ -134,5 +141,6 @@ | |||
| 134 | 141 | whois: whois_result.rows_affected(), | |
| 135 | 142 | backups: backups_result.rows_affected(), | |
| 136 | 143 | systemd: systemd_result.rows_affected(), | |
| 144 | + | synckit_fleet: synckit_fleet_result.rows_affected(), | |
| 137 | 145 | }) | |
| 138 | 146 | } |
| @@ -262,6 +262,22 @@ | |||
| 262 | 262 | CREATE INDEX IF NOT EXISTS idx_systemd_checks_target ON systemd_checks(target, id DESC); | |
| 263 | 263 | ", | |
| 264 | 264 | ), | |
| 265 | + | ( | |
| 266 | + | 13, | |
| 267 | + | "add synckit_fleet_checks table", | |
| 268 | + | r" | |
| 269 | + | CREATE TABLE IF NOT EXISTS synckit_fleet_checks ( | |
| 270 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 271 | + | target TEXT NOT NULL, | |
| 272 | + | window_days INTEGER NOT NULL, | |
| 273 | + | devices INTEGER NOT NULL, | |
| 274 | + | versions TEXT NOT NULL, -- JSON array of version snapshots | |
| 275 | + | checked_at TEXT NOT NULL, | |
| 276 | + | error TEXT | |
| 277 | + | ); | |
| 278 | + | CREATE INDEX IF NOT EXISTS idx_synckit_fleet_checks_target ON synckit_fleet_checks(target, id DESC); | |
| 279 | + | ", | |
| 280 | + | ), | |
| 265 | 281 | ]; | |
| 266 | 282 | ||
| 267 | 283 | #[instrument(skip_all)] |